changes
This commit is contained in:
581
docs/specs/2026-04-03-fusion-accounting-design.md
Normal file
581
docs/specs/2026-04-03-fusion-accounting-design.md
Normal file
@@ -0,0 +1,581 @@
|
||||
# fusion_accounting -- AI Accounting Co-Pilot for Odoo 19
|
||||
|
||||
**Module:** `fusion_accounting`
|
||||
**Version:** 1.0
|
||||
**Odoo:** 19.0 Enterprise
|
||||
**Date:** April 3, 2026
|
||||
**Status:** Design Specification
|
||||
|
||||
---
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
An Odoo 19 module that embeds an AI agent (Claude / GPT with tool-calling) into the Accounting menu. The agent can query, analyze, reconcile, audit, and report on every aspect of the accounting system through a conversational interface backed by a dashboard.
|
||||
|
||||
The module serves three user types: business owner (strategic oversight), office staff (daily processing), and external accountant (period-end work). It starts with tiered permissions (read-free, low-risk auto-approved, high-risk requires approval) and adapts over time by promoting tool+scenario combinations that achieve high approval rates.
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture
|
||||
|
||||
### 2.1 Component Overview
|
||||
|
||||
```
|
||||
fusion_accounting (Odoo Module)
|
||||
├── AI Service Layer
|
||||
│ ├── Agent orchestrator (prompt + tool dispatch)
|
||||
│ ├── Claude adapter (Anthropic API, tool-calling)
|
||||
│ ├── GPT adapter (OpenAI API, function-calling)
|
||||
│ └── Provider config (switchable via settings)
|
||||
│
|
||||
├── Tool Layer (73+ Odoo ORM wrappers across 12 domains)
|
||||
│ ├── Each tool: name, description, parameters, tier, domain
|
||||
│ ├── Registered in fusion.accounting.tool model
|
||||
│ └── Callable by AI via orchestrator dispatch
|
||||
│
|
||||
├── Rules Engine (Fusion Rules)
|
||||
│ ├── fusion.accounting.rule model
|
||||
│ ├── Created by admin or AI (with approval)
|
||||
│ ├── Applied before general AI reasoning
|
||||
│ └── Versioned with rollback
|
||||
│
|
||||
├── Memory Layer
|
||||
│ ├── fusion.accounting.match.history (every suggestion + outcome)
|
||||
│ ├── fusion.accounting.session (chat sessions)
|
||||
│ └── Confidence scoring per tool+scenario
|
||||
│
|
||||
├── Dashboard
|
||||
│ ├── Health cards (bank recon, AR, AP, HST, audit, month-end)
|
||||
│ ├── Action center (prioritized needs-attention + recent activity)
|
||||
│ └── Chat panel (persistent, context-aware)
|
||||
│
|
||||
└── Odoo Integration
|
||||
├── Menu: Accounting > Fusion AI
|
||||
├── Cron: periodic audit scan on posted entries
|
||||
├── Hook: post-action_post audit check (optional)
|
||||
└── Security: role-based tool access
|
||||
```
|
||||
|
||||
### 2.2 Data Flow
|
||||
|
||||
User message or dashboard click enters the controller. The controller builds a prompt containing: the user message, active Fusion Rules, recent match history, current Odoo context (page, journal, period), and the tool definitions for the relevant domain(s). This prompt goes to the selected AI provider.
|
||||
|
||||
The AI responds with either a text message or one or more tool calls. Tool calls are dispatched to the tool layer, which executes ORM operations against Odoo. Results return to the AI for further reasoning or final response. Tier 3 tool calls are intercepted and presented to the user for approval before execution.
|
||||
|
||||
### 2.3 AI Provider Integration
|
||||
|
||||
Both Claude (Anthropic) and GPT (OpenAI) are supported via adapters that normalize the tool-calling interface.
|
||||
|
||||
- **Claude:** Uses `tools` parameter with `input_schema` per tool. Responses include `tool_use` content blocks.
|
||||
- **GPT:** Uses `functions` parameter with JSON Schema per function. Responses include `function_call` in the message.
|
||||
- **Adapter pattern:** Each adapter translates between the normalized internal tool format and the provider-specific format. Switching providers requires only changing a config setting.
|
||||
|
||||
API keys are stored in `ir.config_parameter` with the `fusion_accounting.` prefix.
|
||||
|
||||
---
|
||||
|
||||
## 3. Tool Catalog
|
||||
|
||||
### 3.1 Tool Tiers
|
||||
|
||||
| Tier | Behavior | Examples |
|
||||
|---|---|---|
|
||||
| **1 -- Free** | Execute immediately, no approval. All read-only operations. | `get_unreconciled_bank_lines`, `calculate_hst_balance`, `get_ar_aging` |
|
||||
| **2 -- Auto-approved** | Execute immediately, logged. Low-risk writes that annotate but don't change financial data. | `flag_entry`, `set_audit_status`, `send_followup` (draft) |
|
||||
| **3 -- Requires approval** | AI proposes, user approves/rejects. Financial writes. | `match_bank_line_to_payments`, `reconcile_payment_to_invoice`, `create_payroll_journal_entry` |
|
||||
|
||||
Tier promotion: when a Tier 3 tool+scenario combination reaches a configurable accuracy threshold (default 95%) over a minimum sample size (default 30 decisions), it is promoted to Tier 2. Promotions can be reverted by the admin.
|
||||
|
||||
### 3.2 Domain 1 -- Bank Reconciliation
|
||||
|
||||
| Tool | Tier | Odoo Method | Purpose |
|
||||
|---|---|---|---|
|
||||
| `get_unreconciled_bank_lines` | 1 | `account.bank.statement.line.search_read(is_reconciled=False)` | List unreconciled bank statement lines with filters |
|
||||
| `get_unreconciled_receipts` | 1 | `account.move.line.search_read(account_id=1122, reconciled=False)` | List unreconciled Outstanding Receipts entries |
|
||||
| `match_bank_line_to_payments` | 3 | `account.bank.statement.line.set_line_bank_statement_line(move_line_ids)` | Match a bank line to one or more payment journal items |
|
||||
| `auto_reconcile_bank_lines` | 3 | `account.bank.statement.line._try_auto_reconcile_statement_lines()` | Run Odoo's built-in auto-reconciliation |
|
||||
| `apply_reconcile_model` | 3 | `account.reconcile.model._trigger_reconciliation_model(st_line)` | Apply a specific reconciliation model/rule |
|
||||
| `unmatch_bank_line` | 3 | `account.bank.statement.line.action_unreconcile_entry()` | Undo a reconciliation |
|
||||
| `get_reconcile_suggestions` | 1 | `account.reconcile.model.get_available_reconcile_model_per_statement_line()` | Get Odoo's suggested reconciliation models |
|
||||
| `sum_payments_by_date` | 1 | SQL aggregate on `account.move.line` | Sum card payments for a date range (Elavon batch matching) |
|
||||
|
||||
### 3.3 Domain 2 -- HST/GST Management
|
||||
|
||||
| Tool | Tier | Purpose |
|
||||
|---|---|---|
|
||||
| `calculate_hst_balance` | 1 | Net HST position (collected on 2005 minus ITCs on 2006) for a period |
|
||||
| `get_tax_report` | 1 | Generate tax report via `account.report` generic tax handler |
|
||||
| `find_missing_tax_invoices` | 1 | Invoices with taxable products but no tax applied |
|
||||
| `find_missing_itc_bills` | 1 | Vendor bills without input tax credits |
|
||||
| `get_tax_return_status` | 1 | Status of periodic tax returns via `account.return` |
|
||||
| `generate_tax_return` | 2 | Refresh tax return data via `account.return._generate_or_refresh_all_returns()` |
|
||||
| `validate_tax_return` | 3 | Mark tax return as validated |
|
||||
|
||||
### 3.4 Domain 3 -- Accounts Receivable
|
||||
|
||||
| Tool | Tier | Purpose |
|
||||
|---|---|---|
|
||||
| `get_ar_aging` | 1 | AR aging buckets (current, 30, 60, 90+) |
|
||||
| `get_overdue_invoices` | 1 | Invoices past due with partner contact info |
|
||||
| `get_partner_balance` | 1 | Single partner AR balance and open items |
|
||||
| `send_followup` | 2 | Draft follow-up email via `res.partner.execute_followup()` |
|
||||
| `get_followup_report` | 1 | HTML follow-up report for a partner |
|
||||
| `reconcile_payment_to_invoice` | 3 | Match payment to invoice via `account.move.line.reconcile()` |
|
||||
| `get_unmatched_payments` | 1 | Payments not matched to invoices |
|
||||
|
||||
### 3.5 Domain 4 -- Accounts Payable
|
||||
|
||||
| Tool | Tier | Purpose |
|
||||
|---|---|---|
|
||||
| `get_ap_aging` | 1 | AP aging buckets |
|
||||
| `find_duplicate_bills` | 1 | Same vendor + amount + date within configurable window |
|
||||
| `match_bill_to_po` | 1 | Cross-reference bill lines to PO lines |
|
||||
| `get_unpaid_bills` | 1 | Vendor bills with outstanding balance |
|
||||
| `verify_bill_taxes` | 1 | Check bill tax vs fiscal position expectation |
|
||||
| `get_payment_schedule` | 1 | Bills sorted by due date for cash planning |
|
||||
|
||||
### 3.6 Domain 5 -- Journal Review and Error Detection
|
||||
|
||||
| Tool | Tier | Purpose |
|
||||
|---|---|---|
|
||||
| `find_wrong_direction_balances` | 1 | Accounts where balance direction contradicts account type |
|
||||
| `find_duplicate_entries` | 1 | Entries with matching partner + amount + date + journal |
|
||||
| `find_wrong_account_entries` | 1 | Product lines on unlikely accounts (e.g., revenue on tax account) |
|
||||
| `find_sequence_gaps` | 1 | `account.move` records where `made_sequence_gap = true` |
|
||||
| `find_draft_entries` | 1 | Draft entries that should have been posted or deleted |
|
||||
| `find_unreconciled_suspense` | 1 | Suspense/clearing accounts with non-zero balance |
|
||||
| `verify_reconciliation_integrity` | 1 | Check `account.partial.reconcile` consistency |
|
||||
|
||||
### 3.7 Domain 6 -- Month-End / Year-End Close
|
||||
|
||||
| Tool | Tier | Purpose |
|
||||
|---|---|---|
|
||||
| `get_close_checklist` | 1 | Aggregate all domain checks into a period close checklist |
|
||||
| `get_unreconciled_counts` | 1 | Per-account count of unreconciled items |
|
||||
| `find_entries_in_locked_period` | 1 | Entries after lock dates |
|
||||
| `get_accrual_status` | 1 | Balance on accrual accounts (vacation, sick, etc.) |
|
||||
| `run_hash_integrity_check` | 1 | `res.company._check_hash_integrity()` |
|
||||
| `get_period_summary` | 1 | Trial balance for the closing period |
|
||||
|
||||
### 3.8 Domain 7 -- Payroll Verification
|
||||
|
||||
| Tool | Tier | Purpose |
|
||||
|---|---|---|
|
||||
| `get_payroll_entries` | 1 | Journal entries in payroll-related journals |
|
||||
| `compare_payroll_to_bank` | 1 | Cross-reference payroll cheques to bank statement lines |
|
||||
| `verify_source_deductions` | 1 | CPP + EI + tax calculation verification against CRA tables |
|
||||
| `get_cra_remittance_status` | 1 | CRA payable balance vs payments made |
|
||||
| `find_unmatched_payroll_cheques` | 1 | Bank cheques without matching payroll entry |
|
||||
|
||||
### 3.9 Domain 8 -- Inventory and COGS
|
||||
|
||||
| Tool | Tier | Purpose |
|
||||
|---|---|---|
|
||||
| `get_stock_valuation` | 1 | Stock In Hand (1069) balance and layers |
|
||||
| `get_price_differences` | 1 | Entries on account 5010 (PO price vs bill price) |
|
||||
| `get_cogs_ratio_by_category` | 1 | COGS vs revenue per product category |
|
||||
| `find_unusual_adjustments` | 1 | Large inventory adjustment entries |
|
||||
| `get_inventory_turnover` | 1 | Sales vs average inventory |
|
||||
|
||||
### 3.10 Domain 9 -- ADP Reconciliation
|
||||
|
||||
| Tool | Tier | Purpose |
|
||||
|---|---|---|
|
||||
| `get_adp_receivable_aging` | 1 | Aging on account 1101 (ADP Receivable) |
|
||||
| `match_adp_payment_to_invoice` | 3 | Match ADP deposit to ADP invoices |
|
||||
| `verify_adp_split` | 1 | Customer portion + ADP portion = invoice total |
|
||||
| `find_adp_without_payment` | 1 | ADP invoices without matching government deposit |
|
||||
| `get_adp_summary` | 1 | Period summary of ADP billing vs collection |
|
||||
|
||||
### 3.11 Domain 10 -- Financial Reporting
|
||||
|
||||
| Tool | Tier | Purpose |
|
||||
|---|---|---|
|
||||
| `get_profit_loss` | 1 | P&L via `account.report` |
|
||||
| `get_balance_sheet` | 1 | Balance sheet via `account.report` |
|
||||
| `get_trial_balance` | 1 | Trial balance via `account.report` |
|
||||
| `get_cash_flow` | 1 | Cash flow via `account.report` |
|
||||
| `compare_periods` | 1 | Two period reports side by side |
|
||||
| `answer_financial_question` | 1 | Natural language to ORM/SQL query |
|
||||
| `export_report` | 2 | `account.report.export_to_pdf()` or `export_to_xlsx()` |
|
||||
|
||||
### 3.12 Domain 11 -- Audit and Integrity
|
||||
|
||||
| Tool | Tier | Purpose |
|
||||
|---|---|---|
|
||||
| `audit_posted_entry` | 1 | Run all entry-level checks on a single `account.move` |
|
||||
| `audit_account_balances` | 1 | Run all account-level checks (wrong direction, stale items) |
|
||||
| `audit_tax_compliance` | 1 | All tax checks (missing tax, wrong rate, exempt verification) |
|
||||
| `audit_reconciliation_integrity` | 1 | Verify `account.partial.reconcile` / `account.full.reconcile` consistency |
|
||||
| `check_hash_chain` | 1 | `res.company._check_hash_integrity()` |
|
||||
| `check_sequence_gaps` | 1 | `account.journal._query_has_sequence_holes()` |
|
||||
| `flag_entry` | 2 | Create chatter message on `account.move` with flag and recommendation |
|
||||
| `get_audit_status` | 1 | `account.audit.account.status` per tax return |
|
||||
| `set_audit_status` | 2 | Update review status (todo / reviewed / supervised / anomaly) |
|
||||
| `get_audit_trail` | 1 | `mail.message` history for an `account.move` |
|
||||
| `run_full_audit` | 1 | All checks across all domains for a period |
|
||||
| `get_audit_report` | 1 | Summary of all findings with severity ratings |
|
||||
|
||||
### 3.13 Domain 12 -- Payroll Management
|
||||
|
||||
| Tool | Tier | Purpose |
|
||||
|---|---|---|
|
||||
| `parse_payroll_summary` | 1 | Read pasted/uploaded QBO or fusion_payroll data |
|
||||
| `create_payroll_journal_entry` | 3 | `account.move.create()` with payroll debit/credit lines |
|
||||
| `get_payroll_schedule` | 1 | Employee pay dates, amounts, history |
|
||||
| `match_payroll_cheques` | 3 | Match bank cheques to payroll liabilities |
|
||||
| `verify_payroll_deductions` | 1 | Check CPP/EI/tax against CRA rate tables |
|
||||
| `get_cra_remittance_due` | 1 | Calculate CRA obligation vs payments made |
|
||||
| `prepare_cra_payment` | 3 | Create CRA remittance payment entry |
|
||||
| `generate_t4` | 2 | Trigger `fusion_payroll` T4 generation |
|
||||
| `generate_roe` | 2 | Trigger `fusion_payroll` ROE generation |
|
||||
| `get_payroll_cost_report` | 1 | Period summary by employee/department |
|
||||
|
||||
Phase 1 (QBO bridge): tools work with pasted/uploaded payroll data.
|
||||
Phase 2 (fusion_payroll native): tools call fusion_payroll ORM methods directly.
|
||||
|
||||
---
|
||||
|
||||
## 4. Fusion Rules Engine
|
||||
|
||||
### 4.1 Rule Model
|
||||
|
||||
```
|
||||
fusion.accounting.rule
|
||||
├── name Char, required
|
||||
├── rule_type Selection: match / classify / audit / fee / routing / followup
|
||||
├── description Text (natural language, read by AI)
|
||||
├── trigger_domain Text/JSON (Odoo domain filter for matching records)
|
||||
├── match_logic Text (natural language matching instructions for AI)
|
||||
├── match_code Text (optional Python for deterministic matching)
|
||||
├── fee_account_id Many2one → account.account
|
||||
├── write_off_account_id Many2one → account.account
|
||||
├── approval_tier Selection: auto / needs_approval
|
||||
├── created_by Selection: admin / ai
|
||||
├── confidence_score Float (0.0 to 1.0)
|
||||
├── total_uses Integer
|
||||
├── total_approved Integer
|
||||
├── total_rejected Integer
|
||||
├── promotion_threshold Float (default 0.95)
|
||||
├── min_sample_size Integer (default 30)
|
||||
├── active Boolean
|
||||
├── version Integer
|
||||
├── parent_rule_id Many2one → self (version chain)
|
||||
├── journal_ids Many2many → account.journal
|
||||
├── company_id Many2one → res.company
|
||||
├── notes Text
|
||||
```
|
||||
|
||||
### 4.2 Rule Lifecycle
|
||||
|
||||
1. **Creation:** Admin creates via UI form, or AI proposes after detecting a pattern (3+ identical matches). AI-proposed rules start at Tier 3.
|
||||
2. **Application:** During reconciliation or auditing, the AI loads active rules and applies them before general reasoning. Rules with `match_code` run deterministically; rules with only `match_logic` are interpreted by the AI.
|
||||
3. **Scoring:** Each use updates `total_uses` and `total_approved` / `total_rejected`. Confidence score is recalculated.
|
||||
4. **Promotion:** When confidence crosses `promotion_threshold` with at least `min_sample_size` decisions, `approval_tier` changes from `needs_approval` to `auto`.
|
||||
5. **Modification:** Admin or AI (with approval) can edit. Changes increment `version` and create a new record linked via `parent_rule_id`. Confidence resets for the modified variant.
|
||||
6. **Rollback:** Admin can deactivate current version and reactivate a previous version.
|
||||
|
||||
### 4.3 Rule Priority
|
||||
|
||||
During processing, rules are evaluated in order:
|
||||
1. Admin-created rules (highest priority)
|
||||
2. AI-created rules with auto-approval (proven patterns)
|
||||
3. AI-created rules needing approval (proposed patterns)
|
||||
4. No rule matches: AI reasons from scratch using tools
|
||||
|
||||
---
|
||||
|
||||
## 5. Match History and Learning
|
||||
|
||||
### 5.1 Match History Model
|
||||
|
||||
```
|
||||
fusion.accounting.match.history
|
||||
├── session_id Many2one → fusion.accounting.session
|
||||
├── tool_name Char (which tool was called)
|
||||
├── tool_params Text/JSON (parameters passed)
|
||||
├── tool_result Text/JSON (result returned)
|
||||
├── ai_reasoning Text (AI's explanation for the match)
|
||||
├── ai_confidence Float (AI's self-assessed confidence)
|
||||
├── rule_id Many2one → fusion.accounting.rule (if rule-based)
|
||||
├── proposed_at Datetime
|
||||
├── decision Selection: approved / rejected / pending
|
||||
├── decided_at Datetime
|
||||
├── decided_by Many2one → res.users
|
||||
├── rejection_reason Text (user's explanation if rejected)
|
||||
├── correct_action Text/JSON (what should have happened, if corrected)
|
||||
├── bank_statement_line_id Many2one → account.bank.statement.line
|
||||
├── move_line_ids Many2many → account.move.line
|
||||
├── amount Monetary
|
||||
├── partner_id Many2one → res.partner
|
||||
```
|
||||
|
||||
### 5.2 How Learning Works
|
||||
|
||||
The AI's system prompt includes the most recent N match history records (configurable, default 50) filtered to the current domain/scenario. This gives the AI context about:
|
||||
- What patterns have been approved (do more of this)
|
||||
- What was rejected and why (avoid this)
|
||||
- Partner-specific quirks (e.g., "Shirley Ramsumair: label unreliable")
|
||||
- Fee patterns (e.g., "Elavon: ~1.6% fee to 60545")
|
||||
- Timing patterns (e.g., "weekend card batches combine Fri+Sat")
|
||||
|
||||
The AI does not have persistent memory across sessions beyond what is stored in match history and rules. Every session starts fresh with the system prompt + loaded history + loaded rules.
|
||||
|
||||
---
|
||||
|
||||
## 6. Dashboard
|
||||
|
||||
### 6.1 Location
|
||||
|
||||
Accounting menu > Fusion AI (submenu, next to Dashboard).
|
||||
|
||||
### 6.2 Layout
|
||||
|
||||
**Top row: 6 health cards.** Each shows a key metric with color coding (green/yellow/red). Clicking any card starts a relevant conversation in the chat panel.
|
||||
|
||||
| Card | Metric | Source |
|
||||
|---|---|---|
|
||||
| Bank Reconciliation | Unmatched line count + total amount | `account.bank.statement.line` where `is_reconciled = False` |
|
||||
| AR Outstanding | Total receivable + overdue count | `account.move.line` on AR account, `amount_residual > 0` |
|
||||
| AP Due | Total payable + due this week | `account.move.line` on AP account, grouped by due date |
|
||||
| HST Balance | Net HST (collected minus ITCs) | Balances on accounts 2005 and 2006 |
|
||||
| Audit Score | Score 0-100 + active flag count | Weighted composite of all audit checks |
|
||||
| Month-End Status | Current period status + open items | Aggregate of close checklist items |
|
||||
|
||||
**Middle: two-column action center.**
|
||||
|
||||
Left column: "Needs Attention" -- AI-prioritized list of actionable items. Re-ranked daily by the audit cron. Items are clickable to start the relevant conversation.
|
||||
|
||||
Right column: "Recent AI Activity" -- log of autonomous actions (Tier 2), pending approvals (Tier 3), and completed conversations. Provides transparency.
|
||||
|
||||
**Bottom/Side: Chat panel.** Persistent across navigation within the Accounting module. Shows conversation history for the current session. Supports text input, file upload (for QBO payroll summaries), and structured approval cards for Tier 3 actions.
|
||||
|
||||
### 6.3 Approval Cards
|
||||
|
||||
When the AI proposes a Tier 3 action, the chat displays a structured card:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Match Proposal 94% conf │
|
||||
│ │
|
||||
│ Bank: Mar 4 elavon mrch svc $697.61 │
|
||||
│ ↔ 4 card payments (Mar 3) $709.14 │
|
||||
│ Fee to 60545 (Elavon Fee) $11.53 │
|
||||
│ │
|
||||
│ AI: "Weekend daily batch, 1.6% fee" │
|
||||
│ │
|
||||
│ [ Approve ] [ Reject ] │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Batch mode: multiple cards can be displayed at once with "Approve All" / "Reject All" buttons plus individual controls.
|
||||
|
||||
---
|
||||
|
||||
## 7. Security
|
||||
|
||||
### 7.1 Access Groups
|
||||
|
||||
| Group | Dashboard | Chat (Read) | Chat (Tier 2) | Chat (Tier 3) | Rules | Config |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `fusion_accounting.group_user` (Staff) | View | Yes | No | No | View | No |
|
||||
| `fusion_accounting.group_manager` (Manager) | View | Yes | Yes | Yes | Create/Edit | No |
|
||||
| `fusion_accounting.group_admin` (Admin) | View | Yes | Yes | Yes | Create/Edit | Yes |
|
||||
|
||||
### 7.2 Tool-Level Security
|
||||
|
||||
Each tool definition includes `required_groups`. The AI adapter filters available tools based on the current user's groups before building the prompt. A staff user's AI session simply does not have access to write tools.
|
||||
|
||||
### 7.3 Audit Trail
|
||||
|
||||
All AI actions are logged in `fusion.accounting.match.history` with the user who approved, timestamp, and full context. This is in addition to Odoo's standard chatter/mail tracking on modified records.
|
||||
|
||||
---
|
||||
|
||||
## 8. Module Structure
|
||||
|
||||
```
|
||||
/mnt/extra-addons/fusion_accounting/
|
||||
├── __manifest__.py
|
||||
├── __init__.py
|
||||
├── models/
|
||||
│ ├── __init__.py
|
||||
│ ├── accounting_session.py # Chat session model
|
||||
│ ├── accounting_match_history.py # Match history (approved/rejected)
|
||||
│ ├── accounting_rule.py # Fusion Rules
|
||||
│ ├── accounting_tool.py # Tool registry model
|
||||
│ ├── accounting_config.py # Settings (API keys, thresholds)
|
||||
│ └── accounting_dashboard.py # Dashboard computed fields
|
||||
├── services/
|
||||
│ ├── __init__.py
|
||||
│ ├── agent.py # AI orchestrator (prompt assembly, tool dispatch loop)
|
||||
│ ├── adapters/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── claude.py # Anthropic Claude adapter
|
||||
│ │ └── openai.py # OpenAI GPT adapter
|
||||
│ ├── tools/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── bank_reconciliation.py # Domain 1 tools
|
||||
│ │ ├── hst_management.py # Domain 2 tools
|
||||
│ │ ├── accounts_receivable.py # Domain 3 tools
|
||||
│ │ ├── accounts_payable.py # Domain 4 tools
|
||||
│ │ ├── journal_review.py # Domain 5 tools
|
||||
│ │ ├── month_end.py # Domain 6 tools
|
||||
│ │ ├── payroll.py # Domain 7 + 12 tools
|
||||
│ │ ├── inventory.py # Domain 8 tools
|
||||
│ │ ├── adp.py # Domain 9 tools
|
||||
│ │ ├── reporting.py # Domain 10 tools
|
||||
│ │ └── audit.py # Domain 11 tools
|
||||
│ ├── prompts/
|
||||
│ │ ├── system_prompt.py # Base system prompt
|
||||
│ │ └── domain_prompts.py # Per-domain context injections
|
||||
│ └── scoring.py # Confidence scoring + tier promotion logic
|
||||
├── controllers/
|
||||
│ ├── __init__.py
|
||||
│ └── chat_controller.py # JSON endpoint for chat messages
|
||||
├── wizards/
|
||||
│ ├── __init__.py
|
||||
│ └── rule_wizard.py # Quick-create rule from chat suggestion
|
||||
├── static/
|
||||
│ └── src/
|
||||
│ ├── components/
|
||||
│ │ ├── dashboard/
|
||||
│ │ │ ├── fusion_dashboard.js
|
||||
│ │ │ ├── fusion_dashboard.xml
|
||||
│ │ │ ├── health_card.js
|
||||
│ │ │ └── health_card.xml
|
||||
│ │ ├── chat/
|
||||
│ │ │ ├── chat_panel.js
|
||||
│ │ │ ├── chat_panel.xml
|
||||
│ │ │ ├── approval_card.js
|
||||
│ │ │ └── approval_card.xml
|
||||
│ │ └── rules/
|
||||
│ │ └── rule_form.js
|
||||
│ └── scss/
|
||||
│ ├── dashboard.scss
|
||||
│ └── chat.scss
|
||||
├── views/
|
||||
│ ├── dashboard_views.xml
|
||||
│ ├── session_views.xml
|
||||
│ ├── rule_views.xml
|
||||
│ ├── config_views.xml
|
||||
│ ├── match_history_views.xml
|
||||
│ └── menus.xml
|
||||
├── security/
|
||||
│ ├── security.xml # Groups
|
||||
│ └── ir.model.access.csv # Model access rules
|
||||
├── data/
|
||||
│ ├── cron.xml # Periodic audit scan cron
|
||||
│ ├── tool_definitions.xml # Seed tool registry
|
||||
│ └── default_rules.xml # Starter Fusion Rules
|
||||
└── report/
|
||||
└── audit_report_template.xml # PDF audit report
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Implementation Phases
|
||||
|
||||
### Phase 1: Foundation (estimated 2 weeks)
|
||||
|
||||
Build the module skeleton, AI service layer, and first 2 domains.
|
||||
|
||||
- Module skeleton: manifest, models, security, menu
|
||||
- AI service: agent orchestrator, Claude adapter, GPT adapter
|
||||
- Tool layer: Domain 1 (bank reconciliation) + Domain 5 (journal review)
|
||||
- Controller: chat endpoint (JSON-RPC)
|
||||
- Basic chat UI: simple text input/output in an Odoo form view (no OWL widget yet)
|
||||
- Match history model + logging
|
||||
- Test: reconcile a batch of bank lines via chat
|
||||
|
||||
**Milestone:** Can reconcile bank statement lines through conversation.
|
||||
|
||||
### Phase 2: Rules + More Domains (estimated 2 weeks)
|
||||
|
||||
- Fusion Rules model + CRUD views
|
||||
- AI rule proposal flow (detect pattern, suggest rule, user approves)
|
||||
- Confidence scoring + tier promotion logic
|
||||
- Domains 2 (HST), 3 (AR), 4 (AP)
|
||||
- Approval card flow for Tier 3 actions
|
||||
|
||||
**Milestone:** Can prepare HST filing, chase overdue invoices, and auto-create matching rules.
|
||||
|
||||
### Phase 3: Dashboard + Audit (estimated 2 weeks)
|
||||
|
||||
- OWL dashboard with health cards
|
||||
- OWL chat panel (persistent side panel)
|
||||
- Approval cards with approve/reject buttons
|
||||
- Domain 11 (audit) with cron-based periodic scanning
|
||||
- Domain 6 (month-end close)
|
||||
|
||||
**Milestone:** Dashboard shows accounting health at a glance. Audit cron flags issues automatically.
|
||||
|
||||
### Phase 4: Remaining Domains + Polish (estimated 2 weeks)
|
||||
|
||||
- Domains 7-10 (payroll verification, inventory, ADP, reporting)
|
||||
- Domain 12 (payroll management -- QBO bridge for Phase 1, fusion_payroll-ready)
|
||||
- Export/report tools (PDF, XLSX)
|
||||
- Batch approval mode
|
||||
- Learning/adaptation refinements
|
||||
- Documentation
|
||||
|
||||
**Milestone:** Full 12-domain AI accounting co-pilot operational.
|
||||
|
||||
---
|
||||
|
||||
## 10. Dependencies
|
||||
|
||||
### Odoo Modules (required)
|
||||
|
||||
- `account` (core accounting)
|
||||
- `account_accountant` (enterprise bank reconciliation)
|
||||
- `account_reports` (enterprise reporting + tax returns + audit status)
|
||||
- `account_followup` (AR follow-ups)
|
||||
- `mail` (chatter integration for flagging)
|
||||
|
||||
### Odoo Modules (optional, enhanced features if installed)
|
||||
|
||||
- `account_budget` (budget tools)
|
||||
- `account_asset` (asset depreciation tools)
|
||||
- `account_batch_payment` (batch payment tools)
|
||||
- `fusion_payroll` (native payroll integration for Domain 12 Phase 2)
|
||||
- `fusion_poynt` (Poynt terminal data for card payment matching)
|
||||
- `stock_account` (inventory valuation tools)
|
||||
|
||||
### External
|
||||
|
||||
- Anthropic API key (for Claude) OR OpenAI API key (for GPT) -- at least one required
|
||||
- Python packages: `anthropic`, `openai` (installed in Odoo container)
|
||||
|
||||
---
|
||||
|
||||
## 11. Configuration
|
||||
|
||||
Settings page at: Accounting > Fusion AI > Configuration
|
||||
|
||||
| Setting | Type | Default |
|
||||
|---|---|---|
|
||||
| AI Provider | Selection (claude / openai) | claude |
|
||||
| Anthropic API Key | Char (password field) | -- |
|
||||
| OpenAI API Key | Char (password field) | -- |
|
||||
| Claude Model | Char | claude-sonnet-4-20250514 |
|
||||
| OpenAI Model | Char | gpt-4o |
|
||||
| Tier 3 Promotion Threshold | Float | 0.95 |
|
||||
| Tier 3 Min Sample Size | Integer | 30 |
|
||||
| Audit Cron Frequency | Selection (daily / weekly / monthly) | daily |
|
||||
| Match History in Prompt | Integer (recent N records) | 50 |
|
||||
| Max Tool Calls Per Turn | Integer | 20 |
|
||||
| Enable Post-Action Audit Hook | Boolean | False |
|
||||
|
||||
---
|
||||
|
||||
## 12. Risks and Mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| AI calls wrong tool or wrong parameters | Tiered permissions; Tier 3 requires human approval; tool parameter validation in each tool function |
|
||||
| AI hallucinates financial data | All data comes from Odoo ORM queries, not AI generation. AI reasons about data but cannot invent it. |
|
||||
| Reconciliation error corrupts books | All reconciliation uses Odoo's native engine (`set_line_bank_statement_line`, `reconcile`). Reversible via `action_unreconcile_entry`. |
|
||||
| API costs escalate | Token usage tracked per session. Max tool calls per turn limits runaway loops. Model selection (cheaper models for simple queries). |
|
||||
| Learning from bad patterns | Confidence scoring requires minimum sample size. Admin can demote promoted tools. Rule versioning with rollback. |
|
||||
| Sensitive data in API calls | Financial data sent to AI provider. Mitigate by using AI provider's data privacy agreements. No customer PII in tool descriptions. Partner names in transaction data are necessary for matching. |
|
||||
2
fusion-statements/fusion_statements/__init__.py
Normal file
2
fusion-statements/fusion_statements/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import models
|
||||
from . import wizard
|
||||
22
fusion-statements/fusion_statements/__manifest__.py
Normal file
22
fusion-statements/fusion_statements/__manifest__.py
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
'name': 'Fusion Bank Statements',
|
||||
'version': '19.0.1.0.0',
|
||||
'category': 'Accounting',
|
||||
'summary': 'Import OFX/QFX bank statements with automatic duplicate detection',
|
||||
'description': 'Upload OFX, QFX, or QBO files exported from your bank '
|
||||
'(ScotiaConnect, TD, RBC, etc.) and import them as bank '
|
||||
'statement lines. Smart duplicate detection using the bank\'s '
|
||||
'transaction ID (fitid). No external server communication.',
|
||||
'author': 'Fusion Central',
|
||||
'website': 'https://fusionsoft.ca',
|
||||
'license': 'LGPL-3',
|
||||
'depends': ['account'],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'wizard/import_statement_views.xml',
|
||||
'views/account_journal_views.xml',
|
||||
],
|
||||
'external_dependencies': {'python': ['ofxparse']},
|
||||
'installable': True,
|
||||
'auto_install': False,
|
||||
}
|
||||
2
fusion-statements/fusion_statements/models/__init__.py
Normal file
2
fusion-statements/fusion_statements/models/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import import_log
|
||||
from . import account_journal
|
||||
@@ -0,0 +1,16 @@
|
||||
from odoo import models
|
||||
|
||||
|
||||
class AccountJournal(models.Model):
|
||||
_inherit = 'account.journal'
|
||||
|
||||
def action_open_statement_import(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'Import Bank Statement',
|
||||
'res_model': 'fusion.statement.import',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'context': {'default_journal_id': self.id},
|
||||
}
|
||||
26
fusion-statements/fusion_statements/models/import_log.py
Normal file
26
fusion-statements/fusion_statements/models/import_log.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class FusionStatementImportLog(models.Model):
|
||||
_name = 'fusion.statement.import.log'
|
||||
_description = 'Imported Bank Transaction Log'
|
||||
_order = 'date desc, id desc'
|
||||
_rec_name = 'fitid'
|
||||
|
||||
journal_id = fields.Many2one(
|
||||
'account.journal', required=True, ondelete='cascade', index=True,
|
||||
)
|
||||
fitid = fields.Char(string='Bank Transaction ID', required=True, index=True)
|
||||
date = fields.Date()
|
||||
amount = fields.Float(digits=(16, 2))
|
||||
payment_ref = fields.Char(string='Description')
|
||||
import_date = fields.Datetime(default=fields.Datetime.now, readonly=True)
|
||||
statement_line_id = fields.Many2one('account.bank.statement.line', ondelete='set null')
|
||||
company_id = fields.Many2one(
|
||||
'res.company', required=True, default=lambda self: self.env.company,
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
('journal_fitid_unique', 'UNIQUE(journal_id, fitid)',
|
||||
'This transaction has already been imported for this journal.'),
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_fusion_import_log_accountant,fusion.statement.import.log accountant,model_fusion_statement_import_log,account.group_account_invoice,1,1,1,0
|
||||
access_fusion_import_log_manager,fusion.statement.import.log manager,model_fusion_statement_import_log,account.group_account_manager,1,1,1,1
|
||||
access_fusion_import_wizard,fusion.statement.import wizard,model_fusion_statement_import,account.group_account_invoice,1,1,1,1
|
||||
access_fusion_import_line,fusion.statement.import.line wizard,model_fusion_statement_import_line,account.group_account_invoice,1,1,1,1
|
||||
|
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Add "Import Statement" button to bank journal form view -->
|
||||
<record id="view_account_journal_form_inherit_fusion" model="ir.ui.view">
|
||||
<field name="name">account.journal.form.fusion.statements</field>
|
||||
<field name="model">account.journal</field>
|
||||
<field name="inherit_id" ref="account.view_account_journal_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[@name='button_box']" position="inside">
|
||||
<button name="action_open_statement_import"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-upload"
|
||||
invisible="type != 'bank'">
|
||||
<span class="o_stat_text">Import Statement</span>
|
||||
</button>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
1
fusion-statements/fusion_statements/wizard/__init__.py
Normal file
1
fusion-statements/fusion_statements/wizard/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import import_statement
|
||||
243
fusion-statements/fusion_statements/wizard/import_statement.py
Normal file
243
fusion-statements/fusion_statements/wizard/import_statement.py
Normal file
@@ -0,0 +1,243 @@
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from ofxparse import OfxParser
|
||||
except ImportError:
|
||||
OfxParser = None
|
||||
_logger.warning("ofxparse library not installed — OFX import disabled.")
|
||||
|
||||
|
||||
class FusionStatementImportLine(models.TransientModel):
|
||||
_name = 'fusion.statement.import.line'
|
||||
_description = 'Statement Import Preview Line'
|
||||
_order = 'date desc, id desc'
|
||||
|
||||
wizard_id = fields.Many2one('fusion.statement.import', ondelete='cascade')
|
||||
selected = fields.Boolean(default=True)
|
||||
is_duplicate = fields.Boolean(readonly=True)
|
||||
fitid = fields.Char(string='Transaction ID', readonly=True)
|
||||
date = fields.Date(readonly=True)
|
||||
payment_ref = fields.Char(string='Description', readonly=True)
|
||||
amount = fields.Float(digits=(16, 2), readonly=True)
|
||||
|
||||
|
||||
class FusionStatementImport(models.TransientModel):
|
||||
_name = 'fusion.statement.import'
|
||||
_description = 'Import Bank Statement'
|
||||
|
||||
step = fields.Selection([
|
||||
('upload', 'Upload'),
|
||||
('review', 'Review'),
|
||||
], default='upload', readonly=True)
|
||||
|
||||
journal_id = fields.Many2one(
|
||||
'account.journal', string='Bank Journal', required=True,
|
||||
domain="[('type', '=', 'bank')]",
|
||||
)
|
||||
data_file = fields.Binary(string='Statement File', attachment=False)
|
||||
filename = fields.Char()
|
||||
|
||||
line_ids = fields.One2many('fusion.statement.import.line', 'wizard_id')
|
||||
|
||||
total_new = fields.Integer(compute='_compute_counts')
|
||||
total_duplicate = fields.Integer(compute='_compute_counts')
|
||||
total_selected = fields.Integer(compute='_compute_counts')
|
||||
|
||||
balance_start = fields.Float(digits=(16, 2), readonly=True)
|
||||
balance_end = fields.Float(digits=(16, 2), readonly=True)
|
||||
currency_code = fields.Char(readonly=True)
|
||||
account_number = fields.Char(readonly=True)
|
||||
|
||||
@api.depends('line_ids.selected', 'line_ids.is_duplicate')
|
||||
def _compute_counts(self):
|
||||
for rec in self:
|
||||
lines = rec.line_ids
|
||||
rec.total_new = len(lines.filtered(lambda l: not l.is_duplicate))
|
||||
rec.total_duplicate = len(lines.filtered(lambda l: l.is_duplicate))
|
||||
rec.total_selected = len(lines.filtered(lambda l: l.selected))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Step 1 → Step 2: Parse file
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def action_parse(self):
|
||||
self.ensure_one()
|
||||
if not self.data_file:
|
||||
raise UserError(_("Please upload a statement file."))
|
||||
if not OfxParser:
|
||||
raise UserError(_(
|
||||
"The 'ofxparse' Python library is not installed. "
|
||||
"Ask your administrator to run: pip install ofxparse"
|
||||
))
|
||||
|
||||
raw = base64.b64decode(self.data_file)
|
||||
try:
|
||||
ofx = OfxParser.parse(io.BytesIO(raw))
|
||||
except Exception as e:
|
||||
raise UserError(_(
|
||||
"Could not parse the file. Make sure it is a valid "
|
||||
"OFX/QFX/QBO file.\n\nError: %s"
|
||||
) % str(e)) from e
|
||||
|
||||
if not ofx.accounts:
|
||||
raise UserError(_("No accounts found in the file."))
|
||||
|
||||
account = ofx.accounts[0]
|
||||
transactions = account.statement.transactions
|
||||
if not transactions:
|
||||
raise UserError(_("No transactions found in the file."))
|
||||
|
||||
ImportLog = self.env['fusion.statement.import.log']
|
||||
existing_fitids = set(
|
||||
ImportLog.search([
|
||||
('journal_id', '=', self.journal_id.id),
|
||||
]).mapped('fitid')
|
||||
)
|
||||
|
||||
lines = []
|
||||
for tx in transactions:
|
||||
fitid = str(tx.id).strip()
|
||||
payee = tx.payee or ''
|
||||
if tx.checknum:
|
||||
payee += ' ' + tx.checknum
|
||||
if tx.memo:
|
||||
payee += ' : ' + tx.memo
|
||||
|
||||
is_dup = fitid in existing_fitids
|
||||
lines.append((0, 0, {
|
||||
'fitid': fitid,
|
||||
'date': tx.date.date() if hasattr(tx.date, 'date') else tx.date,
|
||||
'payment_ref': payee.strip(),
|
||||
'amount': float(tx.amount),
|
||||
'is_duplicate': is_dup,
|
||||
'selected': not is_dup,
|
||||
}))
|
||||
|
||||
balance = float(account.statement.balance)
|
||||
total_amt = sum(float(tx.amount) for tx in transactions)
|
||||
|
||||
self.write({
|
||||
'step': 'review',
|
||||
'line_ids': [(5, 0, 0)] + lines,
|
||||
'balance_end': balance,
|
||||
'balance_start': balance - total_amt,
|
||||
'currency_code': account.statement.currency or '',
|
||||
'account_number': account.number or '',
|
||||
})
|
||||
|
||||
return self._reopen()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Step 2: Import selected lines
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def action_import(self):
|
||||
self.ensure_one()
|
||||
selected = self.line_ids.filtered(lambda l: l.selected)
|
||||
if not selected:
|
||||
raise UserError(_("No transactions selected for import."))
|
||||
|
||||
journal = self.journal_id
|
||||
|
||||
statement = self.env['account.bank.statement'].create({
|
||||
'name': self.filename or 'OFX Import',
|
||||
'reference': self.filename or '',
|
||||
'journal_id': journal.id,
|
||||
'balance_start': self.balance_start,
|
||||
'balance_end_real': self.balance_end,
|
||||
})
|
||||
|
||||
ImportLog = self.env['fusion.statement.import.log']
|
||||
created_lines = self.env['account.bank.statement.line']
|
||||
|
||||
for line in selected.sorted('date'):
|
||||
st_line = self.env['account.bank.statement.line'].create({
|
||||
'journal_id': journal.id,
|
||||
'date': line.date,
|
||||
'payment_ref': line.payment_ref,
|
||||
'amount': line.amount,
|
||||
'statement_id': statement.id,
|
||||
})
|
||||
created_lines |= st_line
|
||||
|
||||
ImportLog.create({
|
||||
'journal_id': journal.id,
|
||||
'fitid': line.fitid,
|
||||
'date': line.date,
|
||||
'amount': line.amount,
|
||||
'payment_ref': line.payment_ref,
|
||||
'statement_line_id': st_line.id,
|
||||
'company_id': journal.company_id.id,
|
||||
})
|
||||
|
||||
all_lines = self.line_ids
|
||||
dup_count = len(all_lines.filtered(lambda l: l.is_duplicate))
|
||||
manual_skip = len(all_lines.filtered(lambda l: not l.selected and not l.is_duplicate))
|
||||
date_min = min(selected.mapped('date'))
|
||||
date_max = max(selected.mapped('date'))
|
||||
|
||||
parts = ['%d transactions imported.' % len(selected)]
|
||||
if dup_count:
|
||||
parts.append('%d duplicates detected.' % dup_count)
|
||||
if manual_skip:
|
||||
parts.append('%d manually excluded.' % manual_skip)
|
||||
parts.append('Date range: %s to %s' % (date_min, date_max))
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Bank Statement Imported'),
|
||||
'message': ' '.join(parts),
|
||||
'type': 'success',
|
||||
'sticky': False,
|
||||
'next': {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Imported Statement'),
|
||||
'res_model': 'account.bank.statement',
|
||||
'res_id': statement.id,
|
||||
'views': [(False, 'form')],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Navigation helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def action_back(self):
|
||||
self.ensure_one()
|
||||
self.write({'step': 'upload', 'line_ids': [(5, 0, 0)]})
|
||||
return self._reopen()
|
||||
|
||||
def action_select_all_new(self):
|
||||
self.ensure_one()
|
||||
for line in self.line_ids:
|
||||
line.selected = not line.is_duplicate
|
||||
return self._reopen()
|
||||
|
||||
def action_select_none(self):
|
||||
self.ensure_one()
|
||||
self.line_ids.write({'selected': False})
|
||||
return self._reopen()
|
||||
|
||||
def action_select_all(self):
|
||||
self.ensure_one()
|
||||
self.line_ids.write({'selected': True})
|
||||
return self._reopen()
|
||||
|
||||
def _reopen(self):
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': self._name,
|
||||
'res_id': self.id,
|
||||
'views': [(False, 'form')],
|
||||
'target': 'new',
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="fusion_statement_import_form" model="ir.ui.view">
|
||||
<field name="name">fusion.statement.import.form</field>
|
||||
<field name="model">fusion.statement.import</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Import Bank Statement">
|
||||
|
||||
<!-- Step 1: Upload -->
|
||||
<group invisible="step != 'upload'">
|
||||
<group>
|
||||
<field name="journal_id"/>
|
||||
<field name="data_file" filename="filename"/>
|
||||
<field name="filename" invisible="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<p class="text-muted">
|
||||
Upload an OFX, QFX, or QBO file exported from your bank portal.
|
||||
Duplicate transactions will be detected automatically.
|
||||
</p>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<!-- Step 2: Review -->
|
||||
<group invisible="step != 'review'" string="File Summary">
|
||||
<group>
|
||||
<field name="account_number" readonly="1"/>
|
||||
<field name="currency_code" readonly="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="balance_start" readonly="1"/>
|
||||
<field name="balance_end" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<div invisible="step != 'review'" class="mb-2">
|
||||
<div class="d-flex gap-2 align-items-center mb-3">
|
||||
<span class="badge text-bg-success fs-6">
|
||||
New: <field name="total_new" class="d-inline" readonly="1"/>
|
||||
</span>
|
||||
<span class="badge text-bg-warning fs-6">
|
||||
Duplicates: <field name="total_duplicate" class="d-inline" readonly="1"/>
|
||||
</span>
|
||||
<span class="badge text-bg-primary fs-6">
|
||||
Selected: <field name="total_selected" class="d-inline" readonly="1"/>
|
||||
</span>
|
||||
<span class="flex-grow-1"/>
|
||||
<button name="action_select_all_new" type="object"
|
||||
class="btn btn-secondary btn-sm">
|
||||
Select New Only
|
||||
</button>
|
||||
<button name="action_select_all" type="object"
|
||||
class="btn btn-secondary btn-sm">
|
||||
Select All
|
||||
</button>
|
||||
<button name="action_select_none" type="object"
|
||||
class="btn btn-secondary btn-sm">
|
||||
Deselect All
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<field name="line_ids" nolabel="1">
|
||||
<list editable="bottom"
|
||||
decoration-danger="is_duplicate and selected"
|
||||
decoration-muted="is_duplicate and not selected"
|
||||
decoration-success="not is_duplicate and selected">
|
||||
<field name="selected"/>
|
||||
<field name="is_duplicate" string="Dup?" widget="boolean"/>
|
||||
<field name="date"/>
|
||||
<field name="payment_ref"/>
|
||||
<field name="amount"/>
|
||||
<field name="fitid"/>
|
||||
</list>
|
||||
</field>
|
||||
</div>
|
||||
|
||||
<field name="step" invisible="1"/>
|
||||
|
||||
<footer>
|
||||
<button name="action_parse" type="object"
|
||||
string="Parse File" class="btn-primary"
|
||||
invisible="step != 'upload'"/>
|
||||
<button name="action_import" type="object"
|
||||
string="Import Selected" class="btn-primary"
|
||||
invisible="step != 'review'"/>
|
||||
<button name="action_back" type="object"
|
||||
string="Back" class="btn-secondary"
|
||||
invisible="step != 'review'"/>
|
||||
<button string="Cancel" class="btn-secondary" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fusion_statement_import" model="ir.actions.act_window">
|
||||
<field name="name">Import Bank Statement</field>
|
||||
<field name="res_model">fusion.statement.import</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="view_id" ref="fusion_statement_import_form"/>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_fusion_statement_import"
|
||||
name="Import Bank Statement (OFX)"
|
||||
parent="account.account_transactions_menu"
|
||||
action="action_fusion_statement_import"
|
||||
sequence="90"/>
|
||||
|
||||
</odoo>
|
||||
154
fusion_accounting/CLAUDE.md
Normal file
154
fusion_accounting/CLAUDE.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# fusion_accounting — AI Accounting Co-Pilot
|
||||
|
||||
## What This Module Does
|
||||
An AI agent (Claude/GPT with tool-calling) embedded in Odoo 19 Enterprise Accounting. Conversational interface backed by a dashboard for bank reconciliation, HST/GST management, AR/AP analysis, journal review, month-end close, payroll, inventory, ADP reconciliation, financial reporting, and auditing.
|
||||
|
||||
## Architecture
|
||||
```
|
||||
fusion_accounting/
|
||||
├── models/ 7 models (6 new + 1 inherit on account.move)
|
||||
├── services/
|
||||
│ ├── agent.py AI orchestrator (prompt assembly, tool dispatch loop)
|
||||
│ ├── adapters/ Claude + OpenAI adapters with native tool-calling
|
||||
│ ├── tools/ 85 tool functions across 11 domain files
|
||||
│ ├── prompts/ System prompt builder + 12 domain-specific prompts
|
||||
│ └── scoring.py Confidence scoring + tier promotion logic
|
||||
├── controllers/ 8 JSON-RPC endpoints
|
||||
├── wizards/ Rule creation wizard
|
||||
├── static/src/ OWL dashboard + chat panel + approval cards
|
||||
├── views/ List/form/search views, menus, settings
|
||||
├── security/ 3 groups (User/Manager/Admin), record rules, ACLs
|
||||
├── data/ 82 tool definitions, 2 default rules, 2 crons
|
||||
└── 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 4.5+ 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
|
||||
|
||||
### 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)
|
||||
|
||||
### 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
|
||||
|
||||
### 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
|
||||
- "New Chat" button closes current session and creates a fresh one
|
||||
|
||||
## 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)
|
||||
|
||||
### 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
|
||||
|
||||
### 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
|
||||
|
||||
### 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"
|
||||
|
||||
### 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;
|
||||
```
|
||||
|
||||
## Server Details
|
||||
- **Server**: odoo-westin (192.168.1.40, SSH via `ssh odoo-westin`)
|
||||
- **Container**: odoo-dev-app (Odoo), odoo-dev-db (PostgreSQL)
|
||||
- **Database**: westin-v19
|
||||
- **Module path**: `/mnt/extra-addons/fusion_accounting/`
|
||||
- **Python deps**: anthropic (v0.88.0), openai (v2.30.0) — installed with `--break-system-packages`
|
||||
|
||||
## Deployment Commands
|
||||
```bash
|
||||
# Deploy module to server
|
||||
ssh odoo-westin "docker exec -u 0 odoo-dev-app rm -rf /mnt/extra-addons/fusion_accounting"
|
||||
scp -r "K:\Github\Odoo-Modules\fusion_accounting" odoo-westin:/tmp/fusion_accounting
|
||||
ssh odoo-westin "docker cp /tmp/fusion_accounting odoo-dev-app:/mnt/extra-addons/fusion_accounting && rm -rf /tmp/fusion_accounting"
|
||||
|
||||
# Upgrade module (use alt port to avoid conflict with running instance)
|
||||
ssh odoo-westin "docker exec odoo-dev-app odoo -d westin-v19 -u fusion_accounting --stop-after-init --http-port=8099 -c /etc/odoo/odoo.conf"
|
||||
|
||||
# Restart container
|
||||
ssh odoo-westin "docker restart odoo-dev-app"
|
||||
|
||||
# Check logs
|
||||
ssh odoo-westin "docker logs odoo-dev-app --tail 100"
|
||||
```
|
||||
|
||||
## Security Groups
|
||||
| Group ID | XML ID | Name | Access |
|
||||
|---|---|---|---|
|
||||
| 564 | `group_fusion_accounting_user` | User | Dashboard, chat (read-only tools) |
|
||||
| 565 | `group_fusion_accounting_manager` | Manager | + Approve/reject, Tier 2 tools, rules |
|
||||
| 566 | `group_fusion_accounting_admin` | Administrator | + Config, all tools, rule admin |
|
||||
|
||||
Auto-assigned: `account.group_account_user` → User, `account.group_account_manager` → Admin
|
||||
|
||||
## Models
|
||||
| Model | Type | Purpose |
|
||||
|---|---|---|
|
||||
| `fusion.accounting.session` | Model | Chat sessions with message JSON storage |
|
||||
| `fusion.accounting.match.history` | Model | Every AI tool call + decision (approved/rejected/pending) |
|
||||
| `fusion.accounting.rule` | Model | Fusion Rules engine with versioning and auto-promotion |
|
||||
| `fusion.accounting.tool` | Model | Tool registry (82 tools seeded from XML) |
|
||||
| `fusion.accounting.dashboard` | TransientModel | Computed health metrics (use `.new()` not `.create()`) |
|
||||
| `fusion.accounting.agent` | AbstractModel | AI orchestrator |
|
||||
| `fusion.accounting.adapter.claude` | AbstractModel | Claude tool-calling adapter |
|
||||
| `fusion.accounting.adapter.openai` | AbstractModel | OpenAI tool-calling adapter |
|
||||
| `fusion.accounting.scoring` | AbstractModel | Confidence scoring |
|
||||
| `account.move` (inherit) | Model | Post-action audit hook |
|
||||
|
||||
## 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)`
|
||||
|
||||
## Known Issues / Future Work
|
||||
- `read_group()` deprecation warnings — migrate to `_read_group()` when format is documented
|
||||
- `verify_source_deductions`, `generate_t4`, `generate_roe` are stubs pointing to fusion_payroll (by design — Phase 2)
|
||||
- `account.return` model used in HST tools may not exist in all Odoo 19 setups — needs try/except guard
|
||||
- Batch approval "Approve All" / "Reject All" buttons are in the chat panel but not yet in the match history list view
|
||||
4
fusion_accounting/__init__.py
Normal file
4
fusion_accounting/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from . import models
|
||||
from . import services
|
||||
from . import controllers
|
||||
from . import wizards
|
||||
61
fusion_accounting/__manifest__.py
Normal file
61
fusion_accounting/__manifest__.py
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
'name': 'Fusion Accounting AI',
|
||||
'version': '19.0.1.0.0',
|
||||
'category': 'Accounting/Accounting',
|
||||
'sequence': 25,
|
||||
'summary': 'AI Accounting Co-Pilot with conversational interface and automated analysis',
|
||||
'description': """
|
||||
Fusion Accounting AI
|
||||
====================
|
||||
An AI-powered accounting co-pilot that embeds Claude/GPT into the Odoo Accounting
|
||||
module. Features conversational bank reconciliation, HST management, AR/AP analysis,
|
||||
audit scanning, and a comprehensive dashboard.
|
||||
|
||||
Built by Nexa Systems Inc.
|
||||
""",
|
||||
'icon': '/fusion_accounting/static/description/icon.png',
|
||||
'author': 'Nexa Systems Inc.',
|
||||
'website': 'https://nexasystems.ca',
|
||||
'support': 'support@nexasystems.ca',
|
||||
'maintainer': 'Nexa Systems Inc.',
|
||||
'depends': [
|
||||
'account',
|
||||
'account_accountant',
|
||||
'account_reports',
|
||||
'account_followup',
|
||||
'mail',
|
||||
],
|
||||
'external_dependencies': {
|
||||
'python': ['anthropic', 'openai'],
|
||||
},
|
||||
'data': [
|
||||
# Security
|
||||
'security/security.xml',
|
||||
'security/ir.model.access.csv',
|
||||
# Data
|
||||
'data/cron.xml',
|
||||
'data/tool_definitions.xml',
|
||||
'data/default_rules.xml',
|
||||
# Views
|
||||
'views/config_views.xml',
|
||||
'views/session_views.xml',
|
||||
'views/match_history_views.xml',
|
||||
'views/rule_views.xml',
|
||||
'views/dashboard_views.xml',
|
||||
'views/menus.xml',
|
||||
# Wizards
|
||||
'wizards/rule_wizard.xml',
|
||||
# Reports
|
||||
'report/audit_report_template.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'license': 'OPL-1',
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'fusion_accounting/static/src/**/*.js',
|
||||
'fusion_accounting/static/src/**/*.xml',
|
||||
'fusion_accounting/static/src/**/*.scss',
|
||||
],
|
||||
},
|
||||
}
|
||||
1
fusion_accounting/controllers/__init__.py
Normal file
1
fusion_accounting/controllers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import chat_controller
|
||||
126
fusion_accounting/controllers/chat_controller.py
Normal file
126
fusion_accounting/controllers/chat_controller.py
Normal file
@@ -0,0 +1,126 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionAccountingChatController(http.Controller):
|
||||
|
||||
@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 session.exists() and 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, **kwargs):
|
||||
if not message:
|
||||
return {'error': 'Message is required'}
|
||||
agent = request.env['fusion.accounting.agent']
|
||||
result = agent.chat(int(session_id), message, context=context)
|
||||
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.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.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):
|
||||
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},
|
||||
}
|
||||
|
||||
@http.route('/fusion_accounting/approve_all', type='jsonrpc', auth='user')
|
||||
def approve_all(self, match_history_ids, **kwargs):
|
||||
if not request.env.user.has_group('fusion_accounting.group_fusion_accounting_manager'):
|
||||
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:
|
||||
results.append({'id': mid, 'status': 'error', 'error': str(e)})
|
||||
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.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)
|
||||
results.append({'id': mid, 'status': 'rejected'})
|
||||
except Exception as e:
|
||||
results.append({'id': mid, 'status': 'error', 'error': str(e)})
|
||||
return {'results': results}
|
||||
|
||||
@http.route('/fusion_accounting/session/latest', type='jsonrpc', auth='user')
|
||||
def session_latest(self, **kwargs):
|
||||
session = request.env['fusion.accounting.session'].search([
|
||||
('user_id', '=', request.env.user.id),
|
||||
('state', '=', 'active'),
|
||||
], limit=1, order='create_date desc')
|
||||
if not session:
|
||||
return {'session_id': None, 'messages': [], 'name': None}
|
||||
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']})
|
||||
return {
|
||||
'session_id': session.id,
|
||||
'messages': display_messages,
|
||||
'name': session.name,
|
||||
}
|
||||
|
||||
@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'}
|
||||
return {
|
||||
'messages': json.loads(session.message_ids_json or '[]'),
|
||||
'session_id': session.id,
|
||||
'state': session.state,
|
||||
}
|
||||
39
fusion_accounting/data/cron.xml
Normal file
39
fusion_accounting/data/cron.xml
Normal file
@@ -0,0 +1,39 @@
|
||||
<?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>
|
||||
</odoo>
|
||||
22
fusion_accounting/data/default_rules.xml
Normal file
22
fusion_accounting/data/default_rules.xml
Normal file
@@ -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>
|
||||
700
fusion_accounting/data/tool_definitions.xml
Normal file
700
fusion_accounting/data/tool_definitions.xml
Normal file
@@ -0,0 +1,700 @@
|
||||
<?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.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.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.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.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, useful for matching card batch deposits.</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.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.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">Get a single partner's AR balance and open items.</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_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.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.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.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>
|
||||
|
||||
<!-- 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.group_fusion_accounting_manager</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.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.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.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.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.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.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.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>
|
||||
</odoo>
|
||||
7
fusion_accounting/models/__init__.py
Normal file
7
fusion_accounting/models/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
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
|
||||
53
fusion_accounting/models/account_move_hook.py
Normal file
53
fusion_accounting/models/account_move_hook.py
Normal file
@@ -0,0 +1,53 @@
|
||||
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}')
|
||||
if line.product_id and not line.tax_ids:
|
||||
if move.move_type in ('out_invoice', 'out_refund', 'in_invoice', 'in_refund'):
|
||||
issues.append(f'Missing tax on product line: {line.product_id.name}')
|
||||
|
||||
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',
|
||||
)
|
||||
84
fusion_accounting/models/accounting_config.py
Normal file
84
fusion_accounting/models/accounting_config.py
Normal file
@@ -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',
|
||||
)
|
||||
278
fusion_accounting/models/accounting_dashboard.py
Normal file
278
fusion_accounting/models/accounting_dashboard.py
Normal file
@@ -0,0 +1,278 @@
|
||||
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
|
||||
|
||||
gaps = self.env['account.move'].search_count([
|
||||
('state', '=', 'posted'),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
('made_sequence_gap', '=', True),
|
||||
])
|
||||
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 = []
|
||||
|
||||
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,
|
||||
'title': f'{unrecon} unreconciled bank lines',
|
||||
'domain': 'bank_reconciliation',
|
||||
'action': 'Review and reconcile bank statement lines',
|
||||
})
|
||||
|
||||
overdue = self.env['account.move'].search_count([
|
||||
('move_type', '=', 'out_invoice'),
|
||||
('state', '=', 'posted'),
|
||||
('payment_state', 'in', ('not_paid', 'partial')),
|
||||
('invoice_date_due', '<', fields.Date.today()),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
])
|
||||
if overdue > 0:
|
||||
attention.append({
|
||||
'priority': 2,
|
||||
'title': f'{overdue} overdue customer invoices',
|
||||
'domain': 'accounts_receivable',
|
||||
'action': 'Send follow-up reminders',
|
||||
})
|
||||
|
||||
pending = self.env['fusion.accounting.match.history'].search_count([
|
||||
('decision', '=', 'pending'),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
])
|
||||
if pending > 0:
|
||||
attention.append({
|
||||
'priority': 0,
|
||||
'title': f'{pending} AI actions awaiting approval',
|
||||
'domain': 'audit',
|
||||
'action': 'Review and approve/reject pending actions',
|
||||
})
|
||||
|
||||
drafts = self.env['account.move'].search_count([
|
||||
('state', '=', 'draft'),
|
||||
('date', '<=', fields.Date.today() - timedelta(days=30)),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
])
|
||||
if drafts > 0:
|
||||
attention.append({
|
||||
'priority': 3,
|
||||
'title': f'{drafts} stale draft entries (30+ days)',
|
||||
'domain': 'journal_review',
|
||||
'action': 'Post or delete stale draft entries',
|
||||
})
|
||||
|
||||
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': str(r.proposed_at),
|
||||
'amount': r.amount,
|
||||
} for r in recent])
|
||||
|
||||
def action_refresh(self):
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fusion_accounting.dashboard',
|
||||
}
|
||||
81
fusion_accounting/models/accounting_match_history.py
Normal file
81
fusion_accounting/models/accounting_match_history.py
Normal file
@@ -0,0 +1,81 @@
|
||||
import logging
|
||||
from odoo import models, fields, api
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionAccountingMatchHistory(models.Model):
|
||||
_name = 'fusion.accounting.match.history'
|
||||
_description = 'Fusion Accounting Match History'
|
||||
_order = 'proposed_at desc'
|
||||
|
||||
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_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,
|
||||
)
|
||||
|
||||
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)
|
||||
120
fusion_accounting/models/accounting_rule.py
Normal file
120
fusion_accounting/models/accounting_rule.py
Normal file
@@ -0,0 +1,120 @@
|
||||
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.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:
|
||||
rec.active = False
|
||||
rec.parent_rule_id.active = True
|
||||
60
fusion_accounting/models/accounting_session.py
Normal file
60
fusion_accounting/models/accounting_session.py
Normal file
@@ -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'})
|
||||
60
fusion_accounting/models/accounting_tool.py
Normal file
60
fusion_accounting/models/accounting_tool.py
Normal file
@@ -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.'),
|
||||
]
|
||||
84
fusion_accounting/report/audit_report_template.xml
Normal file
84
fusion_accounting/report/audit_report_template.xml
Normal file
@@ -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>
|
||||
13
fusion_accounting/security/ir.model.access.csv
Normal file
13
fusion_accounting/security/ir.model.access.csv
Normal file
@@ -0,0 +1,13 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_fusion_session_user,fusion.accounting.session.user,model_fusion_accounting_session,group_fusion_accounting_user,1,1,1,0
|
||||
access_fusion_session_admin,fusion.accounting.session.admin,model_fusion_accounting_session,group_fusion_accounting_admin,1,1,1,1
|
||||
access_fusion_history_user,fusion.accounting.match.history.user,model_fusion_accounting_match_history,group_fusion_accounting_user,1,0,0,0
|
||||
access_fusion_history_manager,fusion.accounting.match.history.manager,model_fusion_accounting_match_history,group_fusion_accounting_manager,1,1,1,0
|
||||
access_fusion_history_admin,fusion.accounting.match.history.admin,model_fusion_accounting_match_history,group_fusion_accounting_admin,1,1,1,1
|
||||
access_fusion_rule_user,fusion.accounting.rule.user,model_fusion_accounting_rule,group_fusion_accounting_user,1,0,0,0
|
||||
access_fusion_rule_manager,fusion.accounting.rule.manager,model_fusion_accounting_rule,group_fusion_accounting_manager,1,1,1,0
|
||||
access_fusion_rule_admin,fusion.accounting.rule.admin,model_fusion_accounting_rule,group_fusion_accounting_admin,1,1,1,1
|
||||
access_fusion_tool_user,fusion.accounting.tool.user,model_fusion_accounting_tool,group_fusion_accounting_user,1,0,0,0
|
||||
access_fusion_tool_admin,fusion.accounting.tool.admin,model_fusion_accounting_tool,group_fusion_accounting_admin,1,1,1,1
|
||||
access_fusion_dashboard_user,fusion.accounting.dashboard.user,model_fusion_accounting_dashboard,group_fusion_accounting_user,1,1,1,1
|
||||
access_fusion_rule_wizard_manager,fusion.accounting.rule.wizard.manager,model_fusion_accounting_rule_wizard,group_fusion_accounting_manager,1,1,1,1
|
||||
|
94
fusion_accounting/security/security.xml
Normal file
94
fusion_accounting/security/security.xml
Normal file
@@ -0,0 +1,94 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Module Category -->
|
||||
<record id="module_category_fusion_accounting" model="ir.module.category">
|
||||
<field name="name">Fusion Accounting AI</field>
|
||||
<field name="sequence">25</field>
|
||||
</record>
|
||||
|
||||
<!-- Groups Privilege -->
|
||||
<record id="res_groups_privilege_fusion_accounting" model="res.groups.privilege">
|
||||
<field name="name">Fusion Accounting AI</field>
|
||||
<field name="category_id" ref="module_category_fusion_accounting"/>
|
||||
</record>
|
||||
|
||||
<!-- User Group (Staff) -->
|
||||
<record id="group_fusion_accounting_user" model="res.groups">
|
||||
<field name="name">User</field>
|
||||
<field name="sequence">10</field>
|
||||
<field name="implied_ids" eval="[(4, ref('account.group_account_user'))]"/>
|
||||
<field name="privilege_id" ref="res_groups_privilege_fusion_accounting"/>
|
||||
</record>
|
||||
|
||||
<!-- Manager Group -->
|
||||
<record id="group_fusion_accounting_manager" model="res.groups">
|
||||
<field name="name">Manager</field>
|
||||
<field name="sequence">20</field>
|
||||
<field name="implied_ids" eval="[(4, ref('group_fusion_accounting_user'))]"/>
|
||||
<field name="privilege_id" ref="res_groups_privilege_fusion_accounting"/>
|
||||
</record>
|
||||
|
||||
<!-- Admin Group -->
|
||||
<record id="group_fusion_accounting_admin" model="res.groups">
|
||||
<field name="name">Administrator</field>
|
||||
<field name="sequence">30</field>
|
||||
<field name="implied_ids" eval="[(4, ref('group_fusion_accounting_manager'))]"/>
|
||||
<field name="privilege_id" ref="res_groups_privilege_fusion_accounting"/>
|
||||
</record>
|
||||
|
||||
<!-- Auto-assign: Accounting users get Fusion AI User, Advisers get Admin -->
|
||||
<record id="account.group_account_user" model="res.groups">
|
||||
<field name="implied_ids" eval="[(4, ref('group_fusion_accounting_user'))]"/>
|
||||
</record>
|
||||
<record id="account.group_account_manager" model="res.groups">
|
||||
<field name="implied_ids" eval="[(4, ref('group_fusion_accounting_admin'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- Record Rules -->
|
||||
<record id="rule_fusion_session_user" model="ir.rule">
|
||||
<field name="name">Fusion Session: Own Sessions</field>
|
||||
<field name="model_id" ref="model_fusion_accounting_session"/>
|
||||
<field name="domain_force">[('user_id', '=', user.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('group_fusion_accounting_user'))]"/>
|
||||
</record>
|
||||
|
||||
<record id="rule_fusion_session_manager" model="ir.rule">
|
||||
<field name="name">Fusion Session: All Sessions</field>
|
||||
<field name="model_id" ref="model_fusion_accounting_session"/>
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('group_fusion_accounting_manager'))]"/>
|
||||
</record>
|
||||
|
||||
<record id="rule_fusion_history_user" model="ir.rule">
|
||||
<field name="name">Fusion History: Own History</field>
|
||||
<field name="model_id" ref="model_fusion_accounting_match_history"/>
|
||||
<field name="domain_force">[('session_id.user_id', '=', user.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('group_fusion_accounting_user'))]"/>
|
||||
</record>
|
||||
|
||||
<record id="rule_fusion_history_manager" model="ir.rule">
|
||||
<field name="name">Fusion History: All History</field>
|
||||
<field name="model_id" ref="model_fusion_accounting_match_history"/>
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('group_fusion_accounting_manager'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- Multi-company rules -->
|
||||
<record id="rule_fusion_tool_company" model="ir.rule">
|
||||
<field name="name">Fusion Tool: Multi-Company</field>
|
||||
<field name="model_id" ref="model_fusion_accounting_tool"/>
|
||||
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]</field>
|
||||
</record>
|
||||
|
||||
<record id="rule_fusion_rule_company" model="ir.rule">
|
||||
<field name="name">Fusion Rule: Multi-Company</field>
|
||||
<field name="model_id" ref="model_fusion_accounting_rule"/>
|
||||
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]</field>
|
||||
</record>
|
||||
|
||||
<record id="rule_fusion_history_company" model="ir.rule">
|
||||
<field name="name">Fusion History: Multi-Company</field>
|
||||
<field name="model_id" ref="model_fusion_accounting_match_history"/>
|
||||
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]</field>
|
||||
</record>
|
||||
</odoo>
|
||||
5
fusion_accounting/services/__init__.py
Normal file
5
fusion_accounting/services/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from . import adapters
|
||||
from . import tools
|
||||
from . import prompts
|
||||
from . import agent
|
||||
from . import scoring
|
||||
2
fusion_accounting/services/adapters/__init__.py
Normal file
2
fusion_accounting/services/adapters/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import claude
|
||||
from . import openai_adapter
|
||||
141
fusion_accounting/services/adapters/claude.py
Normal file
141
fusion_accounting/services/adapters/claude.py
Normal file
@@ -0,0 +1,141 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from odoo import models, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
import anthropic as anthropic_sdk
|
||||
except ImportError:
|
||||
anthropic_sdk = None
|
||||
|
||||
|
||||
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):
|
||||
client = self._get_client()
|
||||
model = 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
|
||||
137
fusion_accounting/services/adapters/openai_adapter.py
Normal file
137
fusion_accounting/services/adapters/openai_adapter.py
Normal file
@@ -0,0 +1,137 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from odoo import models, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from openai import OpenAI
|
||||
except ImportError:
|
||||
OpenAI = None
|
||||
|
||||
|
||||
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):
|
||||
client = self._get_client()
|
||||
model = 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
|
||||
315
fusion_accounting/services/agent.py
Normal file
315
fusion_accounting/services/agent.py
Normal file
@@ -0,0 +1,315 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
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 _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:
|
||||
defn = {
|
||||
'name': tool.name,
|
||||
'description': tool.description,
|
||||
}
|
||||
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) if params else '{}',
|
||||
'tool_result': json.dumps(result) 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):
|
||||
session = self.env['fusion.accounting.session'].browse(session_id)
|
||||
if not session.exists():
|
||||
raise UserError(_("Session not found."))
|
||||
|
||||
adapter = self._get_adapter()
|
||||
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 '[]')
|
||||
messages_json.append({'role': 'user', 'content': user_message})
|
||||
|
||||
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}
|
||||
|
||||
for turn in range(max_turns):
|
||||
response = adapter.call_with_tools(
|
||||
system_prompt=system_prompt,
|
||||
messages=messages_json,
|
||||
tools=tool_definitions,
|
||||
)
|
||||
total_tokens_in += response.get('tokens_in', 0)
|
||||
total_tokens_out += response.get('tokens_out', 0)
|
||||
|
||||
if response.get('tool_calls'):
|
||||
tool_results = []
|
||||
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'
|
||||
|
||||
if tier == '3':
|
||||
history_rec = self._log_match_history(
|
||||
session, tool_name, tool_params, None,
|
||||
reasoning=tc.get('reasoning', ''),
|
||||
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}',
|
||||
}),
|
||||
})
|
||||
else:
|
||||
result = self._execute_tool(tool_name, tool_params, session.id)
|
||||
self._log_match_history(
|
||||
session, tool_name, tool_params, result,
|
||||
reasoning=tc.get('reasoning', ''),
|
||||
tier=tier,
|
||||
)
|
||||
tool_results.append({
|
||||
'tool_call_id': tc.get('id', ''),
|
||||
'result': json.dumps(result) if not isinstance(result, str) else result,
|
||||
})
|
||||
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.tool_call_count += len(tool_results)
|
||||
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=[],
|
||||
)
|
||||
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': self._get_config('ai_provider', 'claude'),
|
||||
'ai_model': adapter._get_model_name(),
|
||||
})
|
||||
|
||||
pending = self.env['fusion.accounting.match.history'].search([
|
||||
('session_id', '=', session.id),
|
||||
('decision', '=', 'pending'),
|
||||
])
|
||||
|
||||
return {
|
||||
'text': response.get('text', ''),
|
||||
'pending_approvals': [{
|
||||
'id': p.id,
|
||||
'tool_name': p.tool_name,
|
||||
'params': p.tool_params,
|
||||
'reasoning': p.ai_reasoning,
|
||||
'confidence': p.ai_confidence,
|
||||
'amount': p.amount,
|
||||
} for p in pending],
|
||||
'session_id': session.id,
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
return {'status': 'rejected', 'reason': reason}
|
||||
2
fusion_accounting/services/prompts/__init__.py
Normal file
2
fusion_accounting/services/prompts/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import system_prompt
|
||||
from . import domain_prompts
|
||||
109
fusion_accounting/services/prompts/domain_prompts.py
Normal file
109
fusion_accounting/services/prompts/domain_prompts.py
Normal file
@@ -0,0 +1,109 @@
|
||||
DOMAIN_PROMPTS = {
|
||||
'bank_reconciliation': """
|
||||
BANK RECONCILIATION CONTEXT:
|
||||
You are helping with bank statement reconciliation. Key concepts:
|
||||
- Bank statement lines (account.bank.statement.line) represent transactions from the bank feed.
|
||||
- Each line needs to be matched to one or more journal items (account.move.line).
|
||||
- Matching is done via set_line_bank_statement_line(move_line_ids).
|
||||
- Fee differences (e.g., Elavon card processing fees) should be allocated to the fee account.
|
||||
- Weekend batches may combine multiple days of card payments.
|
||||
- Always verify amounts before proposing a match.
|
||||
""",
|
||||
|
||||
'hst_management': """
|
||||
HST/GST MANAGEMENT CONTEXT:
|
||||
You are helping with Canadian HST/GST tax management.
|
||||
- HST Collected is tracked on account 2005 (credit balance = liability).
|
||||
- Input Tax Credits (ITCs) are on account 2006 (debit balance = asset).
|
||||
- Net HST = Collected - ITCs. Positive means owing to CRA.
|
||||
- Quarterly filing periods. Check for missing tax on invoices/bills.
|
||||
- All vendor bills should have ITCs unless explicitly exempt.
|
||||
""",
|
||||
|
||||
'accounts_receivable': """
|
||||
ACCOUNTS RECEIVABLE CONTEXT:
|
||||
- AR aging: current, 1-30, 31-60, 61-90, 90+ days overdue.
|
||||
- Follow-up actions escalate by aging bucket.
|
||||
- Payments should be matched to specific invoices.
|
||||
- Unmatched payments sit on the Outstanding Receipts account (1122).
|
||||
""",
|
||||
|
||||
'accounts_payable': """
|
||||
ACCOUNTS PAYABLE CONTEXT:
|
||||
- AP aging mirrors AR: current through 90+ days.
|
||||
- Watch for duplicate bills (same vendor + amount + date).
|
||||
- Bills should match purchase orders when applicable.
|
||||
- Tax on bills should match the vendor's fiscal position.
|
||||
""",
|
||||
|
||||
'journal_review': """
|
||||
JOURNAL REVIEW CONTEXT:
|
||||
- Check for wrong-direction balances (e.g., expense account with credit balance).
|
||||
- Detect duplicate entries (same partner + amount + date + journal).
|
||||
- Flag entries on unlikely accounts (revenue on a tax account, etc.).
|
||||
- Sequence gaps may indicate deleted entries.
|
||||
- Draft entries older than 30 days should be reviewed.
|
||||
""",
|
||||
|
||||
'month_end': """
|
||||
MONTH-END CLOSE CONTEXT:
|
||||
- Aggregate all domain checks into a close checklist.
|
||||
- Verify all bank reconciliations are current.
|
||||
- Check accrual account balances (vacation, sick leave, etc.).
|
||||
- Verify no entries exist after lock date.
|
||||
- Run hash integrity check.
|
||||
- Produce period trial balance summary.
|
||||
""",
|
||||
|
||||
'payroll_verification': """
|
||||
PAYROLL VERIFICATION CONTEXT:
|
||||
- Cross-reference payroll journal entries to bank statement cheques.
|
||||
- Verify CPP, EI, and income tax deductions against CRA rate tables.
|
||||
- Check CRA remittance account balance vs payments made.
|
||||
""",
|
||||
|
||||
'inventory': """
|
||||
INVENTORY & COGS CONTEXT:
|
||||
- Stock In Hand tracked on account 1069.
|
||||
- Price differences on account 5010 (PO price vs bill price).
|
||||
- COGS ratio by product category helps spot anomalies.
|
||||
- Large inventory adjustments need review.
|
||||
""",
|
||||
|
||||
'adp': """
|
||||
ADP RECONCILIATION CONTEXT:
|
||||
- ADP Receivable tracked on account 1101.
|
||||
- ADP invoices have customer portion + ADP portion = total.
|
||||
- Government deposits should match ADP invoices.
|
||||
""",
|
||||
|
||||
'reporting': """
|
||||
FINANCIAL REPORTING CONTEXT:
|
||||
- Reports use Odoo's account.report engine.
|
||||
- Available: P&L, Balance Sheet, Trial Balance, Cash Flow.
|
||||
- Period comparison available for trend analysis.
|
||||
- Export to PDF or XLSX for external distribution.
|
||||
""",
|
||||
|
||||
'audit': """
|
||||
AUDIT & INTEGRITY CONTEXT:
|
||||
- Run comprehensive checks on posted entries.
|
||||
- Verify hash chain integrity on journals.
|
||||
- Check sequence continuity.
|
||||
- Flag entries with chatter messages for review tracking.
|
||||
- Audit status per account: todo / reviewed / supervised / anomaly.
|
||||
""",
|
||||
|
||||
'payroll_management': """
|
||||
PAYROLL MANAGEMENT CONTEXT:
|
||||
- Parse pasted payroll summaries from QBO or fusion_payroll.
|
||||
- Create payroll journal entries with proper debit/credit lines.
|
||||
- Match payroll cheques to bank statement lines.
|
||||
- Calculate CRA obligations (CPP employer + employee, EI, income tax).
|
||||
- Prepare CRA remittance payment entries.
|
||||
""",
|
||||
}
|
||||
|
||||
|
||||
def get_domain_prompt(domain):
|
||||
return DOMAIN_PROMPTS.get(domain, '')
|
||||
98
fusion_accounting/services/prompts/system_prompt.py
Normal file
98
fusion_accounting/services/prompts/system_prompt.py
Normal file
@@ -0,0 +1,98 @@
|
||||
import json
|
||||
|
||||
|
||||
def build_system_prompt(rules, history, context=None):
|
||||
parts = [
|
||||
CORE_SYSTEM_PROMPT,
|
||||
_build_rules_section(rules),
|
||||
_build_history_section(history),
|
||||
]
|
||||
if context:
|
||||
parts.append(_build_context_section(context))
|
||||
return '\n\n'.join(p for p in parts if p)
|
||||
|
||||
|
||||
CORE_SYSTEM_PROMPT = """You are Fusion AI, an expert accounting co-pilot embedded in Odoo 19.
|
||||
You assist with bank reconciliation, HST/GST management, AR/AP analysis, journal review,
|
||||
month-end close, payroll, inventory, ADP reconciliation, financial reporting, and auditing.
|
||||
|
||||
BEHAVIOUR:
|
||||
- Use tools to query and act on Odoo data. Never invent financial figures.
|
||||
- For Tier 1 tools: execute immediately and report results.
|
||||
- For Tier 2 tools: execute and log. Inform the user what was done.
|
||||
- For Tier 3 tools: propose the action with clear reasoning. The user must approve.
|
||||
- When proposing a Tier 3 action, explain: what you want to do, why, the amounts involved, and your confidence level.
|
||||
- Apply Fusion Rules (below) before general reasoning.
|
||||
- Reference match history for patterns the user has approved/rejected before.
|
||||
- Use Canadian English. Format monetary amounts with $ and two decimals.
|
||||
- When you encounter ambiguity, ask clarifying questions rather than guessing.
|
||||
|
||||
RESPONSE FORMATTING:
|
||||
- Use rich Markdown formatting in your responses. The chat renders Markdown as HTML.
|
||||
- Use **bold** for account names, amounts, and key terms.
|
||||
- Use ## and ### headers to organize sections in longer responses.
|
||||
- Use Markdown tables for tabular data (| col1 | col2 | format).
|
||||
- Use bullet lists (- item) for findings, issues, and action items.
|
||||
- Use numbered lists (1. item) for sequential steps or ranked items.
|
||||
- Use `code` for account codes, reference numbers, and technical IDs.
|
||||
- Use --- horizontal rules to separate sections in long reports.
|
||||
|
||||
LINKING TO ODOO RECORDS:
|
||||
- When referencing specific records, include clickable Odoo links.
|
||||
- Journal entries: [INV/2026/00123](/odoo/accounting/123) where 123 is the move ID.
|
||||
- Partners: [Customer Name](/odoo/contacts/456) where 456 is the partner ID.
|
||||
- Accounts: reference by code in bold, e.g. **1001 - Cash**.
|
||||
- Bank statement lines: mention the date, reference, and amount clearly.
|
||||
- When tool results include record IDs, always link them.
|
||||
|
||||
TOOL CALLING:
|
||||
- Call tools by name with the required parameters.
|
||||
- You may call multiple tools in sequence to gather data before proposing an action.
|
||||
- Do not exceed the maximum tool calls per turn.
|
||||
- When presenting tool results, format them richly with tables, bold amounts, and links.
|
||||
"""
|
||||
|
||||
|
||||
def _build_rules_section(rules):
|
||||
if not rules:
|
||||
return ''
|
||||
lines = ['ACTIVE FUSION RULES:']
|
||||
for rule in rules:
|
||||
priority = 'ADMIN' if rule.created_by == 'admin' else 'AI'
|
||||
tier = 'auto' if rule.approval_tier == 'auto' else 'needs-approval'
|
||||
lines.append(
|
||||
f'- [{priority}/{tier}] {rule.name} ({rule.rule_type}): '
|
||||
f'{rule.description or rule.match_logic or "No description"}'
|
||||
)
|
||||
if rule.match_logic:
|
||||
lines.append(f' Match logic: {rule.match_logic}')
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def _build_history_section(history):
|
||||
if not history:
|
||||
return ''
|
||||
lines = ['RECENT MATCH HISTORY (learn from these patterns):']
|
||||
for h in history[:50]:
|
||||
status = h.decision
|
||||
reason = ''
|
||||
if h.rejection_reason:
|
||||
reason = f' (reason: {h.rejection_reason})'
|
||||
lines.append(
|
||||
f'- {h.tool_name}: {status}{reason} '
|
||||
f'[confidence={h.ai_confidence:.0%}]'
|
||||
)
|
||||
if h.ai_reasoning:
|
||||
lines.append(f' Reasoning: {h.ai_reasoning[:200]}')
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def _build_context_section(context):
|
||||
if not context:
|
||||
return ''
|
||||
if isinstance(context, dict):
|
||||
parts = ['CURRENT CONTEXT:']
|
||||
for k, v in context.items():
|
||||
parts.append(f'- {k}: {v}')
|
||||
return '\n'.join(parts)
|
||||
return f'CURRENT CONTEXT: {context}'
|
||||
61
fusion_accounting/services/scoring.py
Normal file
61
fusion_accounting/services/scoring.py
Normal file
@@ -0,0 +1,61 @@
|
||||
import logging
|
||||
|
||||
from odoo import models, api
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionAccountingScoring(models.AbstractModel):
|
||||
_name = 'fusion.accounting.scoring'
|
||||
_description = 'Fusion Accounting Confidence Scoring'
|
||||
|
||||
def calculate_confidence(self, tool_name, scenario_key=None):
|
||||
domain = [('tool_name', '=', tool_name)]
|
||||
if scenario_key:
|
||||
domain.append(('tool_params', 'ilike', scenario_key))
|
||||
history = self.env['fusion.accounting.match.history'].search(domain)
|
||||
if not history:
|
||||
return 0.0
|
||||
decided = history.filtered(lambda h: h.decision in ('approved', 'rejected'))
|
||||
if not decided:
|
||||
return 0.0
|
||||
approved = len(decided.filtered(lambda h: h.decision == 'approved'))
|
||||
return approved / len(decided)
|
||||
|
||||
def check_promotions(self):
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
threshold = float(ICP.get_param('fusion_accounting.tier3_threshold', '0.95'))
|
||||
min_sample = int(ICP.get_param('fusion_accounting.tier3_min_sample', '30'))
|
||||
|
||||
rules = self.env['fusion.accounting.rule'].search([
|
||||
('active', '=', True),
|
||||
('approval_tier', '=', 'needs_approval'),
|
||||
])
|
||||
promoted = self.env['fusion.accounting.rule']
|
||||
for rule in rules:
|
||||
if rule.total_uses >= min_sample and rule.confidence_score >= threshold:
|
||||
rule.approval_tier = 'auto'
|
||||
promoted |= rule
|
||||
_logger.info(
|
||||
"Promoted rule '%s' to auto (confidence=%.2f, sample=%d)",
|
||||
rule.name, rule.confidence_score, rule.total_uses,
|
||||
)
|
||||
return promoted
|
||||
|
||||
def get_tool_stats(self, tool_name=None):
|
||||
domain = []
|
||||
if tool_name:
|
||||
domain.append(('tool_name', '=', tool_name))
|
||||
history = self.env['fusion.accounting.match.history'].search(domain)
|
||||
|
||||
stats = {}
|
||||
for h in history:
|
||||
if h.tool_name not in stats:
|
||||
stats[h.tool_name] = {
|
||||
'total': 0, 'approved': 0, 'rejected': 0,
|
||||
'pending': 0, 'auto': 0,
|
||||
}
|
||||
stats[h.tool_name]['total'] += 1
|
||||
if h.decision in stats[h.tool_name]:
|
||||
stats[h.tool_name][h.decision] += 1
|
||||
return stats
|
||||
19
fusion_accounting/services/tools/__init__.py
Normal file
19
fusion_accounting/services/tools/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from .bank_reconciliation import TOOLS as BANK_RECON_TOOLS
|
||||
from .hst_management import TOOLS as HST_TOOLS
|
||||
from .accounts_receivable import TOOLS as AR_TOOLS
|
||||
from .accounts_payable import TOOLS as AP_TOOLS
|
||||
from .journal_review import TOOLS as JOURNAL_TOOLS
|
||||
from .month_end import TOOLS as MONTH_END_TOOLS
|
||||
from .payroll import TOOLS as PAYROLL_TOOLS
|
||||
from .inventory import TOOLS as INVENTORY_TOOLS
|
||||
from .adp import TOOLS as ADP_TOOLS
|
||||
from .reporting import TOOLS as REPORTING_TOOLS
|
||||
from .audit import TOOLS as AUDIT_TOOLS
|
||||
|
||||
TOOL_DISPATCH = {}
|
||||
for tools_dict in [
|
||||
BANK_RECON_TOOLS, HST_TOOLS, AR_TOOLS, AP_TOOLS, JOURNAL_TOOLS,
|
||||
MONTH_END_TOOLS, PAYROLL_TOOLS, INVENTORY_TOOLS, ADP_TOOLS,
|
||||
REPORTING_TOOLS, AUDIT_TOOLS,
|
||||
]:
|
||||
TOOL_DISPATCH.update(tools_dict)
|
||||
150
fusion_accounting/services/tools/accounts_payable.py
Normal file
150
fusion_accounting/services/tools/accounts_payable.py
Normal file
@@ -0,0 +1,150 @@
|
||||
import logging
|
||||
from odoo import fields
|
||||
from datetime import timedelta
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_ap_aging(env, params):
|
||||
today = fields.Date.today()
|
||||
domain = [
|
||||
('account_id.account_type', '=', 'liability_payable'),
|
||||
('parent_state', '=', 'posted'),
|
||||
('reconciled', '=', False),
|
||||
('company_id', '=', env.company.id),
|
||||
]
|
||||
amls = env['account.move.line'].search(domain)
|
||||
|
||||
buckets = {'current': 0, '1_30': 0, '31_60': 0, '61_90': 0, '90_plus': 0}
|
||||
for aml in amls:
|
||||
amt = abs(aml.amount_residual)
|
||||
if not aml.date_maturity or aml.date_maturity >= today:
|
||||
buckets['current'] += amt
|
||||
else:
|
||||
days = (today - aml.date_maturity).days
|
||||
if days <= 30:
|
||||
buckets['1_30'] += amt
|
||||
elif days <= 60:
|
||||
buckets['31_60'] += amt
|
||||
elif days <= 90:
|
||||
buckets['61_90'] += amt
|
||||
else:
|
||||
buckets['90_plus'] += amt
|
||||
|
||||
return {'total': sum(buckets.values()), 'buckets': buckets, 'line_count': len(amls)}
|
||||
|
||||
|
||||
def find_duplicate_bills(env, params):
|
||||
window_days = int(params.get('window_days', 7))
|
||||
bills = env['account.move'].search([
|
||||
('move_type', 'in', ('in_invoice', 'in_refund')),
|
||||
('state', '=', 'posted'),
|
||||
('company_id', '=', env.company.id),
|
||||
], order='partner_id, amount_total, date')
|
||||
|
||||
duplicates = []
|
||||
prev = None
|
||||
for bill in bills:
|
||||
if prev and (
|
||||
prev.partner_id == bill.partner_id
|
||||
and abs(prev.amount_total - bill.amount_total) < 0.01
|
||||
and abs((prev.date - bill.date).days) <= window_days
|
||||
):
|
||||
duplicates.append({
|
||||
'bill_1': {'id': prev.id, 'name': prev.name, 'date': str(prev.date)},
|
||||
'bill_2': {'id': bill.id, 'name': bill.name, 'date': str(bill.date)},
|
||||
'partner': bill.partner_id.name,
|
||||
'amount': bill.amount_total,
|
||||
})
|
||||
prev = bill
|
||||
|
||||
return {'count': len(duplicates), 'duplicates': duplicates[:20]}
|
||||
|
||||
|
||||
def match_bill_to_po(env, params):
|
||||
bill_id = int(params['bill_id'])
|
||||
bill = env['account.move'].browse(bill_id)
|
||||
if not bill.exists():
|
||||
return {'error': 'Bill not found'}
|
||||
matches = []
|
||||
for line in bill.invoice_line_ids:
|
||||
if line.purchase_line_id:
|
||||
matches.append({
|
||||
'bill_line': line.name or '',
|
||||
'po': line.purchase_line_id.order_id.name,
|
||||
'po_line': line.purchase_line_id.name,
|
||||
'po_qty': line.purchase_line_id.product_qty,
|
||||
'bill_qty': line.quantity,
|
||||
'match': abs(line.quantity - line.purchase_line_id.product_qty) < 0.01,
|
||||
})
|
||||
return {'bill': bill.name, 'matches': matches, 'unmatched_lines': len(bill.invoice_line_ids) - len(matches)}
|
||||
|
||||
|
||||
def get_unpaid_bills(env, params):
|
||||
domain = [
|
||||
('move_type', 'in', ('in_invoice', 'in_refund')),
|
||||
('state', '=', 'posted'),
|
||||
('payment_state', 'in', ('not_paid', 'partial')),
|
||||
('company_id', '=', env.company.id),
|
||||
]
|
||||
if params.get('partner_id'):
|
||||
domain.append(('partner_id', '=', int(params['partner_id'])))
|
||||
bills = env['account.move'].search(domain, order='invoice_date_due asc', limit=int(params.get('limit', 50)))
|
||||
return {
|
||||
'count': len(bills),
|
||||
'total': sum(b.amount_residual for b in bills),
|
||||
'bills': [{
|
||||
'id': b.id, 'name': b.name,
|
||||
'partner': b.partner_id.name if b.partner_id else '',
|
||||
'amount_total': b.amount_total,
|
||||
'amount_residual': b.amount_residual,
|
||||
'date_due': str(b.invoice_date_due) if b.invoice_date_due else '',
|
||||
} for b in bills],
|
||||
}
|
||||
|
||||
|
||||
def verify_bill_taxes(env, params):
|
||||
bill_id = int(params['bill_id'])
|
||||
bill = env['account.move'].browse(bill_id)
|
||||
if not bill.exists():
|
||||
return {'error': 'Bill not found'}
|
||||
issues = []
|
||||
for line in bill.invoice_line_ids:
|
||||
if line.product_id and not line.tax_ids:
|
||||
issues.append({
|
||||
'line': line.name or line.product_id.name,
|
||||
'issue': 'No tax applied to product line',
|
||||
})
|
||||
return {'bill': bill.name, 'issues': issues, 'clean': len(issues) == 0}
|
||||
|
||||
|
||||
def get_payment_schedule(env, params):
|
||||
days_ahead = int(params.get('days_ahead', 30))
|
||||
cutoff = fields.Date.today() + timedelta(days=days_ahead)
|
||||
bills = env['account.move'].search([
|
||||
('move_type', '=', 'in_invoice'),
|
||||
('state', '=', 'posted'),
|
||||
('payment_state', 'in', ('not_paid', 'partial')),
|
||||
('invoice_date_due', '<=', cutoff),
|
||||
('company_id', '=', env.company.id),
|
||||
], order='invoice_date_due asc')
|
||||
return {
|
||||
'period': f'Next {days_ahead} days',
|
||||
'total': sum(b.amount_residual for b in bills),
|
||||
'bills': [{
|
||||
'id': b.id, 'name': b.name,
|
||||
'partner': b.partner_id.name if b.partner_id else '',
|
||||
'amount_residual': b.amount_residual,
|
||||
'date_due': str(b.invoice_date_due) if b.invoice_date_due else '',
|
||||
} for b in bills[:50]],
|
||||
}
|
||||
|
||||
|
||||
TOOLS = {
|
||||
'get_ap_aging': get_ap_aging,
|
||||
'find_duplicate_bills': find_duplicate_bills,
|
||||
'match_bill_to_po': match_bill_to_po,
|
||||
'get_unpaid_bills': get_unpaid_bills,
|
||||
'verify_bill_taxes': verify_bill_taxes,
|
||||
'get_payment_schedule': get_payment_schedule,
|
||||
}
|
||||
165
fusion_accounting/services/tools/accounts_receivable.py
Normal file
165
fusion_accounting/services/tools/accounts_receivable.py
Normal file
@@ -0,0 +1,165 @@
|
||||
import logging
|
||||
from odoo import fields
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_ar_aging(env, params):
|
||||
today = fields.Date.today()
|
||||
domain = [
|
||||
('account_id.account_type', '=', 'asset_receivable'),
|
||||
('parent_state', '=', 'posted'),
|
||||
('reconciled', '=', False),
|
||||
('company_id', '=', env.company.id),
|
||||
]
|
||||
amls = env['account.move.line'].search(domain)
|
||||
|
||||
buckets = {'current': 0, '1_30': 0, '31_60': 0, '61_90': 0, '90_plus': 0}
|
||||
for aml in amls:
|
||||
if not aml.date_maturity or aml.date_maturity >= today:
|
||||
buckets['current'] += aml.amount_residual
|
||||
else:
|
||||
days = (today - aml.date_maturity).days
|
||||
if days <= 30:
|
||||
buckets['1_30'] += aml.amount_residual
|
||||
elif days <= 60:
|
||||
buckets['31_60'] += aml.amount_residual
|
||||
elif days <= 90:
|
||||
buckets['61_90'] += aml.amount_residual
|
||||
else:
|
||||
buckets['90_plus'] += aml.amount_residual
|
||||
|
||||
return {
|
||||
'total': sum(buckets.values()),
|
||||
'buckets': buckets,
|
||||
'line_count': len(amls),
|
||||
}
|
||||
|
||||
|
||||
def get_overdue_invoices(env, params):
|
||||
today = fields.Date.today()
|
||||
days_overdue = int(params.get('min_days_overdue', 1))
|
||||
from datetime import timedelta
|
||||
cutoff = today - timedelta(days=days_overdue)
|
||||
invoices = env['account.move'].search([
|
||||
('move_type', '=', 'out_invoice'),
|
||||
('state', '=', 'posted'),
|
||||
('payment_state', 'in', ('not_paid', 'partial')),
|
||||
('invoice_date_due', '<', cutoff),
|
||||
('company_id', '=', env.company.id),
|
||||
], order='invoice_date_due asc', limit=int(params.get('limit', 50)))
|
||||
return {
|
||||
'count': len(invoices),
|
||||
'invoices': [{
|
||||
'id': inv.id,
|
||||
'name': inv.name,
|
||||
'partner': inv.partner_id.name if inv.partner_id else '',
|
||||
'email': inv.partner_id.email or '' if inv.partner_id else '',
|
||||
'phone': inv.partner_id.phone or '' if inv.partner_id else '',
|
||||
'amount_total': inv.amount_total,
|
||||
'amount_residual': inv.amount_residual,
|
||||
'date_due': str(inv.invoice_date_due),
|
||||
'days_overdue': (today - inv.invoice_date_due).days,
|
||||
} for inv in invoices],
|
||||
}
|
||||
|
||||
|
||||
def get_partner_balance(env, params):
|
||||
partner_id = int(params['partner_id'])
|
||||
partner = env['res.partner'].browse(partner_id)
|
||||
if not partner.exists():
|
||||
return {'error': 'Partner not found'}
|
||||
amls = env['account.move.line'].search([
|
||||
('partner_id', '=', partner_id),
|
||||
('account_id.account_type', '=', 'asset_receivable'),
|
||||
('parent_state', '=', 'posted'),
|
||||
('reconciled', '=', False),
|
||||
('company_id', '=', env.company.id),
|
||||
])
|
||||
return {
|
||||
'partner': partner.name,
|
||||
'balance': sum(aml.amount_residual for aml in amls),
|
||||
'open_items': [{
|
||||
'id': aml.id,
|
||||
'ref': aml.ref or aml.move_id.name,
|
||||
'date': str(aml.date),
|
||||
'amount_residual': aml.amount_residual,
|
||||
'date_maturity': str(aml.date_maturity) if aml.date_maturity else '',
|
||||
} for aml in amls],
|
||||
}
|
||||
|
||||
|
||||
def send_followup(env, params):
|
||||
partner_id = int(params['partner_id'])
|
||||
partner = env['res.partner'].browse(partner_id)
|
||||
if not partner.exists():
|
||||
return {'error': 'Partner not found'}
|
||||
options = {
|
||||
'partner_id': partner_id,
|
||||
'email': params.get('send_email', False),
|
||||
'print': params.get('print_letter', False),
|
||||
'sms': False,
|
||||
}
|
||||
if params.get('email_subject'):
|
||||
options['email_subject'] = params['email_subject']
|
||||
if params.get('body'):
|
||||
options['body'] = params['body']
|
||||
result = partner.execute_followup(options)
|
||||
return {'status': 'sent', 'partner': partner.name, 'result': str(result) if result else 'done'}
|
||||
|
||||
|
||||
def get_followup_report(env, params):
|
||||
partner_id = int(params['partner_id'])
|
||||
partner = env['res.partner'].browse(partner_id)
|
||||
if not partner.exists():
|
||||
return {'error': 'Partner not found'}
|
||||
try:
|
||||
report = env['account.followup.report']
|
||||
html = report._get_followup_report_html(partner)
|
||||
return {'partner': partner.name, 'html': html}
|
||||
except Exception as e:
|
||||
return {'error': str(e)}
|
||||
|
||||
|
||||
def reconcile_payment_to_invoice(env, params):
|
||||
move_line_ids = [int(x) for x in params['move_line_ids']]
|
||||
amls = env['account.move.line'].browse(move_line_ids)
|
||||
if len(amls) < 2:
|
||||
return {'error': 'Need at least 2 journal items to reconcile'}
|
||||
amls.reconcile()
|
||||
return {
|
||||
'status': 'reconciled',
|
||||
'move_line_ids': move_line_ids,
|
||||
}
|
||||
|
||||
|
||||
def get_unmatched_payments(env, params):
|
||||
domain = [
|
||||
('account_id.account_type', '=', 'asset_receivable'),
|
||||
('parent_state', '=', 'posted'),
|
||||
('reconciled', '=', False),
|
||||
('move_id.payment_id', '!=', False),
|
||||
('company_id', '=', env.company.id),
|
||||
]
|
||||
amls = env['account.move.line'].search(domain, order='date desc')
|
||||
return {
|
||||
'count': len(amls),
|
||||
'payments': [{
|
||||
'id': aml.id,
|
||||
'date': str(aml.date),
|
||||
'ref': aml.ref or aml.move_id.name,
|
||||
'partner': aml.partner_id.name if aml.partner_id else '',
|
||||
'amount': abs(aml.amount_residual),
|
||||
} for aml in amls[:50]],
|
||||
}
|
||||
|
||||
|
||||
TOOLS = {
|
||||
'get_ar_aging': get_ar_aging,
|
||||
'get_overdue_invoices': get_overdue_invoices,
|
||||
'get_partner_balance': get_partner_balance,
|
||||
'send_followup': send_followup,
|
||||
'get_followup_report': get_followup_report,
|
||||
'reconcile_payment_to_invoice': reconcile_payment_to_invoice,
|
||||
'get_unmatched_payments': get_unmatched_payments,
|
||||
}
|
||||
111
fusion_accounting/services/tools/adp.py
Normal file
111
fusion_accounting/services/tools/adp.py
Normal file
@@ -0,0 +1,111 @@
|
||||
import logging
|
||||
from odoo import fields
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_adp_receivable_aging(env, params):
|
||||
accounts = env['account.account'].search([
|
||||
('code', '=like', '1101%'),
|
||||
('company_id', '=', env.company.id),
|
||||
])
|
||||
today = fields.Date.today()
|
||||
amls = env['account.move.line'].search([
|
||||
('account_id', 'in', accounts.ids),
|
||||
('reconciled', '=', False),
|
||||
('parent_state', '=', 'posted'),
|
||||
])
|
||||
buckets = {'current': 0, '1_30': 0, '31_60': 0, '61_90': 0, '90_plus': 0}
|
||||
for aml in amls:
|
||||
amt = abs(aml.amount_residual)
|
||||
if not aml.date_maturity or aml.date_maturity >= today:
|
||||
buckets['current'] += amt
|
||||
else:
|
||||
days = (today - aml.date_maturity).days
|
||||
if days <= 30:
|
||||
buckets['1_30'] += amt
|
||||
elif days <= 60:
|
||||
buckets['31_60'] += amt
|
||||
elif days <= 90:
|
||||
buckets['61_90'] += amt
|
||||
else:
|
||||
buckets['90_plus'] += amt
|
||||
return {'total': sum(buckets.values()), 'buckets': buckets}
|
||||
|
||||
|
||||
def match_adp_payment_to_invoice(env, params):
|
||||
move_line_ids = [int(x) for x in params['move_line_ids']]
|
||||
amls = env['account.move.line'].browse(move_line_ids).exists()
|
||||
if len(amls) < 2:
|
||||
return {'error': 'Need at least 2 existing journal items to reconcile'}
|
||||
amls.reconcile()
|
||||
return {'status': 'matched', 'move_line_ids': amls.ids}
|
||||
|
||||
|
||||
def verify_adp_split(env, params):
|
||||
invoice_id = int(params['invoice_id'])
|
||||
invoice = env['account.move'].browse(invoice_id)
|
||||
if not invoice.exists():
|
||||
return {'error': 'Invoice not found'}
|
||||
lines = invoice.invoice_line_ids
|
||||
total = invoice.amount_untaxed
|
||||
return {
|
||||
'invoice': invoice.name,
|
||||
'total_untaxed': total,
|
||||
'total_with_tax': invoice.amount_total,
|
||||
'lines': [{'name': l.name, 'subtotal': l.price_subtotal, 'total': l.price_total} for l in lines],
|
||||
'balanced': abs(sum(l.price_subtotal for l in lines) - total) < 0.01,
|
||||
}
|
||||
|
||||
|
||||
def find_adp_without_payment(env, params):
|
||||
adp_partner = env['res.partner'].search([('name', 'ilike', 'ADP')], limit=1)
|
||||
if not adp_partner:
|
||||
return {'status': 'info', 'message': 'No ADP partner found in the system.'}
|
||||
invoices = env['account.move'].search([
|
||||
('partner_id', '=', adp_partner.id),
|
||||
('move_type', '=', 'out_invoice'),
|
||||
('state', '=', 'posted'),
|
||||
('payment_state', 'in', ('not_paid', 'partial')),
|
||||
])
|
||||
return {
|
||||
'count': len(invoices),
|
||||
'invoices': [{
|
||||
'id': inv.id, 'name': inv.name,
|
||||
'amount': inv.amount_residual, 'date': str(inv.date),
|
||||
} for inv in invoices[:20]],
|
||||
}
|
||||
|
||||
|
||||
def get_adp_summary(env, params):
|
||||
date_from = params.get('date_from')
|
||||
date_to = params.get('date_to')
|
||||
accounts = env['account.account'].search([
|
||||
('code', '=like', '1101%'), ('company_id', '=', env.company.id),
|
||||
])
|
||||
domain = [
|
||||
('account_id', 'in', accounts.ids),
|
||||
('parent_state', '=', 'posted'),
|
||||
]
|
||||
if date_from:
|
||||
domain.append(('date', '>=', date_from))
|
||||
if date_to:
|
||||
domain.append(('date', '<=', date_to))
|
||||
lines = env['account.move.line'].search(domain)
|
||||
total_debit = sum(l.debit for l in lines)
|
||||
total_credit = sum(l.credit for l in lines)
|
||||
return {
|
||||
'period': f'{date_from or "all"} to {date_to or "now"}',
|
||||
'billed': total_debit,
|
||||
'collected': total_credit,
|
||||
'outstanding': total_debit - total_credit,
|
||||
}
|
||||
|
||||
|
||||
TOOLS = {
|
||||
'get_adp_receivable_aging': get_adp_receivable_aging,
|
||||
'match_adp_payment_to_invoice': match_adp_payment_to_invoice,
|
||||
'verify_adp_split': verify_adp_split,
|
||||
'find_adp_without_payment': find_adp_without_payment,
|
||||
'get_adp_summary': get_adp_summary,
|
||||
}
|
||||
156
fusion_accounting/services/tools/audit.py
Normal file
156
fusion_accounting/services/tools/audit.py
Normal file
@@ -0,0 +1,156 @@
|
||||
import logging
|
||||
from odoo import fields
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def audit_posted_entry(env, params):
|
||||
move_id = int(params['move_id'])
|
||||
move = env['account.move'].browse(move_id)
|
||||
if not move.exists():
|
||||
return {'error': 'Entry not found'}
|
||||
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({'severity': 'critical', 'issue': f'Unbalanced entry: debit={total_debit}, credit={total_credit}'})
|
||||
for line in move.line_ids:
|
||||
if not line.account_id:
|
||||
issues.append({'severity': 'critical', 'issue': f'Line missing account: {line.name}'})
|
||||
if not move.line_ids:
|
||||
issues.append({'severity': 'warning', 'issue': 'Entry has no lines'})
|
||||
return {
|
||||
'move': move.name, 'date': str(move.date),
|
||||
'issues': issues, 'clean': len(issues) == 0,
|
||||
}
|
||||
|
||||
|
||||
def audit_account_balances(env, params):
|
||||
from .journal_review import find_wrong_direction_balances
|
||||
return find_wrong_direction_balances(env, params)
|
||||
|
||||
|
||||
def audit_tax_compliance(env, params):
|
||||
from .hst_management import find_missing_tax_invoices, find_missing_itc_bills
|
||||
invoices = find_missing_tax_invoices(env, params)
|
||||
bills = find_missing_itc_bills(env, params)
|
||||
return {
|
||||
'missing_tax_invoices': invoices.get('missing_tax_count', 0),
|
||||
'missing_itc_bills': bills.get('missing_itc_count', 0),
|
||||
'total_issues': invoices.get('missing_tax_count', 0) + bills.get('missing_itc_count', 0),
|
||||
}
|
||||
|
||||
|
||||
def audit_reconciliation_integrity(env, params):
|
||||
from .journal_review import verify_reconciliation_integrity
|
||||
return verify_reconciliation_integrity(env, params)
|
||||
|
||||
|
||||
def check_hash_chain(env, params):
|
||||
from .month_end import run_hash_integrity_check
|
||||
return run_hash_integrity_check(env, params)
|
||||
|
||||
|
||||
def check_sequence_gaps(env, params):
|
||||
from .journal_review import find_sequence_gaps
|
||||
return find_sequence_gaps(env, params)
|
||||
|
||||
|
||||
def flag_entry(env, params):
|
||||
move_id = int(params['move_id'])
|
||||
flag = params.get('flag', 'Review Required')
|
||||
recommendation = params.get('recommendation', '')
|
||||
move = env['account.move'].browse(move_id)
|
||||
if not move.exists():
|
||||
return {'error': 'Entry not found'}
|
||||
body = f'<strong>🏴 {flag}</strong><br/>{recommendation}'
|
||||
move.message_post(body=body, message_type='comment', subtype_xmlid='mail.mt_note')
|
||||
return {'status': 'flagged', 'move': move.name, 'flag': flag}
|
||||
|
||||
|
||||
def get_audit_status(env, params):
|
||||
statuses = env['account.audit.account.status'].search([])
|
||||
return {
|
||||
'statuses': [{
|
||||
'id': s.id,
|
||||
'account': s.account_id.name,
|
||||
'status': s.status,
|
||||
'audit': s.audit_id.display_name if s.audit_id else '',
|
||||
} for s in statuses[:50]],
|
||||
}
|
||||
|
||||
|
||||
def set_audit_status(env, params):
|
||||
status_id = int(params['status_id'])
|
||||
new_status = params['status']
|
||||
rec = env['account.audit.account.status'].browse(status_id)
|
||||
if not rec.exists():
|
||||
return {'error': 'Audit status record not found'}
|
||||
rec.status = new_status
|
||||
return {'status': 'updated', 'id': status_id, 'new_status': new_status}
|
||||
|
||||
|
||||
def get_audit_trail(env, params):
|
||||
move_id = int(params['move_id'])
|
||||
move = env['account.move'].browse(move_id)
|
||||
if not move.exists():
|
||||
return {'error': 'Entry not found'}
|
||||
messages = env['mail.message'].search([
|
||||
('model', '=', 'account.move'),
|
||||
('res_id', '=', move_id),
|
||||
], order='date desc', limit=20)
|
||||
return {
|
||||
'move': move.name,
|
||||
'messages': [{
|
||||
'date': str(m.date),
|
||||
'author': m.author_id.name if m.author_id else '',
|
||||
'body': m.body or '',
|
||||
'type': m.message_type,
|
||||
} for m in messages],
|
||||
}
|
||||
|
||||
|
||||
def run_full_audit(env, params):
|
||||
results = {}
|
||||
results['account_balances'] = audit_account_balances(env, params)
|
||||
results['tax_compliance'] = audit_tax_compliance(env, params)
|
||||
results['reconciliation'] = audit_reconciliation_integrity(env, params)
|
||||
results['hash_chain'] = check_hash_chain(env, params)
|
||||
results['sequence_gaps'] = check_sequence_gaps(env, params)
|
||||
|
||||
total_issues = 0
|
||||
for key, val in results.items():
|
||||
total_issues += val.get('count', 0) + val.get('total_issues', 0)
|
||||
|
||||
score = max(0, 100 - total_issues * 5)
|
||||
return {
|
||||
'score': min(100, score),
|
||||
'total_issues': total_issues,
|
||||
'details': results,
|
||||
}
|
||||
|
||||
|
||||
def get_audit_report(env, params):
|
||||
audit = run_full_audit(env, params)
|
||||
report_lines = [f"Audit Score: {audit['score']}/100", f"Total Issues: {audit['total_issues']}", '']
|
||||
for domain, detail in audit.get('details', {}).items():
|
||||
report_lines.append(f"--- {domain.replace('_', ' ').title()} ---")
|
||||
count = detail.get('count', detail.get('total_issues', 0))
|
||||
report_lines.append(f" Issues: {count}")
|
||||
return {'report': '\n'.join(report_lines), 'score': audit['score']}
|
||||
|
||||
|
||||
TOOLS = {
|
||||
'audit_posted_entry': audit_posted_entry,
|
||||
'audit_account_balances': audit_account_balances,
|
||||
'audit_tax_compliance': audit_tax_compliance,
|
||||
'audit_reconciliation_integrity': audit_reconciliation_integrity,
|
||||
'check_hash_chain': check_hash_chain,
|
||||
'check_sequence_gaps': check_sequence_gaps,
|
||||
'flag_entry': flag_entry,
|
||||
'get_audit_status': get_audit_status,
|
||||
'set_audit_status': set_audit_status,
|
||||
'get_audit_trail': get_audit_trail,
|
||||
'run_full_audit': run_full_audit,
|
||||
'get_audit_report': get_audit_report,
|
||||
}
|
||||
177
fusion_accounting/services/tools/bank_reconciliation.py
Normal file
177
fusion_accounting/services/tools/bank_reconciliation.py
Normal file
@@ -0,0 +1,177 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_unreconciled_bank_lines(env, params):
|
||||
domain = [('is_reconciled', '=', False), ('company_id', '=', env.company.id)]
|
||||
if params.get('journal_id'):
|
||||
domain.append(('journal_id', '=', int(params['journal_id'])))
|
||||
if params.get('date_from'):
|
||||
domain.append(('date', '>=', params['date_from']))
|
||||
if params.get('date_to'):
|
||||
domain.append(('date', '<=', params['date_to']))
|
||||
if params.get('min_amount'):
|
||||
domain.append(('amount', '>=', float(params['min_amount'])))
|
||||
limit = int(params.get('limit', 50))
|
||||
lines = env['account.bank.statement.line'].search(domain, limit=limit, order='date desc')
|
||||
return {
|
||||
'count': len(lines),
|
||||
'total_amount': sum(abs(l.amount) for l in lines),
|
||||
'lines': [{
|
||||
'id': l.id,
|
||||
'date': str(l.date),
|
||||
'payment_ref': l.payment_ref or '',
|
||||
'partner_name': l.partner_name or (l.partner_id.name if l.partner_id else ''),
|
||||
'amount': l.amount,
|
||||
'journal': l.journal_id.name,
|
||||
} for l in lines],
|
||||
}
|
||||
|
||||
|
||||
def get_unreconciled_receipts(env, params):
|
||||
account_code = params.get('account_code', '1122')
|
||||
accounts = env['account.account'].search([
|
||||
('code', '=like', f'{account_code}%'),
|
||||
('company_id', '=', env.company.id),
|
||||
])
|
||||
domain = [
|
||||
('account_id', 'in', accounts.ids),
|
||||
('reconciled', '=', False),
|
||||
('parent_state', '=', 'posted'),
|
||||
('company_id', '=', env.company.id),
|
||||
]
|
||||
lines = env['account.move.line'].search(domain, order='date desc')
|
||||
return {
|
||||
'count': len(lines),
|
||||
'total_amount': sum(abs(l.amount_residual) for l in lines),
|
||||
'lines': [{
|
||||
'id': l.id,
|
||||
'date': str(l.date),
|
||||
'ref': l.ref or l.move_id.name,
|
||||
'partner': l.partner_id.name if l.partner_id else '',
|
||||
'amount_residual': l.amount_residual,
|
||||
} for l in lines],
|
||||
}
|
||||
|
||||
|
||||
def match_bank_line_to_payments(env, params):
|
||||
st_line_id = int(params['statement_line_id'])
|
||||
move_line_ids = [int(x) for x in params['move_line_ids']]
|
||||
st_line = env['account.bank.statement.line'].browse(st_line_id)
|
||||
if not st_line.exists():
|
||||
return {'error': 'Statement line not found'}
|
||||
st_line.set_line_bank_statement_line(move_line_ids)
|
||||
return {
|
||||
'status': 'matched',
|
||||
'statement_line_id': st_line_id,
|
||||
'matched_move_lines': move_line_ids,
|
||||
'is_reconciled': st_line.is_reconciled,
|
||||
}
|
||||
|
||||
|
||||
def auto_reconcile_bank_lines(env, params):
|
||||
company_id = params.get('company_id', env.company.id)
|
||||
lines = env['account.bank.statement.line'].search([
|
||||
('is_reconciled', '=', False),
|
||||
('company_id', '=', int(company_id)),
|
||||
])
|
||||
before_count = len(lines)
|
||||
lines._try_auto_reconcile_statement_lines(company_id=int(company_id))
|
||||
still_unreconciled = env['account.bank.statement.line'].search([
|
||||
('is_reconciled', '=', False),
|
||||
('company_id', '=', int(company_id)),
|
||||
])
|
||||
reconciled_count = before_count - len(still_unreconciled)
|
||||
return {
|
||||
'status': 'completed',
|
||||
'lines_before': before_count,
|
||||
'lines_reconciled': reconciled_count,
|
||||
'lines_remaining': len(still_unreconciled),
|
||||
}
|
||||
|
||||
|
||||
def apply_reconcile_model(env, params):
|
||||
model_id = int(params['model_id'])
|
||||
st_line_id = int(params['statement_line_id'])
|
||||
reco_model = env['account.reconcile.model'].browse(model_id)
|
||||
st_line = env['account.bank.statement.line'].browse(st_line_id)
|
||||
if not reco_model.exists() or not st_line.exists():
|
||||
return {'error': 'Model or statement line not found'}
|
||||
_liquidity_lines, suspense_lines, _other_lines = st_line._seek_for_lines()
|
||||
residual = sum(l.amount_residual for l in suspense_lines) if suspense_lines else st_line.amount
|
||||
write_off_vals = reco_model._get_write_off_move_lines_dict(st_line, residual)
|
||||
if write_off_vals:
|
||||
line_ids_create_command = [(0, 0, vals) for vals in write_off_vals]
|
||||
st_line.move_id.write({'line_ids': line_ids_create_command})
|
||||
return {
|
||||
'status': 'applied',
|
||||
'model': reco_model.name,
|
||||
'write_off_lines': len(write_off_vals) if write_off_vals else 0,
|
||||
}
|
||||
|
||||
|
||||
def unmatch_bank_line(env, params):
|
||||
st_line_id = int(params['statement_line_id'])
|
||||
st_line = env['account.bank.statement.line'].browse(st_line_id)
|
||||
if not st_line.exists():
|
||||
return {'error': 'Statement line not found'}
|
||||
st_line.action_unreconcile_entry()
|
||||
return {'status': 'unmatched', 'statement_line_id': st_line_id}
|
||||
|
||||
|
||||
def get_reconcile_suggestions(env, params):
|
||||
st_line_id = int(params['statement_line_id'])
|
||||
st_line = env['account.bank.statement.line'].browse(st_line_id)
|
||||
if not st_line.exists():
|
||||
return {'error': 'Statement line not found'}
|
||||
models = env['account.reconcile.model'].search([
|
||||
('company_id', '=', env.company.id),
|
||||
])
|
||||
return {
|
||||
'models': [{
|
||||
'id': m.id,
|
||||
'name': m.name,
|
||||
'trigger': m.trigger if hasattr(m, 'trigger') else 'manual',
|
||||
} for m in models],
|
||||
}
|
||||
|
||||
|
||||
def sum_payments_by_date(env, params):
|
||||
date_from = params.get('date_from')
|
||||
date_to = params.get('date_to')
|
||||
if not date_from or not date_to:
|
||||
return {'error': 'date_from and date_to are required'}
|
||||
journal_ids = params.get('journal_ids', [])
|
||||
domain = [
|
||||
('parent_state', '=', 'posted'),
|
||||
('company_id', '=', env.company.id),
|
||||
('date', '>=', date_from),
|
||||
('date', '<=', date_to),
|
||||
]
|
||||
if journal_ids:
|
||||
domain.append(('journal_id', 'in', [int(j) for j in journal_ids]))
|
||||
lines = env['account.move.line'].search(domain)
|
||||
total_debit = sum(l.debit for l in lines)
|
||||
total_credit = sum(l.credit for l in lines)
|
||||
return {
|
||||
'date_from': date_from,
|
||||
'date_to': date_to,
|
||||
'total_debit': total_debit,
|
||||
'total_credit': total_credit,
|
||||
'net': total_debit - total_credit,
|
||||
'line_count': len(lines),
|
||||
}
|
||||
|
||||
|
||||
TOOLS = {
|
||||
'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,
|
||||
}
|
||||
171
fusion_accounting/services/tools/hst_management.py
Normal file
171
fusion_accounting/services/tools/hst_management.py
Normal file
@@ -0,0 +1,171 @@
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def calculate_hst_balance(env, params):
|
||||
date_from = params.get('date_from')
|
||||
date_to = params.get('date_to')
|
||||
base_domain = [
|
||||
('parent_state', '=', 'posted'),
|
||||
('company_id', '=', env.company.id),
|
||||
]
|
||||
if date_from:
|
||||
base_domain.append(('date', '>=', date_from))
|
||||
if date_to:
|
||||
base_domain.append(('date', '<=', date_to))
|
||||
|
||||
collected_accounts = env['account.account'].search([
|
||||
('code', '=like', '2005%'), ('company_id', '=', env.company.id),
|
||||
])
|
||||
itc_accounts = env['account.account'].search([
|
||||
('code', '=like', '2006%'), ('company_id', '=', env.company.id),
|
||||
])
|
||||
|
||||
collected_lines = env['account.move.line'].search(
|
||||
base_domain + [('account_id', 'in', collected_accounts.ids)]
|
||||
)
|
||||
itc_lines = env['account.move.line'].search(
|
||||
base_domain + [('account_id', 'in', itc_accounts.ids)]
|
||||
)
|
||||
|
||||
hst_collected = abs(sum(l.balance for l in collected_lines))
|
||||
itcs = abs(sum(l.balance for l in itc_lines))
|
||||
|
||||
return {
|
||||
'hst_collected': hst_collected,
|
||||
'input_tax_credits': itcs,
|
||||
'net_hst': hst_collected - itcs,
|
||||
'status': 'owing' if (hst_collected - itcs) > 0 else 'refund',
|
||||
'period': f'{date_from or "all"} to {date_to or "now"}',
|
||||
}
|
||||
|
||||
|
||||
def get_tax_report(env, params):
|
||||
report_ref = params.get('report_ref', 'account.generic_tax_report')
|
||||
try:
|
||||
report = env.ref(report_ref)
|
||||
except Exception:
|
||||
return {'error': f'Report not found: {report_ref}'}
|
||||
options = report.get_options({
|
||||
'date': {
|
||||
'date_from': params.get('date_from', ''),
|
||||
'date_to': params.get('date_to', ''),
|
||||
}
|
||||
})
|
||||
lines = report._get_lines(options)
|
||||
return {
|
||||
'report_name': report.name,
|
||||
'lines': [{
|
||||
'name': l.get('name', ''),
|
||||
'columns': [c.get('no_format', c.get('name', '')) for c in l.get('columns', [])],
|
||||
} for l in lines[:50]],
|
||||
}
|
||||
|
||||
|
||||
def find_missing_tax_invoices(env, params):
|
||||
date_from = params.get('date_from')
|
||||
date_to = params.get('date_to')
|
||||
domain = [
|
||||
('move_type', 'in', ('out_invoice', 'out_refund')),
|
||||
('state', '=', 'posted'),
|
||||
('company_id', '=', env.company.id),
|
||||
]
|
||||
if date_from:
|
||||
domain.append(('date', '>=', date_from))
|
||||
if date_to:
|
||||
domain.append(('date', '<=', date_to))
|
||||
|
||||
invoices = env['account.move'].search(domain)
|
||||
missing = invoices.filtered(
|
||||
lambda inv: not any(line.tax_ids for line in inv.invoice_line_ids)
|
||||
)
|
||||
return {
|
||||
'total_invoices': len(invoices),
|
||||
'missing_tax_count': len(missing),
|
||||
'invoices': [{
|
||||
'id': inv.id,
|
||||
'name': inv.name,
|
||||
'partner': inv.partner_id.name if inv.partner_id else '',
|
||||
'amount_total': inv.amount_total,
|
||||
'date': str(inv.date),
|
||||
} for inv in missing[:30]],
|
||||
}
|
||||
|
||||
|
||||
def find_missing_itc_bills(env, params):
|
||||
date_from = params.get('date_from')
|
||||
date_to = params.get('date_to')
|
||||
domain = [
|
||||
('move_type', 'in', ('in_invoice', 'in_refund')),
|
||||
('state', '=', 'posted'),
|
||||
('company_id', '=', env.company.id),
|
||||
]
|
||||
if date_from:
|
||||
domain.append(('date', '>=', date_from))
|
||||
if date_to:
|
||||
domain.append(('date', '<=', date_to))
|
||||
|
||||
bills = env['account.move'].search(domain)
|
||||
missing = bills.filtered(
|
||||
lambda b: not any(line.tax_ids for line in b.invoice_line_ids)
|
||||
)
|
||||
return {
|
||||
'total_bills': len(bills),
|
||||
'missing_itc_count': len(missing),
|
||||
'bills': [{
|
||||
'id': b.id,
|
||||
'name': b.name,
|
||||
'partner': b.partner_id.name if b.partner_id else '',
|
||||
'amount_total': b.amount_total,
|
||||
'date': str(b.date),
|
||||
} for b in missing[:30]],
|
||||
}
|
||||
|
||||
|
||||
def get_tax_return_status(env, params):
|
||||
returns = env['account.return'].search([
|
||||
('company_id', '=', env.company.id),
|
||||
], order='date_start desc', limit=10)
|
||||
return {
|
||||
'returns': [{
|
||||
'id': r.id,
|
||||
'name': r.display_name,
|
||||
'date_start': str(r.date_start) if hasattr(r, 'date_start') else '',
|
||||
'date_end': str(r.date_end) if hasattr(r, 'date_end') else '',
|
||||
'state': r.state if hasattr(r, 'state') else '',
|
||||
} for r in returns],
|
||||
}
|
||||
|
||||
|
||||
def generate_tax_return(env, params):
|
||||
try:
|
||||
env['account.return']._generate_or_refresh_all_returns(
|
||||
company=env.company
|
||||
)
|
||||
return {'status': 'generated', 'message': 'Tax returns refreshed successfully.'}
|
||||
except Exception as e:
|
||||
return {'error': str(e)}
|
||||
|
||||
|
||||
def validate_tax_return(env, params):
|
||||
return_id = int(params['return_id'])
|
||||
tax_return = env['account.return'].browse(return_id)
|
||||
if not tax_return.exists():
|
||||
return {'error': 'Tax return not found'}
|
||||
try:
|
||||
tax_return.action_validate()
|
||||
return {'status': 'validated', 'return_id': return_id}
|
||||
except Exception as e:
|
||||
return {'error': str(e)}
|
||||
|
||||
|
||||
TOOLS = {
|
||||
'calculate_hst_balance': calculate_hst_balance,
|
||||
'get_tax_report': get_tax_report,
|
||||
'find_missing_tax_invoices': find_missing_tax_invoices,
|
||||
'find_missing_itc_bills': find_missing_itc_bills,
|
||||
'get_tax_return_status': get_tax_return_status,
|
||||
'generate_tax_return': generate_tax_return,
|
||||
'validate_tax_return': validate_tax_return,
|
||||
}
|
||||
113
fusion_accounting/services/tools/inventory.py
Normal file
113
fusion_accounting/services/tools/inventory.py
Normal file
@@ -0,0 +1,113 @@
|
||||
import logging
|
||||
from odoo import fields
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_stock_valuation(env, params):
|
||||
accounts = env['account.account'].search([
|
||||
('code', '=like', '1069%'),
|
||||
('company_id', '=', env.company.id),
|
||||
])
|
||||
result = []
|
||||
for acct in accounts:
|
||||
balance = sum(env['account.move.line'].search([
|
||||
('account_id', '=', acct.id),
|
||||
('parent_state', '=', 'posted'),
|
||||
]).mapped('balance'))
|
||||
result.append({'code': acct.code, 'name': acct.name, 'balance': balance})
|
||||
return {'accounts': result, 'total': sum(r['balance'] for r in result)}
|
||||
|
||||
|
||||
def get_price_differences(env, params):
|
||||
accounts = env['account.account'].search([
|
||||
('code', '=like', '5010%'),
|
||||
('company_id', '=', env.company.id),
|
||||
])
|
||||
domain = [
|
||||
('account_id', 'in', accounts.ids),
|
||||
('parent_state', '=', 'posted'),
|
||||
('company_id', '=', env.company.id),
|
||||
]
|
||||
if params.get('date_from'):
|
||||
domain.append(('date', '>=', params['date_from']))
|
||||
if params.get('date_to'):
|
||||
domain.append(('date', '<=', params['date_to']))
|
||||
lines = env['account.move.line'].search(domain, order='date desc', limit=50)
|
||||
return {
|
||||
'total': sum(l.balance for l in lines),
|
||||
'entries': [{
|
||||
'id': l.id, 'date': str(l.date),
|
||||
'move': l.move_id.name, 'amount': l.balance,
|
||||
'partner': l.partner_id.name if l.partner_id else '',
|
||||
} for l in lines],
|
||||
}
|
||||
|
||||
|
||||
def get_cogs_ratio_by_category(env, params):
|
||||
date_from = params.get('date_from')
|
||||
date_to = params.get('date_to')
|
||||
base_domain = [
|
||||
('parent_state', '=', 'posted'),
|
||||
('company_id', '=', env.company.id),
|
||||
]
|
||||
if date_from:
|
||||
base_domain.append(('date', '>=', date_from))
|
||||
if date_to:
|
||||
base_domain.append(('date', '<=', date_to))
|
||||
|
||||
revenue_lines = env['account.move.line'].search(
|
||||
base_domain + [('account_id.account_type', '=', 'income')]
|
||||
)
|
||||
cogs_lines = env['account.move.line'].search(
|
||||
base_domain + [('account_id.account_type', '=', 'expense_direct_cost')]
|
||||
)
|
||||
revenue = abs(sum(l.balance for l in revenue_lines))
|
||||
cogs = abs(sum(l.balance for l in cogs_lines))
|
||||
ratio = (cogs / revenue * 100) if revenue else 0
|
||||
return {'revenue': revenue, 'cogs': cogs, 'ratio_pct': round(ratio, 2)}
|
||||
|
||||
|
||||
def find_unusual_adjustments(env, params):
|
||||
threshold = float(params.get('threshold', 1000))
|
||||
domain = [
|
||||
('parent_state', '=', 'posted'),
|
||||
('company_id', '=', env.company.id),
|
||||
('account_id.account_type', '=', 'expense_direct_cost'),
|
||||
]
|
||||
lines = env['account.move.line'].search(domain)
|
||||
unusual = lines.filtered(lambda l: abs(l.balance) > threshold)
|
||||
return {
|
||||
'count': len(unusual),
|
||||
'adjustments': [{
|
||||
'id': l.id, 'date': str(l.date), 'move': l.move_id.name,
|
||||
'amount': l.balance, 'name': l.name or '',
|
||||
} for l in unusual[:20]],
|
||||
}
|
||||
|
||||
|
||||
def get_inventory_turnover(env, params):
|
||||
from .reporting import get_profit_loss
|
||||
pl = get_profit_loss(env, params)
|
||||
stock = get_stock_valuation(env, params)
|
||||
avg_inventory = stock.get('total', 0)
|
||||
cogs = 0
|
||||
for line in pl.get('lines', []):
|
||||
if 'cost' in line.get('name', '').lower():
|
||||
cols = line.get('columns', [])
|
||||
if cols:
|
||||
try:
|
||||
cogs = float(cols[0])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
turnover = (cogs / avg_inventory) if avg_inventory else 0
|
||||
return {'cogs': cogs, 'avg_inventory': avg_inventory, 'turnover': round(turnover, 2)}
|
||||
|
||||
|
||||
TOOLS = {
|
||||
'get_stock_valuation': get_stock_valuation,
|
||||
'get_price_differences': get_price_differences,
|
||||
'get_cogs_ratio_by_category': get_cogs_ratio_by_category,
|
||||
'find_unusual_adjustments': find_unusual_adjustments,
|
||||
'get_inventory_turnover': get_inventory_turnover,
|
||||
}
|
||||
220
fusion_accounting/services/tools/journal_review.py
Normal file
220
fusion_accounting/services/tools/journal_review.py
Normal file
@@ -0,0 +1,220 @@
|
||||
import logging
|
||||
from odoo import fields
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
ACCOUNT_TYPE_EXPECTED_DIRECTION = {
|
||||
'asset_receivable': 'debit',
|
||||
'asset_cash': 'debit',
|
||||
'asset_current': 'debit',
|
||||
'asset_non_current': 'debit',
|
||||
'asset_prepayments': 'debit',
|
||||
'asset_fixed': 'debit',
|
||||
'liability_payable': 'credit',
|
||||
'liability_credit_card': 'credit',
|
||||
'liability_current': 'credit',
|
||||
'liability_non_current': 'credit',
|
||||
'equity': 'credit',
|
||||
'equity_unaffected': 'credit',
|
||||
'income': 'credit',
|
||||
'income_other': 'credit',
|
||||
'expense': 'debit',
|
||||
'expense_depreciation': 'debit',
|
||||
'expense_direct_cost': 'debit',
|
||||
'off_balance': None,
|
||||
}
|
||||
|
||||
|
||||
def find_wrong_direction_balances(env, params):
|
||||
balance_data = env['account.move.line'].read_group(
|
||||
[('parent_state', '=', 'posted'), ('company_id', '=', env.company.id)],
|
||||
['balance:sum'], ['account_id'],
|
||||
)
|
||||
acct_ids = [row['account_id'][0] for row in balance_data if row.get('account_id')]
|
||||
acct_map = {}
|
||||
if acct_ids:
|
||||
for acct in env['account.account'].browse(acct_ids):
|
||||
acct_map[acct.id] = acct
|
||||
|
||||
issues = []
|
||||
for row in balance_data:
|
||||
if not row.get('account_id'):
|
||||
continue
|
||||
acct = acct_map.get(row['account_id'][0])
|
||||
if not acct:
|
||||
continue
|
||||
expected = ACCOUNT_TYPE_EXPECTED_DIRECTION.get(acct.account_type)
|
||||
if not expected:
|
||||
continue
|
||||
balance = row.get('balance', 0) or 0
|
||||
if (expected == 'debit' and balance < -0.01) or (expected == 'credit' and balance > 0.01):
|
||||
issues.append({
|
||||
'account_id': acct.id,
|
||||
'code': acct.code,
|
||||
'name': acct.name,
|
||||
'balance': balance,
|
||||
'expected': expected,
|
||||
'actual': 'credit' if balance < 0 else 'debit',
|
||||
})
|
||||
return {'count': len(issues), 'issues': issues}
|
||||
|
||||
|
||||
def find_duplicate_entries(env, params):
|
||||
date_from = params.get('date_from')
|
||||
date_to = params.get('date_to')
|
||||
domain = [
|
||||
('state', '=', 'posted'),
|
||||
('company_id', '=', env.company.id),
|
||||
]
|
||||
if date_from:
|
||||
domain.append(('date', '>=', date_from))
|
||||
if date_to:
|
||||
domain.append(('date', '<=', date_to))
|
||||
moves = env['account.move'].search(domain, order='partner_id, amount_total, date')
|
||||
|
||||
duplicates = []
|
||||
prev = None
|
||||
for move in moves:
|
||||
if prev and (
|
||||
prev.partner_id == move.partner_id and prev.partner_id
|
||||
and abs(prev.amount_total - move.amount_total) < 0.01
|
||||
and prev.date == move.date
|
||||
and prev.journal_id == move.journal_id
|
||||
):
|
||||
duplicates.append({
|
||||
'entry_1': {'id': prev.id, 'name': prev.name},
|
||||
'entry_2': {'id': move.id, 'name': move.name},
|
||||
'partner': move.partner_id.name,
|
||||
'amount': move.amount_total,
|
||||
'date': str(move.date),
|
||||
})
|
||||
prev = move
|
||||
return {'count': len(duplicates), 'duplicates': duplicates[:20]}
|
||||
|
||||
|
||||
def find_wrong_account_entries(env, params):
|
||||
date_from = params.get('date_from')
|
||||
date_to = params.get('date_to')
|
||||
domain = [
|
||||
('parent_state', '=', 'posted'),
|
||||
('company_id', '=', env.company.id),
|
||||
]
|
||||
if date_from:
|
||||
domain.append(('date', '>=', date_from))
|
||||
if date_to:
|
||||
domain.append(('date', '<=', date_to))
|
||||
|
||||
issues = []
|
||||
tax_accounts = env['account.account'].search([
|
||||
('account_type', 'in', ('liability_current', 'asset_current')),
|
||||
('code', '=like', '2005%'),
|
||||
('company_id', '=', env.company.id),
|
||||
])
|
||||
if tax_accounts:
|
||||
revenue_on_tax = env['account.move.line'].search(
|
||||
domain + [
|
||||
('account_id', 'in', tax_accounts.ids),
|
||||
('product_id', '!=', False),
|
||||
]
|
||||
)
|
||||
for line in revenue_on_tax[:20]:
|
||||
issues.append({
|
||||
'id': line.id,
|
||||
'move': line.move_id.name,
|
||||
'account': f'{line.account_id.code} {line.account_id.name}',
|
||||
'product': line.product_id.name,
|
||||
'amount': line.balance,
|
||||
'issue': 'Product line on tax account',
|
||||
})
|
||||
return {'count': len(issues), 'issues': issues}
|
||||
|
||||
|
||||
def find_sequence_gaps(env, params):
|
||||
moves = env['account.move'].search([
|
||||
('state', '=', 'posted'),
|
||||
('company_id', '=', env.company.id),
|
||||
('made_sequence_gap', '=', True),
|
||||
], order='date desc', limit=50)
|
||||
return {
|
||||
'count': len(moves),
|
||||
'gaps': [{
|
||||
'id': m.id,
|
||||
'name': m.name,
|
||||
'date': str(m.date),
|
||||
'journal': m.journal_id.name,
|
||||
} for m in moves],
|
||||
}
|
||||
|
||||
|
||||
def find_draft_entries(env, params):
|
||||
min_age_days = int(params.get('min_age_days', 30))
|
||||
from datetime import timedelta
|
||||
cutoff = fields.Date.today() - timedelta(days=min_age_days)
|
||||
drafts = env['account.move'].search([
|
||||
('state', '=', 'draft'),
|
||||
('date', '<=', cutoff),
|
||||
('company_id', '=', env.company.id),
|
||||
], order='date asc', limit=50)
|
||||
return {
|
||||
'count': len(drafts),
|
||||
'entries': [{
|
||||
'id': d.id,
|
||||
'name': d.name or 'Draft',
|
||||
'date': str(d.date),
|
||||
'journal': d.journal_id.name,
|
||||
'amount': d.amount_total,
|
||||
'partner': d.partner_id.name if d.partner_id else '',
|
||||
} for d in drafts],
|
||||
}
|
||||
|
||||
|
||||
def find_unreconciled_suspense(env, params):
|
||||
suspense_accounts = env['account.account'].search([
|
||||
('code', '=like', '999%'),
|
||||
('company_id', '=', env.company.id),
|
||||
])
|
||||
issues = []
|
||||
for acct in suspense_accounts:
|
||||
balance = sum(env['account.move.line'].search([
|
||||
('account_id', '=', acct.id),
|
||||
('parent_state', '=', 'posted'),
|
||||
]).mapped('balance'))
|
||||
if abs(balance) > 0.01:
|
||||
issues.append({
|
||||
'account_id': acct.id,
|
||||
'code': acct.code,
|
||||
'name': acct.name,
|
||||
'balance': balance,
|
||||
})
|
||||
return {'count': len(issues), 'accounts': issues}
|
||||
|
||||
|
||||
def verify_reconciliation_integrity(env, params):
|
||||
partials = env['account.partial.reconcile'].search([
|
||||
('company_id', '=', env.company.id),
|
||||
], limit=500)
|
||||
issues = []
|
||||
for p in partials:
|
||||
debit_ok = p.debit_move_id.reconciled or abs(p.debit_move_id.amount_residual) < 0.01
|
||||
credit_ok = p.credit_move_id.reconciled or abs(p.credit_move_id.amount_residual) < 0.01
|
||||
if not debit_ok and not credit_ok:
|
||||
issues.append({
|
||||
'id': p.id,
|
||||
'debit_move': p.debit_move_id.move_id.name,
|
||||
'credit_move': p.credit_move_id.move_id.name,
|
||||
'amount': p.amount,
|
||||
'debit_residual': p.debit_move_id.amount_residual,
|
||||
'credit_residual': p.credit_move_id.amount_residual,
|
||||
})
|
||||
return {'count': len(issues), 'issues': issues[:20]}
|
||||
|
||||
|
||||
TOOLS = {
|
||||
'find_wrong_direction_balances': find_wrong_direction_balances,
|
||||
'find_duplicate_entries': find_duplicate_entries,
|
||||
'find_wrong_account_entries': find_wrong_account_entries,
|
||||
'find_sequence_gaps': find_sequence_gaps,
|
||||
'find_draft_entries': find_draft_entries,
|
||||
'find_unreconciled_suspense': find_unreconciled_suspense,
|
||||
'verify_reconciliation_integrity': verify_reconciliation_integrity,
|
||||
}
|
||||
130
fusion_accounting/services/tools/month_end.py
Normal file
130
fusion_accounting/services/tools/month_end.py
Normal file
@@ -0,0 +1,130 @@
|
||||
import logging
|
||||
from odoo import fields
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_close_checklist(env, params):
|
||||
from .bank_reconciliation import get_unreconciled_bank_lines
|
||||
from .journal_review import find_draft_entries, find_sequence_gaps
|
||||
from .hst_management import calculate_hst_balance
|
||||
|
||||
period = params.get('period', str(fields.Date.today())[:7])
|
||||
date_from = f'{period}-01'
|
||||
import calendar
|
||||
year, month = int(period[:4]), int(period[5:7])
|
||||
last_day = calendar.monthrange(year, month)[1]
|
||||
date_to = f'{period}-{last_day:02d}'
|
||||
|
||||
p = {'date_from': date_from, 'date_to': date_to}
|
||||
|
||||
bank = get_unreconciled_bank_lines(env, p)
|
||||
drafts = find_draft_entries(env, {'min_age_days': '0'})
|
||||
gaps = find_sequence_gaps(env, p)
|
||||
hst = calculate_hst_balance(env, p)
|
||||
|
||||
checklist = [
|
||||
{'item': 'Bank Reconciliation', 'status': 'ok' if bank['count'] == 0 else 'attention', 'detail': f"{bank['count']} unreconciled lines"},
|
||||
{'item': 'Draft Entries', 'status': 'ok' if drafts['count'] == 0 else 'attention', 'detail': f"{drafts['count']} draft entries"},
|
||||
{'item': 'Sequence Gaps', 'status': 'ok' if gaps['count'] == 0 else 'warning', 'detail': f"{gaps['count']} gaps found"},
|
||||
{'item': 'HST Balance', 'status': 'info', 'detail': f"Net HST: ${hst['net_hst']:.2f}"},
|
||||
]
|
||||
return {'period': period, 'checklist': checklist}
|
||||
|
||||
|
||||
def get_unreconciled_counts(env, params):
|
||||
accounts = env['account.account'].search([
|
||||
('reconcile', '=', True),
|
||||
('company_id', '=', env.company.id),
|
||||
])
|
||||
result = []
|
||||
for acct in accounts:
|
||||
count = env['account.move.line'].search_count([
|
||||
('account_id', '=', acct.id),
|
||||
('reconciled', '=', False),
|
||||
('parent_state', '=', 'posted'),
|
||||
])
|
||||
if count > 0:
|
||||
result.append({
|
||||
'account_id': acct.id,
|
||||
'code': acct.code,
|
||||
'name': acct.name,
|
||||
'unreconciled_count': count,
|
||||
})
|
||||
return {'accounts': sorted(result, key=lambda x: -x['unreconciled_count'])}
|
||||
|
||||
|
||||
def find_entries_in_locked_period(env, params):
|
||||
company = env.company
|
||||
lock_date = company.fiscalyear_lock_date
|
||||
if not lock_date:
|
||||
return {'status': 'no_lock_date', 'entries': []}
|
||||
entries = env['account.move'].search([
|
||||
('date', '<=', lock_date),
|
||||
('state', '=', 'draft'),
|
||||
('company_id', '=', company.id),
|
||||
])
|
||||
return {
|
||||
'lock_date': str(lock_date),
|
||||
'count': len(entries),
|
||||
'entries': [{'id': e.id, 'name': e.name, 'date': str(e.date)} for e in entries[:20]],
|
||||
}
|
||||
|
||||
|
||||
def get_accrual_status(env, params):
|
||||
accrual_codes = params.get('account_codes', ['2100', '2110', '2120'])
|
||||
result = []
|
||||
for code in accrual_codes:
|
||||
accounts = env['account.account'].search([
|
||||
('code', '=like', f'{code}%'),
|
||||
('company_id', '=', env.company.id),
|
||||
])
|
||||
for acct in accounts:
|
||||
balance = sum(env['account.move.line'].search([
|
||||
('account_id', '=', acct.id),
|
||||
('parent_state', '=', 'posted'),
|
||||
]).mapped('balance'))
|
||||
result.append({'code': acct.code, 'name': acct.name, 'balance': balance})
|
||||
return {'accruals': result}
|
||||
|
||||
|
||||
def run_hash_integrity_check(env, params):
|
||||
try:
|
||||
result = env.company._check_hash_integrity()
|
||||
return {
|
||||
'status': 'completed',
|
||||
'results': result.get('results', []),
|
||||
'printing_date': result.get('printing_date', ''),
|
||||
}
|
||||
except Exception as e:
|
||||
return {'error': str(e)}
|
||||
|
||||
|
||||
def get_period_summary(env, params):
|
||||
date_from = params.get('date_from')
|
||||
date_to = params.get('date_to')
|
||||
try:
|
||||
report = env.ref('account_reports.trial_balance_report')
|
||||
except Exception:
|
||||
report = env.ref('account.trial_balance_report', raise_if_not_found=False)
|
||||
if not report:
|
||||
return {'error': 'Trial balance report not found'}
|
||||
options = report.get_options({'date': {'date_from': date_from, 'date_to': date_to}})
|
||||
lines = report._get_lines(options)
|
||||
return {
|
||||
'period': f'{date_from} to {date_to}',
|
||||
'lines': [{
|
||||
'name': l.get('name', ''),
|
||||
'columns': [c.get('no_format', c.get('name', '')) for c in l.get('columns', [])],
|
||||
} for l in lines[:100]],
|
||||
}
|
||||
|
||||
|
||||
TOOLS = {
|
||||
'get_close_checklist': get_close_checklist,
|
||||
'get_unreconciled_counts': get_unreconciled_counts,
|
||||
'find_entries_in_locked_period': find_entries_in_locked_period,
|
||||
'get_accrual_status': get_accrual_status,
|
||||
'run_hash_integrity_check': run_hash_integrity_check,
|
||||
'get_period_summary': get_period_summary,
|
||||
}
|
||||
205
fusion_accounting/services/tools/payroll.py
Normal file
205
fusion_accounting/services/tools/payroll.py
Normal file
@@ -0,0 +1,205 @@
|
||||
import logging
|
||||
from odoo import fields
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_payroll_entries(env, params):
|
||||
payroll_journals = env['account.journal'].search([
|
||||
('name', 'ilike', 'payroll'),
|
||||
('company_id', '=', env.company.id),
|
||||
])
|
||||
if not payroll_journals and params.get('journal_id'):
|
||||
payroll_journals = env['account.journal'].browse(int(params['journal_id']))
|
||||
domain = [
|
||||
('journal_id', 'in', payroll_journals.ids),
|
||||
('state', '=', 'posted'),
|
||||
('company_id', '=', env.company.id),
|
||||
]
|
||||
if params.get('date_from'):
|
||||
domain.append(('date', '>=', params['date_from']))
|
||||
if params.get('date_to'):
|
||||
domain.append(('date', '<=', params['date_to']))
|
||||
entries = env['account.move'].search(domain, order='date desc', limit=50)
|
||||
return {
|
||||
'count': len(entries),
|
||||
'entries': [{
|
||||
'id': e.id, 'name': e.name, 'date': str(e.date),
|
||||
'amount': e.amount_total, 'ref': e.ref or '',
|
||||
} for e in entries],
|
||||
}
|
||||
|
||||
|
||||
def compare_payroll_to_bank(env, params):
|
||||
date_from = params.get('date_from')
|
||||
date_to = params.get('date_to')
|
||||
if not date_from or not date_to:
|
||||
return {'error': 'date_from and date_to are required'}
|
||||
payroll_journals = env['account.journal'].search([
|
||||
('name', 'ilike', 'payroll'), ('company_id', '=', env.company.id),
|
||||
])
|
||||
payroll_entries = env['account.move'].search([
|
||||
('journal_id', 'in', payroll_journals.ids),
|
||||
('state', '=', 'posted'),
|
||||
('date', '>=', date_from), ('date', '<=', date_to),
|
||||
])
|
||||
bank_lines = env['account.bank.statement.line'].search([
|
||||
('date', '>=', date_from), ('date', '<=', date_to),
|
||||
('company_id', '=', env.company.id),
|
||||
])
|
||||
payroll_total = sum(e.amount_total for e in payroll_entries)
|
||||
bank_payroll = sum(abs(l.amount) for l in bank_lines if 'payroll' in (l.payment_ref or '').lower())
|
||||
return {
|
||||
'payroll_journal_total': payroll_total,
|
||||
'bank_payroll_total': bank_payroll,
|
||||
'difference': payroll_total - bank_payroll,
|
||||
}
|
||||
|
||||
|
||||
def verify_source_deductions(env, params):
|
||||
return {
|
||||
'status': 'info',
|
||||
'message': 'Source deduction verification requires CRA rate tables. Use fusion_payroll for full verification.',
|
||||
}
|
||||
|
||||
|
||||
def get_cra_remittance_status(env, params):
|
||||
cra_accounts = env['account.account'].search([
|
||||
('name', 'ilike', 'CRA'),
|
||||
('company_id', '=', env.company.id),
|
||||
])
|
||||
result = []
|
||||
for acct in cra_accounts:
|
||||
balance = sum(env['account.move.line'].search([
|
||||
('account_id', '=', acct.id),
|
||||
('parent_state', '=', 'posted'),
|
||||
]).mapped('balance'))
|
||||
result.append({'code': acct.code, 'name': acct.name, 'balance': balance})
|
||||
return {'accounts': result}
|
||||
|
||||
|
||||
def find_unmatched_payroll_cheques(env, params):
|
||||
bank_lines = env['account.bank.statement.line'].search([
|
||||
('is_reconciled', '=', False),
|
||||
('company_id', '=', env.company.id),
|
||||
('payment_ref', 'ilike', 'cheque'),
|
||||
])
|
||||
return {
|
||||
'count': len(bank_lines),
|
||||
'cheques': [{
|
||||
'id': l.id, 'date': str(l.date),
|
||||
'ref': l.payment_ref, 'amount': l.amount,
|
||||
} for l in bank_lines[:30]],
|
||||
}
|
||||
|
||||
|
||||
def parse_payroll_summary(env, params):
|
||||
import re
|
||||
raw_data = params.get('data', '')
|
||||
if not raw_data:
|
||||
return {'error': 'No payroll data provided'}
|
||||
|
||||
lines = raw_data.strip().split('\n')
|
||||
entries = []
|
||||
totals = {'gross': 0, 'cpp': 0, 'ei': 0, 'tax': 0, 'net': 0}
|
||||
|
||||
for line in lines:
|
||||
amounts = re.findall(r'\$?([\d,]+\.?\d*)', line)
|
||||
if len(amounts) >= 2:
|
||||
name_part = re.sub(r'\$?[\d,]+\.?\d*', '', line).strip(' \t,|-')
|
||||
parsed_amounts = [float(a.replace(',', '')) for a in amounts]
|
||||
entry = {'name': name_part or 'Employee', 'amounts': parsed_amounts}
|
||||
if len(parsed_amounts) >= 5:
|
||||
entry.update({
|
||||
'gross': parsed_amounts[0],
|
||||
'cpp': parsed_amounts[1],
|
||||
'ei': parsed_amounts[2],
|
||||
'tax': parsed_amounts[3],
|
||||
'net': parsed_amounts[4] if len(parsed_amounts) > 4 else parsed_amounts[0] - sum(parsed_amounts[1:4]),
|
||||
})
|
||||
for k in ('gross', 'cpp', 'ei', 'tax', 'net'):
|
||||
totals[k] += entry.get(k, 0)
|
||||
entries.append(entry)
|
||||
|
||||
return {
|
||||
'status': 'parsed',
|
||||
'employee_count': len(entries),
|
||||
'entries': entries,
|
||||
'totals': totals,
|
||||
'raw_lines': len(lines),
|
||||
}
|
||||
|
||||
|
||||
def create_payroll_journal_entry(env, params):
|
||||
journal_id = int(params['journal_id'])
|
||||
date = params['date']
|
||||
lines_data = params['lines']
|
||||
move_vals = {
|
||||
'journal_id': journal_id,
|
||||
'date': date,
|
||||
'ref': params.get('ref', 'Payroll Entry'),
|
||||
'line_ids': [(0, 0, {
|
||||
'account_id': int(line['account_id']),
|
||||
'name': line.get('name', 'Payroll'),
|
||||
'debit': float(line.get('debit', 0)),
|
||||
'credit': float(line.get('credit', 0)),
|
||||
'partner_id': int(line['partner_id']) if line.get('partner_id') else False,
|
||||
}) for line in lines_data],
|
||||
}
|
||||
move = env['account.move'].create(move_vals)
|
||||
return {'status': 'created', 'move_id': move.id, 'name': move.name}
|
||||
|
||||
|
||||
def get_payroll_schedule(env, params):
|
||||
return {'status': 'info', 'message': 'Payroll schedule available via fusion_payroll module.'}
|
||||
|
||||
|
||||
def match_payroll_cheques(env, params):
|
||||
st_line_id = int(params['statement_line_id'])
|
||||
move_line_ids = [int(x) for x in params['move_line_ids']]
|
||||
st_line = env['account.bank.statement.line'].browse(st_line_id)
|
||||
st_line.set_line_bank_statement_line(move_line_ids)
|
||||
return {'status': 'matched', 'statement_line_id': st_line_id}
|
||||
|
||||
|
||||
def verify_payroll_deductions(env, params):
|
||||
return verify_source_deductions(env, params)
|
||||
|
||||
|
||||
def get_cra_remittance_due(env, params):
|
||||
return get_cra_remittance_status(env, params)
|
||||
|
||||
|
||||
def prepare_cra_payment(env, params):
|
||||
return create_payroll_journal_entry(env, params)
|
||||
|
||||
|
||||
def generate_t4(env, params):
|
||||
return {'status': 'info', 'message': 'T4 generation available via fusion_payroll module.'}
|
||||
|
||||
|
||||
def generate_roe(env, params):
|
||||
return {'status': 'info', 'message': 'ROE generation available via fusion_payroll module.'}
|
||||
|
||||
|
||||
def get_payroll_cost_report(env, params):
|
||||
return get_payroll_entries(env, params)
|
||||
|
||||
|
||||
TOOLS = {
|
||||
'get_payroll_entries': get_payroll_entries,
|
||||
'compare_payroll_to_bank': compare_payroll_to_bank,
|
||||
'verify_source_deductions': verify_source_deductions,
|
||||
'get_cra_remittance_status': get_cra_remittance_status,
|
||||
'find_unmatched_payroll_cheques': find_unmatched_payroll_cheques,
|
||||
'parse_payroll_summary': parse_payroll_summary,
|
||||
'create_payroll_journal_entry': create_payroll_journal_entry,
|
||||
'get_payroll_schedule': get_payroll_schedule,
|
||||
'match_payroll_cheques': match_payroll_cheques,
|
||||
'verify_payroll_deductions': verify_payroll_deductions,
|
||||
'get_cra_remittance_due': get_cra_remittance_due,
|
||||
'prepare_cra_payment': prepare_cra_payment,
|
||||
'generate_t4': generate_t4,
|
||||
'generate_roe': generate_roe,
|
||||
'get_payroll_cost_report': get_payroll_cost_report,
|
||||
}
|
||||
117
fusion_accounting/services/tools/reporting.py
Normal file
117
fusion_accounting/services/tools/reporting.py
Normal file
@@ -0,0 +1,117 @@
|
||||
import logging
|
||||
import base64
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_report(env, ref_id):
|
||||
try:
|
||||
return env.ref(ref_id)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _run_report(env, report_ref, params):
|
||||
report = _get_report(env, report_ref)
|
||||
if not report:
|
||||
return {'error': f'Report {report_ref} not found'}
|
||||
date_opts = {}
|
||||
if params.get('date_from'):
|
||||
date_opts['date_from'] = params['date_from']
|
||||
if params.get('date_to'):
|
||||
date_opts['date_to'] = params['date_to']
|
||||
options = report.get_options({'date': date_opts} if date_opts else {})
|
||||
lines = report._get_lines(options)
|
||||
return {
|
||||
'report_name': report.name,
|
||||
'lines': [{
|
||||
'name': l.get('name', ''),
|
||||
'level': l.get('level', 0),
|
||||
'columns': [c.get('no_format', c.get('name', '')) for c in l.get('columns', [])],
|
||||
} for l in lines[:100]],
|
||||
}
|
||||
|
||||
|
||||
def get_profit_loss(env, params):
|
||||
return _run_report(env, 'account_reports.profit_and_loss', params)
|
||||
|
||||
|
||||
def get_balance_sheet(env, params):
|
||||
return _run_report(env, 'account_reports.balance_sheet', params)
|
||||
|
||||
|
||||
def get_trial_balance(env, params):
|
||||
return _run_report(env, 'account_reports.trial_balance_report', params)
|
||||
|
||||
|
||||
def get_cash_flow(env, params):
|
||||
return _run_report(env, 'account_reports.cash_flow_statement', params)
|
||||
|
||||
|
||||
def compare_periods(env, params):
|
||||
report_ref = params.get('report_ref', 'account_reports.profit_and_loss')
|
||||
report = _get_report(env, report_ref)
|
||||
if not report:
|
||||
return {'error': f'Report {report_ref} not found'}
|
||||
|
||||
period1 = _run_report(env, report_ref, {
|
||||
'date_from': params.get('period1_from'),
|
||||
'date_to': params.get('period1_to'),
|
||||
})
|
||||
period2 = _run_report(env, report_ref, {
|
||||
'date_from': params.get('period2_from'),
|
||||
'date_to': params.get('period2_to'),
|
||||
})
|
||||
return {'period_1': period1, 'period_2': period2}
|
||||
|
||||
|
||||
def answer_financial_question(env, params):
|
||||
question = params.get('question', '')
|
||||
sql_query = params.get('sql_query')
|
||||
if sql_query:
|
||||
return {'error': 'Direct SQL not permitted. Use report tools instead.'}
|
||||
return {'status': 'info', 'message': f'Use specific report tools to answer: {question}'}
|
||||
|
||||
|
||||
def export_report(env, params):
|
||||
report_ref = params.get('report_ref', 'account_reports.profit_and_loss')
|
||||
fmt = params.get('format', 'pdf')
|
||||
report = _get_report(env, report_ref)
|
||||
if not report:
|
||||
return {'error': f'Report {report_ref} not found'}
|
||||
date_opts = {}
|
||||
if params.get('date_from'):
|
||||
date_opts['date_from'] = params['date_from']
|
||||
if params.get('date_to'):
|
||||
date_opts['date_to'] = params['date_to']
|
||||
options = report.get_options({'date': date_opts} if date_opts else {})
|
||||
|
||||
try:
|
||||
if fmt == 'xlsx':
|
||||
result = report.dispatch_report_action(options, 'export_to_xlsx')
|
||||
else:
|
||||
result = report.dispatch_report_action(options, 'export_to_pdf')
|
||||
|
||||
if isinstance(result, dict) and result.get('file_content'):
|
||||
return {
|
||||
'file_name': result.get('file_name', f'report.{fmt}'),
|
||||
'file_type': result.get('file_type', fmt),
|
||||
'file_content_b64': base64.b64encode(result['file_content']).decode(),
|
||||
}
|
||||
return {
|
||||
'status': 'generated',
|
||||
'message': f'Report exported as {fmt}. Use the Odoo UI to download.',
|
||||
}
|
||||
except Exception as e:
|
||||
return {'error': f'Export failed: {str(e)}'}
|
||||
|
||||
|
||||
TOOLS = {
|
||||
'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,
|
||||
'answer_financial_question': answer_financial_question,
|
||||
'export_report': export_report,
|
||||
}
|
||||
BIN
fusion_accounting/static/description/icon.png
Normal file
BIN
fusion_accounting/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
@@ -0,0 +1,20 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
|
||||
export class FusionApprovalCard extends Component {
|
||||
static template = "fusion_accounting.ApprovalCard";
|
||||
static props = ["approval", "onApprove", "onReject"];
|
||||
|
||||
get confidencePercent() {
|
||||
return Math.round((this.props.approval.confidence || 0) * 100);
|
||||
}
|
||||
|
||||
approve() {
|
||||
this.props.onApprove(this.props.approval.id);
|
||||
}
|
||||
|
||||
reject() {
|
||||
this.props.onReject(this.props.approval.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="fusion_accounting.ApprovalCard">
|
||||
<div class="fusion_approval_card card border-warning mb-2">
|
||||
<div class="card-body p-2">
|
||||
<div class="d-flex justify-content-between align-items-start mb-1">
|
||||
<strong t-esc="props.approval.tool_name"/>
|
||||
<span class="badge bg-warning text-dark">
|
||||
<t t-esc="confidencePercent"/>% conf
|
||||
</span>
|
||||
</div>
|
||||
<p class="small mb-1 text-muted" t-esc="props.approval.reasoning"/>
|
||||
<t t-if="props.approval.amount">
|
||||
<p class="small mb-1">
|
||||
Amount: <strong>$<t t-esc="(props.approval.amount || 0).toFixed(2)"/></strong>
|
||||
</p>
|
||||
</t>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-success btn-sm flex-grow-1" t-on-click="approve">
|
||||
<i class="fa fa-check"/> Approve
|
||||
</button>
|
||||
<button class="btn btn-outline-danger btn-sm flex-grow-1" t-on-click="reject">
|
||||
<i class="fa fa-times"/> Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
304
fusion_accounting/static/src/components/chat/chat_panel.js
Normal file
304
fusion_accounting/static/src/components/chat/chat_panel.js
Normal file
@@ -0,0 +1,304 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component, useState, useRef, onWillStart, onMounted, onPatched } from "@odoo/owl";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { FusionApprovalCard } from "./approval_card";
|
||||
|
||||
function mdToHtml(text) {
|
||||
if (!text) return "";
|
||||
|
||||
// Split into lines for block-level processing
|
||||
const lines = text.split("\n");
|
||||
const output = [];
|
||||
let inTable = false;
|
||||
let tableHeader = false;
|
||||
let inList = false;
|
||||
let listType = null;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
let line = lines[i];
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Close list if we're not in a list item anymore
|
||||
if (inList && !trimmed.match(/^[-*]\s/) && !trimmed.match(/^\d+\.\s/) && trimmed !== "") {
|
||||
output.push(listType === "ul" ? "</ul>" : "</ol>");
|
||||
inList = false;
|
||||
listType = null;
|
||||
}
|
||||
|
||||
// Table row detection (line has at least 2 pipes)
|
||||
const pipeCount = (trimmed.match(/\|/g) || []).length;
|
||||
if (pipeCount >= 2 && trimmed.includes("|")) {
|
||||
// Separator row (|---|---|)
|
||||
if (/^[\s|:\-]+$/.test(trimmed)) {
|
||||
tableHeader = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Split cells
|
||||
let cells = trimmed.split("|").map(c => c.trim());
|
||||
// Remove empty first/last from leading/trailing pipes
|
||||
if (cells[0] === "") cells.shift();
|
||||
if (cells.length > 0 && cells[cells.length - 1] === "") cells.pop();
|
||||
|
||||
if (!inTable) {
|
||||
output.push('<div class="table-responsive my-2"><table class="table table-sm table-bordered align-middle">');
|
||||
inTable = true;
|
||||
// First row is header
|
||||
output.push("<thead><tr>");
|
||||
cells.forEach(c => output.push(`<th class="px-2 py-1 fw-semibold">${inlineFormat(c)}</th>`));
|
||||
output.push("</tr></thead><tbody>");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Body row
|
||||
output.push("<tr>");
|
||||
cells.forEach(c => output.push(`<td class="px-2 py-1">${inlineFormat(c)}</td>`));
|
||||
output.push("</tr>");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Close table if we were in one
|
||||
if (inTable) {
|
||||
output.push("</tbody></table></div>");
|
||||
inTable = false;
|
||||
tableHeader = false;
|
||||
}
|
||||
|
||||
// Empty line
|
||||
if (trimmed === "") {
|
||||
output.push("");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Headers
|
||||
const headerMatch = trimmed.match(/^(#{1,5})\s+(.+)$/);
|
||||
if (headerMatch) {
|
||||
const level = Math.min(headerMatch[1].length + 2, 6); // ## -> h4, ### -> h5
|
||||
output.push(`<h${level} class="mt-3 mb-1">${inlineFormat(headerMatch[2])}</h${level}>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Horizontal rule
|
||||
if (/^[-*_]{3,}$/.test(trimmed)) {
|
||||
output.push('<hr class="my-2"/>');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unordered list
|
||||
const ulMatch = trimmed.match(/^[-*]\s+(.+)$/);
|
||||
if (ulMatch) {
|
||||
if (!inList || listType !== "ul") {
|
||||
if (inList) output.push(listType === "ul" ? "</ul>" : "</ol>");
|
||||
output.push('<ul class="mb-1">');
|
||||
inList = true;
|
||||
listType = "ul";
|
||||
}
|
||||
output.push(`<li>${inlineFormat(ulMatch[1])}</li>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ordered list
|
||||
const olMatch = trimmed.match(/^\d+\.\s+(.+)$/);
|
||||
if (olMatch) {
|
||||
if (!inList || listType !== "ol") {
|
||||
if (inList) output.push(listType === "ul" ? "</ul>" : "</ol>");
|
||||
output.push('<ol class="mb-1">');
|
||||
inList = true;
|
||||
listType = "ol";
|
||||
}
|
||||
output.push(`<li>${inlineFormat(olMatch[1])}</li>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular paragraph
|
||||
output.push(`<p class="mb-1">${inlineFormat(trimmed)}</p>`);
|
||||
}
|
||||
|
||||
// Close open elements
|
||||
if (inTable) output.push("</tbody></table></div>");
|
||||
if (inList) output.push(listType === "ul" ? "</ul>" : "</ol>");
|
||||
|
||||
return output.join("\n");
|
||||
}
|
||||
|
||||
function inlineFormat(text) {
|
||||
if (!text) return "";
|
||||
return text
|
||||
// Escape HTML entities
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
// Bold + italic
|
||||
.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
// Inline code
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
// Links [text](url)
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>')
|
||||
// Odoo record links #model,id
|
||||
.replace(/#([\w.]+),(\d+)/g, '<a href="/odoo/$1/$2" class="badge text-bg-primary text-decoration-none">$1#$2</a>');
|
||||
}
|
||||
|
||||
|
||||
export class FusionChatPanel extends Component {
|
||||
static template = "fusion_accounting.ChatPanel";
|
||||
static components = { FusionApprovalCard };
|
||||
static props = ["sessionId?"];
|
||||
|
||||
setup() {
|
||||
this.inputRef = useRef("chatInput");
|
||||
this.messagesRef = useRef("messages");
|
||||
this.state = useState({
|
||||
messages: [],
|
||||
pendingApprovals: [],
|
||||
inputText: "",
|
||||
sending: false,
|
||||
loading: true,
|
||||
internalSessionId: null,
|
||||
sessionName: null,
|
||||
});
|
||||
|
||||
onWillStart(async () => {
|
||||
await this.loadLatestSession();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
this._renderRichMessages();
|
||||
});
|
||||
|
||||
onPatched(() => {
|
||||
this._renderRichMessages();
|
||||
});
|
||||
}
|
||||
|
||||
_renderRichMessages() {
|
||||
const container = this.messagesRef.el;
|
||||
if (!container) return;
|
||||
const richDivs = container.querySelectorAll(".fusion_rich_slot[data-idx]");
|
||||
for (const div of richDivs) {
|
||||
const idx = parseInt(div.dataset.idx);
|
||||
const msg = this.state.messages[idx];
|
||||
if (msg && msg.role === "assistant" && msg.content) {
|
||||
const html = mdToHtml(msg.content);
|
||||
if (div.innerHTML !== html) {
|
||||
div.innerHTML = html;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get sessionId() {
|
||||
return this.state.internalSessionId || this.props.sessionId;
|
||||
}
|
||||
|
||||
async loadLatestSession() {
|
||||
this.state.loading = true;
|
||||
try {
|
||||
const data = await rpc("/fusion_accounting/session/latest", {});
|
||||
if (data.session_id) {
|
||||
this.state.internalSessionId = data.session_id;
|
||||
this.state.messages = data.messages || [];
|
||||
this.state.sessionName = data.name;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to load session:", e);
|
||||
}
|
||||
this.state.loading = false;
|
||||
this.scrollToBottom();
|
||||
}
|
||||
|
||||
async onNewChat() {
|
||||
if (this.sessionId) {
|
||||
try {
|
||||
await rpc("/fusion_accounting/session/close", { session_id: this.sessionId });
|
||||
} catch (e) { /* not critical */ }
|
||||
}
|
||||
const session = await rpc("/fusion_accounting/session/create", {});
|
||||
this.state.internalSessionId = session.session_id;
|
||||
this.state.sessionName = session.name;
|
||||
this.state.messages = [];
|
||||
this.state.pendingApprovals = [];
|
||||
}
|
||||
|
||||
async sendMessage() {
|
||||
const text = this.state.inputText.trim();
|
||||
if (!text || this.state.sending) return;
|
||||
|
||||
if (!this.sessionId) {
|
||||
const session = await rpc("/fusion_accounting/session/create", {});
|
||||
this.state.internalSessionId = session.session_id;
|
||||
this.state.sessionName = session.name;
|
||||
}
|
||||
|
||||
this.state.messages.push({ role: "user", content: text });
|
||||
this.state.inputText = "";
|
||||
this.state.sending = true;
|
||||
this.scrollToBottom();
|
||||
|
||||
try {
|
||||
const result = await rpc("/fusion_accounting/chat", {
|
||||
session_id: this.sessionId,
|
||||
message: text,
|
||||
});
|
||||
if (result.text) {
|
||||
this.state.messages.push({ role: "assistant", content: result.text });
|
||||
}
|
||||
if (result.pending_approvals) {
|
||||
this.state.pendingApprovals = result.pending_approvals;
|
||||
}
|
||||
} catch (e) {
|
||||
this.state.messages.push({
|
||||
role: "assistant",
|
||||
content: `Error: ${e.message || "Something went wrong."}`,
|
||||
});
|
||||
}
|
||||
this.state.sending = false;
|
||||
this.scrollToBottom();
|
||||
}
|
||||
|
||||
onKeyDown(ev) {
|
||||
if (ev.key === "Enter" && !ev.shiftKey) {
|
||||
ev.preventDefault();
|
||||
this.sendMessage();
|
||||
}
|
||||
}
|
||||
|
||||
scrollToBottom() {
|
||||
const el = this.messagesRef.el;
|
||||
if (el) {
|
||||
setTimeout(() => { el.scrollTop = el.scrollHeight; }, 100);
|
||||
}
|
||||
}
|
||||
|
||||
async onApprove(matchHistoryId) {
|
||||
await rpc("/fusion_accounting/approve", { match_history_id: matchHistoryId });
|
||||
this.state.pendingApprovals = this.state.pendingApprovals.filter(a => a.id !== matchHistoryId);
|
||||
this.state.messages.push({ role: "assistant", content: "Action approved and executed." });
|
||||
}
|
||||
|
||||
async onReject(matchHistoryId) {
|
||||
await rpc("/fusion_accounting/reject", { match_history_id: matchHistoryId, reason: "User rejected" });
|
||||
this.state.pendingApprovals = this.state.pendingApprovals.filter(a => a.id !== matchHistoryId);
|
||||
this.state.messages.push({ role: "assistant", content: "Action rejected." });
|
||||
}
|
||||
|
||||
async onApproveAll() {
|
||||
const ids = this.state.pendingApprovals.map(a => a.id);
|
||||
if (!ids.length) return;
|
||||
await rpc("/fusion_accounting/approve_all", { match_history_ids: ids });
|
||||
const count = this.state.pendingApprovals.length;
|
||||
this.state.pendingApprovals = [];
|
||||
this.state.messages.push({ role: "assistant", content: `${count} actions approved and executed.` });
|
||||
}
|
||||
|
||||
async onRejectAll() {
|
||||
const ids = this.state.pendingApprovals.map(a => a.id);
|
||||
if (!ids.length) return;
|
||||
await rpc("/fusion_accounting/reject_all", { match_history_ids: ids, reason: "Batch rejected" });
|
||||
const count = this.state.pendingApprovals.length;
|
||||
this.state.pendingApprovals = [];
|
||||
this.state.messages.push({ role: "assistant", content: `${count} actions rejected.` });
|
||||
}
|
||||
}
|
||||
103
fusion_accounting/static/src/components/chat/chat_panel.xml
Normal file
103
fusion_accounting/static/src/components/chat/chat_panel.xml
Normal file
@@ -0,0 +1,103 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="fusion_accounting.ChatPanel">
|
||||
<div class="fusion_chat_panel card h-100 d-flex flex-column">
|
||||
<div class="card-header d-flex justify-content-between align-items-center py-2">
|
||||
<div>
|
||||
<h5 class="mb-0 d-inline"><i class="fa fa-comments-o me-2"/>Fusion AI</h5>
|
||||
<small class="text-muted ms-2" t-if="state.sessionName" t-esc="state.sessionName"/>
|
||||
</div>
|
||||
<button class="btn btn-outline-secondary btn-sm" t-on-click="onNewChat"
|
||||
title="Start a new conversation">
|
||||
<i class="fa fa-plus me-1"/>New Chat
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
<div class="fusion_chat_messages flex-grow-1 overflow-auto p-3" t-ref="messages">
|
||||
<t t-if="state.loading">
|
||||
<div class="text-center text-muted py-4">
|
||||
<i class="fa fa-spinner fa-spin fa-2x"/>
|
||||
<p class="mt-2">Loading conversation...</p>
|
||||
</div>
|
||||
</t>
|
||||
<t t-elif="state.messages.length === 0">
|
||||
<div class="text-center text-muted py-4">
|
||||
<i class="fa fa-robot fa-3x mb-3 d-block"/>
|
||||
<p>Ask me about your accounting data.<br/>
|
||||
I can help with bank reconciliation, tax analysis, AR/AP, auditing, and more.</p>
|
||||
</div>
|
||||
</t>
|
||||
<t t-foreach="state.messages" t-as="msg" t-key="msg_index">
|
||||
<!-- User message -->
|
||||
<t t-if="msg.role === 'user'">
|
||||
<div class="fusion_chat_msg mb-2 p-2 rounded bg-primary-subtle ms-4">
|
||||
<small class="text-muted d-block mb-1">
|
||||
<i class="fa fa-user me-1"/>You
|
||||
</small>
|
||||
<span style="white-space: pre-wrap;" t-esc="msg.content"/>
|
||||
</div>
|
||||
</t>
|
||||
<!-- AI message — rich HTML rendered via onPatched -->
|
||||
<t t-else="">
|
||||
<div class="fusion_chat_msg fusion_ai_msg mb-3 p-3 rounded me-4">
|
||||
<small class="text-muted d-block mb-2">
|
||||
<i class="fa fa-robot me-1"/>Fusion AI
|
||||
</small>
|
||||
<div class="fusion_rich_content fusion_rich_slot"
|
||||
t-att-data-idx="msg_index"/>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
<t t-if="state.sending">
|
||||
<div class="fusion_ai_msg rounded p-3 me-4 mb-2">
|
||||
<small class="text-muted d-block mb-1">
|
||||
<i class="fa fa-robot me-1"/>Fusion AI
|
||||
</small>
|
||||
<i class="fa fa-spinner fa-spin me-1"/> Thinking...
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- Pending Approvals -->
|
||||
<t t-if="state.pendingApprovals.length > 0">
|
||||
<div class="border-top p-2">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<small class="text-muted">Pending Approvals (<t t-esc="state.pendingApprovals.length"/>):</small>
|
||||
<div class="d-flex gap-1" t-if="state.pendingApprovals.length > 1">
|
||||
<button class="btn btn-success btn-sm" t-on-click="onApproveAll">
|
||||
<i class="fa fa-check-double"/> Approve All
|
||||
</button>
|
||||
<button class="btn btn-outline-danger btn-sm" t-on-click="onRejectAll">
|
||||
Reject All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<t t-foreach="state.pendingApprovals" t-as="approval" t-key="approval.id">
|
||||
<FusionApprovalCard
|
||||
approval="approval"
|
||||
onApprove.bind="onApprove"
|
||||
onReject.bind="onReject"/>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Input -->
|
||||
<div class="fusion_chat_input border-top p-2">
|
||||
<div class="input-group">
|
||||
<textarea
|
||||
t-ref="chatInput"
|
||||
class="form-control form-control-sm"
|
||||
placeholder="Ask Fusion AI..."
|
||||
rows="2"
|
||||
t-model="state.inputText"
|
||||
t-on-keydown="onKeyDown"/>
|
||||
<button class="btn btn-primary btn-sm" t-on-click="sendMessage"
|
||||
t-att-disabled="state.sending">
|
||||
<i class="fa fa-paper-plane"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,98 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component, useState, onWillStart } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { FusionHealthCard } from "./health_card";
|
||||
import { FusionChatPanel } from "../chat/chat_panel";
|
||||
|
||||
export class FusionDashboard extends Component {
|
||||
static template = "fusion_accounting.Dashboard";
|
||||
static components = { FusionHealthCard, FusionChatPanel };
|
||||
static props = ["*"];
|
||||
|
||||
setup() {
|
||||
this.action = useService("action");
|
||||
this.state = useState({
|
||||
data: null,
|
||||
loading: true,
|
||||
chatSessionId: null,
|
||||
});
|
||||
|
||||
onWillStart(async () => {
|
||||
await this.loadDashboard();
|
||||
});
|
||||
}
|
||||
|
||||
async loadDashboard() {
|
||||
this.state.loading = true;
|
||||
try {
|
||||
this.state.data = await rpc("/fusion_accounting/dashboard/data");
|
||||
} catch (e) {
|
||||
console.error("Dashboard load error:", e);
|
||||
this.state.data = null;
|
||||
}
|
||||
this.state.loading = false;
|
||||
}
|
||||
|
||||
async onCardClick(domain) {
|
||||
if (!this.state.chatSessionId) {
|
||||
const session = await rpc("/fusion_accounting/session/create", {
|
||||
context_domain: domain,
|
||||
});
|
||||
this.state.chatSessionId = session.session_id;
|
||||
}
|
||||
}
|
||||
|
||||
get cards() {
|
||||
if (!this.state.data) return [];
|
||||
const d = this.state.data;
|
||||
return [
|
||||
{
|
||||
title: "Bank Reconciliation",
|
||||
metric: `${d.bank_recon.count} unmatched`,
|
||||
subtext: `$${(d.bank_recon.amount || 0).toFixed(2)} total`,
|
||||
domain: "bank_reconciliation",
|
||||
status: d.bank_recon.count === 0 ? "green" : d.bank_recon.count < 10 ? "yellow" : "red",
|
||||
},
|
||||
{
|
||||
title: "AR Outstanding",
|
||||
metric: `$${(d.ar.total || 0).toFixed(2)}`,
|
||||
subtext: `${d.ar.overdue_count} overdue`,
|
||||
domain: "accounts_receivable",
|
||||
status: d.ar.overdue_count === 0 ? "green" : d.ar.overdue_count < 5 ? "yellow" : "red",
|
||||
},
|
||||
{
|
||||
title: "AP Due",
|
||||
metric: `$${(d.ap.total || 0).toFixed(2)}`,
|
||||
subtext: `${d.ap.due_this_week} due this week`,
|
||||
domain: "accounts_payable",
|
||||
status: d.ap.due_this_week === 0 ? "green" : "yellow",
|
||||
},
|
||||
{
|
||||
title: "HST Balance",
|
||||
metric: `$${(d.hst.balance || 0).toFixed(2)}`,
|
||||
subtext: d.hst.balance > 0 ? "Owing to CRA" : "Refund expected",
|
||||
domain: "hst_management",
|
||||
status: "blue",
|
||||
},
|
||||
{
|
||||
title: "Audit Score",
|
||||
metric: `${d.audit.score}/100`,
|
||||
subtext: `${d.audit.flags} flags`,
|
||||
domain: "audit",
|
||||
status: d.audit.score >= 80 ? "green" : d.audit.score >= 60 ? "yellow" : "red",
|
||||
},
|
||||
{
|
||||
title: "Month-End",
|
||||
metric: d.month_end.status,
|
||||
subtext: `${d.month_end.open_items} open items`,
|
||||
domain: "month_end",
|
||||
status: d.month_end.open_items === 0 ? "green" : "yellow",
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("fusion_accounting.dashboard", FusionDashboard);
|
||||
@@ -0,0 +1,55 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="fusion_accounting.Dashboard">
|
||||
<div class="o_action fusion_accounting_dashboard">
|
||||
<div class="fusion_dashboard_header d-flex justify-content-between align-items-center p-3">
|
||||
<h2 class="mb-0">Fusion AI Dashboard</h2>
|
||||
<button class="btn btn-outline-primary btn-sm" t-on-click="loadDashboard">
|
||||
<i class="fa fa-refresh"/> Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<t t-if="state.loading">
|
||||
<div class="text-center p-5">
|
||||
<i class="fa fa-spinner fa-spin fa-2x"/>
|
||||
<p class="mt-2">Loading dashboard...</p>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-else="">
|
||||
<!-- Health Cards -->
|
||||
<div class="fusion_health_cards d-flex flex-wrap gap-3 p-3">
|
||||
<t t-foreach="cards" t-as="card" t-key="card.domain">
|
||||
<FusionHealthCard
|
||||
title="card.title"
|
||||
metric="card.metric"
|
||||
subtext="card.subtext"
|
||||
status="card.status"
|
||||
domain="card.domain"
|
||||
onCardClick.bind="onCardClick"/>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- Action Centre + Chat -->
|
||||
<div class="d-flex gap-3 p-3" style="min-height: 500px;">
|
||||
<!-- Action Centre -->
|
||||
<div class="flex-grow-1">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Needs Attention</h5>
|
||||
</div>
|
||||
<div class="card-body overflow-auto">
|
||||
<p class="text-muted">AI-prioritised items will appear here after the first audit scan.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat Panel (720px = original 400 + 80%) -->
|
||||
<div style="width: 720px; min-width: 600px;">
|
||||
<FusionChatPanel sessionId="state.chatSessionId"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,22 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
|
||||
export class FusionHealthCard extends Component {
|
||||
static template = "fusion_accounting.HealthCard";
|
||||
static props = ["title", "metric", "subtext", "status", "domain", "onCardClick"];
|
||||
|
||||
get statusClass() {
|
||||
const map = {
|
||||
green: "bg-success-subtle border-success",
|
||||
yellow: "bg-warning-subtle border-warning",
|
||||
red: "bg-danger-subtle border-danger",
|
||||
blue: "bg-info-subtle border-info",
|
||||
};
|
||||
return map[this.props.status] || "bg-light";
|
||||
}
|
||||
|
||||
onClick() {
|
||||
this.props.onCardClick(this.props.domain);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="fusion_accounting.HealthCard">
|
||||
<div class="fusion_health_card card border-2 cursor-pointer"
|
||||
t-attf-class="{{statusClass}}"
|
||||
style="min-width: 180px; flex: 1;"
|
||||
t-on-click="onClick">
|
||||
<div class="card-body text-center p-3">
|
||||
<h6 class="card-title text-muted mb-1" t-esc="props.title"/>
|
||||
<h3 class="mb-1" t-esc="props.metric"/>
|
||||
<small class="text-muted" t-esc="props.subtext"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
72
fusion_accounting/static/src/scss/chat.scss
Normal file
72
fusion_accounting/static/src/scss/chat.scss
Normal file
@@ -0,0 +1,72 @@
|
||||
.fusion_chat_panel {
|
||||
.fusion_chat_messages {
|
||||
max-height: 500px;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.fusion_chat_msg {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.fusion_ai_msg {
|
||||
background: var(--o-view-background-color);
|
||||
border: 1px solid var(--o-border-color);
|
||||
}
|
||||
|
||||
.fusion_rich_content {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
|
||||
h3, h4, h5 {
|
||||
font-weight: 600;
|
||||
color: var(--o-main-color-5, inherit);
|
||||
}
|
||||
|
||||
table {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--o-action-color, var(--bs-link-color));
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
a.badge {
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
hr {
|
||||
border-color: var(--o-border-color);
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.fusion_chat_input {
|
||||
textarea {
|
||||
resize: none;
|
||||
}
|
||||
}
|
||||
|
||||
.fusion_approval_card {
|
||||
border-left: 3px solid var(--bs-warning);
|
||||
}
|
||||
}
|
||||
16
fusion_accounting/static/src/scss/dashboard.scss
Normal file
16
fusion_accounting/static/src/scss/dashboard.scss
Normal file
@@ -0,0 +1,16 @@
|
||||
.fusion_accounting_dashboard {
|
||||
.fusion_dashboard_header {
|
||||
border-bottom: 1px solid var(--o-border-color);
|
||||
background: var(--o-view-background-color);
|
||||
}
|
||||
|
||||
.fusion_health_cards {
|
||||
.fusion_health_card {
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(var(--bs-body-color-rgb), 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
64
fusion_accounting/tests/test_api_live.py
Normal file
64
fusion_accounting/tests/test_api_live.py
Normal file
@@ -0,0 +1,64 @@
|
||||
import anthropic
|
||||
import json
|
||||
import sys
|
||||
|
||||
api_key = sys.argv[1]
|
||||
model = sys.argv[2] if len(sys.argv) > 2 else 'claude-sonnet-4-6'
|
||||
print(f'API Key: {api_key[:12]}...{api_key[-4:]}')
|
||||
print(f'Model: {model}')
|
||||
|
||||
client = anthropic.Anthropic(api_key=api_key)
|
||||
|
||||
print()
|
||||
print('--- Test 1: Basic API Call ---')
|
||||
try:
|
||||
r = client.messages.create(model=model, max_tokens=100, messages=[{'role': 'user', 'content': 'Say hello in one sentence.'}])
|
||||
print(f'OK: {r.content[0].text}')
|
||||
print(f'Tokens: {r.usage.input_tokens} in, {r.usage.output_tokens} out')
|
||||
except Exception as e:
|
||||
print(f'FAILED: {e}')
|
||||
sys.exit(1)
|
||||
|
||||
print()
|
||||
print('--- Test 2: Tool Calling ---')
|
||||
try:
|
||||
tools = [{'name': 'get_account_balance', 'description': 'Get balance of an account by code', 'input_schema': {'type': 'object', 'properties': {'account_code': {'type': 'string', 'description': 'Account code'}}, 'required': ['account_code']}}]
|
||||
r = client.messages.create(model=model, max_tokens=300, system='You are an accounting AI. Always use tools to look up data before answering.', messages=[{'role': 'user', 'content': 'Look up the balance on account 2005.'}], tools=tools)
|
||||
print(f'Stop reason: {r.stop_reason}')
|
||||
tool_id = None
|
||||
for b in r.content:
|
||||
if b.type == 'text':
|
||||
print(f'Text: {b.text}')
|
||||
elif b.type == 'tool_use':
|
||||
print(f'TOOL CALL: {b.name}({json.dumps(b.input)}) id={b.id}')
|
||||
tool_id = b.id
|
||||
if r.stop_reason == 'tool_use':
|
||||
print('RESULT: Tool calling WORKING')
|
||||
else:
|
||||
print('RESULT: No tool call (model answered directly)')
|
||||
except Exception as e:
|
||||
print(f'FAILED: {e}')
|
||||
sys.exit(1)
|
||||
|
||||
print()
|
||||
print('--- Test 3: Tool Result Round-Trip ---')
|
||||
try:
|
||||
if not tool_id:
|
||||
tool_id = 'toolu_test123'
|
||||
msgs = [
|
||||
{'role': 'user', 'content': 'Look up account 2005 balance.'},
|
||||
{'role': 'assistant', 'content': [{'type': 'tool_use', 'id': tool_id, 'name': 'get_account_balance', 'input': {'account_code': '2005'}}]},
|
||||
{'role': 'user', 'content': [{'type': 'tool_result', 'tool_use_id': tool_id, 'content': json.dumps({'balance': -15234.56, 'name': 'HST Collected'})}]}
|
||||
]
|
||||
r2 = client.messages.create(model=model, max_tokens=200, system='You are an accounting AI. Report findings in Canadian dollars.', messages=msgs, tools=tools)
|
||||
for b in r2.content:
|
||||
if b.type == 'text':
|
||||
print(f'AI: {b.text}')
|
||||
print(f'Tokens: {r2.usage.input_tokens} in, {r2.usage.output_tokens} out')
|
||||
print('RESULT: Multi-turn tool flow WORKING')
|
||||
except Exception as e:
|
||||
print(f'FAILED: {e}')
|
||||
sys.exit(1)
|
||||
|
||||
print()
|
||||
print('=== ALL TESTS PASSED ===')
|
||||
107
fusion_accounting/tests/test_claude_api.py
Normal file
107
fusion_accounting/tests/test_claude_api.py
Normal file
@@ -0,0 +1,107 @@
|
||||
import anthropic
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
def get_db_param(key):
|
||||
result = subprocess.run(
|
||||
['docker', 'exec', 'odoo-dev-db', 'psql', '-U', 'odoo', '-d', 'westin-v19', '-t', '-A', '-c',
|
||||
f"SELECT value FROM ir_config_parameter WHERE key = '{key}'"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
api_key = get_db_param('fusion_accounting.anthropic_api_key')
|
||||
if not api_key:
|
||||
print('ERROR: No API key found in database')
|
||||
sys.exit(1)
|
||||
print(f'API Key found: {api_key[:12]}...{api_key[-4:]}')
|
||||
|
||||
model = get_db_param('fusion_accounting.claude_model') or 'claude-sonnet-4-6'
|
||||
print(f'Model: {model}')
|
||||
|
||||
client = anthropic.Anthropic(api_key=api_key)
|
||||
|
||||
# Test 1: Basic API call
|
||||
print('\n--- Test 1: Basic API Call ---')
|
||||
try:
|
||||
response = client.messages.create(
|
||||
model=model,
|
||||
max_tokens=100,
|
||||
messages=[{'role': 'user', 'content': 'Say hello in one sentence.'}]
|
||||
)
|
||||
print(f'Status: OK')
|
||||
print(f'Response: {response.content[0].text}')
|
||||
print(f'Tokens: {response.usage.input_tokens} in, {response.usage.output_tokens} out')
|
||||
except Exception as e:
|
||||
print(f'FAILED: {e}')
|
||||
sys.exit(1)
|
||||
|
||||
# Test 2: Tool calling
|
||||
print('\n--- Test 2: Tool Calling ---')
|
||||
try:
|
||||
tools = [{
|
||||
'name': 'get_account_balance',
|
||||
'description': 'Get the balance of an accounting account by code',
|
||||
'input_schema': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'account_code': {'type': 'string', 'description': 'Account code like 1000, 2005'},
|
||||
},
|
||||
'required': ['account_code']
|
||||
}
|
||||
}]
|
||||
response = client.messages.create(
|
||||
model=model,
|
||||
max_tokens=300,
|
||||
system='You are an accounting assistant. Use tools to look up data.',
|
||||
messages=[{'role': 'user', 'content': 'What is the balance on account 2005 (HST Collected)?'}],
|
||||
tools=tools,
|
||||
)
|
||||
print(f'Stop reason: {response.stop_reason}')
|
||||
for block in response.content:
|
||||
if block.type == 'text':
|
||||
print(f'Text: {block.text}')
|
||||
elif block.type == 'tool_use':
|
||||
print(f'Tool call: {block.name}({json.dumps(block.input)})')
|
||||
print(f'Tool ID: {block.id}')
|
||||
print(f'Tokens: {response.usage.input_tokens} in, {response.usage.output_tokens} out')
|
||||
if response.stop_reason == 'tool_use':
|
||||
print('Tool calling: WORKING')
|
||||
else:
|
||||
print('Tool calling: Model responded with text (functional but did not use tool)')
|
||||
except Exception as e:
|
||||
print(f'FAILED: {e}')
|
||||
sys.exit(1)
|
||||
|
||||
# Test 3: Multi-turn with tool result
|
||||
print('\n--- Test 3: Tool Result Round-Trip ---')
|
||||
try:
|
||||
messages = [
|
||||
{'role': 'user', 'content': 'What is the HST balance on account 2005?'},
|
||||
{'role': 'assistant', 'content': [
|
||||
{'type': 'tool_use', 'id': 'test_123', 'name': 'get_account_balance',
|
||||
'input': {'account_code': '2005'}}
|
||||
]},
|
||||
{'role': 'user', 'content': [
|
||||
{'type': 'tool_result', 'tool_use_id': 'test_123',
|
||||
'content': json.dumps({'balance': -15234.56, 'name': 'HST Collected'})}
|
||||
]}
|
||||
]
|
||||
response = client.messages.create(
|
||||
model=model,
|
||||
max_tokens=200,
|
||||
system='You are an accounting assistant. Report findings concisely in Canadian dollars.',
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
)
|
||||
for block in response.content:
|
||||
if block.type == 'text':
|
||||
print(f'AI Response: {block.text}')
|
||||
print(f'Tokens: {response.usage.input_tokens} in, {response.usage.output_tokens} out')
|
||||
print('Multi-turn tool flow: WORKING')
|
||||
except Exception as e:
|
||||
print(f'FAILED: {e}')
|
||||
sys.exit(1)
|
||||
|
||||
print('\n=== ALL TESTS PASSED ===')
|
||||
49
fusion_accounting/views/config_views.xml
Normal file
49
fusion_accounting/views/config_views.xml
Normal file
@@ -0,0 +1,49 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="res_config_settings_view_form_fusion_accounting" model="ir.ui.view">
|
||||
<field name="name">res.config.settings.view.form.fusion.accounting</field>
|
||||
<field name="model">res.config.settings</field>
|
||||
<field name="inherit_id" ref="account.res_config_settings_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//app[@name='account']" position="inside">
|
||||
<block title="Fusion AI" name="fusion_ai_settings">
|
||||
<setting string="AI Provider" help="Select the AI provider for Fusion Accounting.">
|
||||
<field name="fusion_ai_provider" widget="radio"/>
|
||||
</setting>
|
||||
<setting string="Anthropic API Key" help="API key for Anthropic Claude. Leave blank if using Fusion API module.">
|
||||
<field name="fusion_anthropic_api_key" password="True"/>
|
||||
</setting>
|
||||
<setting string="OpenAI API Key" help="API key for OpenAI GPT. Leave blank if using Fusion API module.">
|
||||
<field name="fusion_openai_api_key" password="True"/>
|
||||
</setting>
|
||||
<setting string="Claude Model" help="The Anthropic Claude model to use for conversations.">
|
||||
<field name="fusion_claude_model"/>
|
||||
</setting>
|
||||
<setting string="OpenAI Model" help="The OpenAI model to use for conversations.">
|
||||
<field name="fusion_openai_model"/>
|
||||
</setting>
|
||||
</block>
|
||||
<block title="Fusion AI Behaviour" name="fusion_ai_behaviour">
|
||||
<setting string="Tier 3 Promotion Threshold" help="Accuracy threshold (0.0 - 1.0) for promoting Tier 3 tools to auto-approved.">
|
||||
<field name="fusion_tier3_threshold"/>
|
||||
</setting>
|
||||
<setting string="Tier 3 Minimum Sample Size" help="Minimum decisions before promotion is considered.">
|
||||
<field name="fusion_tier3_min_sample"/>
|
||||
</setting>
|
||||
<setting string="Audit Scan Frequency" help="How often the automated audit scan runs.">
|
||||
<field name="fusion_audit_cron_frequency"/>
|
||||
</setting>
|
||||
<setting string="Match History in Prompt" help="Number of recent match history records to include in AI prompt context.">
|
||||
<field name="fusion_history_in_prompt"/>
|
||||
</setting>
|
||||
<setting string="Max Tool Calls Per Turn" help="Maximum number of tool calls the AI can make in a single conversation turn.">
|
||||
<field name="fusion_max_tool_calls"/>
|
||||
</setting>
|
||||
<setting string="Post-Action Audit Hook" help="Run audit checks automatically after journal entries are posted.">
|
||||
<field name="fusion_enable_post_audit"/>
|
||||
</setting>
|
||||
</block>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
8
fusion_accounting/views/dashboard_views.xml
Normal file
8
fusion_accounting/views/dashboard_views.xml
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Client Action for OWL Dashboard -->
|
||||
<record id="action_fusion_dashboard" model="ir.actions.client">
|
||||
<field name="name">Fusion AI Dashboard</field>
|
||||
<field name="tag">fusion_accounting.dashboard</field>
|
||||
</record>
|
||||
</odoo>
|
||||
97
fusion_accounting/views/match_history_views.xml
Normal file
97
fusion_accounting/views/match_history_views.xml
Normal file
@@ -0,0 +1,97 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_fusion_history_list" model="ir.ui.view">
|
||||
<field name="name">fusion.accounting.match.history.list</field>
|
||||
<field name="model">fusion.accounting.match.history</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Match History">
|
||||
<field name="proposed_at"/>
|
||||
<field name="tool_name"/>
|
||||
<field name="decision" widget="badge"
|
||||
decoration-success="decision == 'approved'"
|
||||
decoration-danger="decision == 'rejected'"
|
||||
decoration-warning="decision == 'pending'"
|
||||
decoration-info="decision == 'auto'"/>
|
||||
<field name="ai_confidence" widget="progressbar"/>
|
||||
<field name="amount"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="decided_by"/>
|
||||
<field name="decided_at"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_fusion_history_form" model="ir.ui.view">
|
||||
<field name="name">fusion.accounting.match.history.form</field>
|
||||
<field name="model">fusion.accounting.match.history</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Match History">
|
||||
<header>
|
||||
<button name="action_approve" string="Approve" type="object"
|
||||
class="btn-primary" invisible="decision != 'pending'"
|
||||
groups="fusion_accounting.group_fusion_accounting_manager"/>
|
||||
<button name="action_reject" string="Reject" type="object"
|
||||
class="btn-danger" invisible="decision != 'pending'"
|
||||
groups="fusion_accounting.group_fusion_accounting_manager"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="tool_name"/>
|
||||
<field name="decision"/>
|
||||
<field name="ai_confidence"/>
|
||||
<field name="amount"/>
|
||||
<field name="partner_id"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="session_id"/>
|
||||
<field name="rule_id"/>
|
||||
<field name="proposed_at"/>
|
||||
<field name="decided_at"/>
|
||||
<field name="decided_by"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="AI Details">
|
||||
<field name="ai_reasoning"/>
|
||||
<field name="tool_params"/>
|
||||
<field name="tool_result"/>
|
||||
</group>
|
||||
<group string="Correction" invisible="decision != 'rejected'">
|
||||
<field name="rejection_reason"/>
|
||||
<field name="correct_action"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_fusion_history_search" model="ir.ui.view">
|
||||
<field name="name">fusion.accounting.match.history.search</field>
|
||||
<field name="model">fusion.accounting.match.history</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="tool_name"/>
|
||||
<field name="partner_id"/>
|
||||
<filter name="pending" string="Pending" domain="[('decision', '=', 'pending')]"/>
|
||||
<filter name="approved" string="Approved" domain="[('decision', '=', 'approved')]"/>
|
||||
<filter name="rejected" string="Rejected" domain="[('decision', '=', 'rejected')]"/>
|
||||
<separator/>
|
||||
<group>
|
||||
<filter name="group_tool" string="Tool" domain="[]" context="{'group_by': 'tool_name'}"/>
|
||||
<filter name="group_decision" string="Decision" domain="[]" context="{'group_by': 'decision'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fusion_history" model="ir.actions.act_window">
|
||||
<field name="name">Match History</field>
|
||||
<field name="res_model">fusion.accounting.match.history</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="view_fusion_history_search"/>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">No match history yet</p>
|
||||
<p>AI tool calls and their outcomes will appear here.</p>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
46
fusion_accounting/views/menus.xml
Normal file
46
fusion_accounting/views/menus.xml
Normal file
@@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Root menu under Accounting (account_accountant uses accountant.menu_accounting) -->
|
||||
<menuitem id="menu_fusion_accounting_root"
|
||||
name="Fusion AI"
|
||||
parent="accountant.menu_accounting"
|
||||
sequence="8"
|
||||
groups="group_fusion_accounting_user"/>
|
||||
|
||||
<!-- Dashboard -->
|
||||
<menuitem id="menu_fusion_dashboard"
|
||||
name="Dashboard"
|
||||
parent="menu_fusion_accounting_root"
|
||||
action="action_fusion_dashboard"
|
||||
sequence="10"/>
|
||||
|
||||
<!-- Sessions -->
|
||||
<menuitem id="menu_fusion_sessions"
|
||||
name="AI Sessions"
|
||||
parent="menu_fusion_accounting_root"
|
||||
action="action_fusion_session"
|
||||
sequence="20"/>
|
||||
|
||||
<!-- Match History -->
|
||||
<menuitem id="menu_fusion_history"
|
||||
name="Match History"
|
||||
parent="menu_fusion_accounting_root"
|
||||
action="action_fusion_history"
|
||||
sequence="30"/>
|
||||
|
||||
<!-- Rules -->
|
||||
<menuitem id="menu_fusion_rules"
|
||||
name="Fusion Rules"
|
||||
parent="menu_fusion_accounting_root"
|
||||
action="action_fusion_rule"
|
||||
sequence="40"
|
||||
groups="group_fusion_accounting_manager"/>
|
||||
|
||||
<!-- Configuration (link to settings) -->
|
||||
<menuitem id="menu_fusion_config"
|
||||
name="Configuration"
|
||||
parent="menu_fusion_accounting_root"
|
||||
action="account.action_account_config"
|
||||
sequence="90"
|
||||
groups="group_fusion_accounting_admin"/>
|
||||
</odoo>
|
||||
113
fusion_accounting/views/rule_views.xml
Normal file
113
fusion_accounting/views/rule_views.xml
Normal file
@@ -0,0 +1,113 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_fusion_rule_list" model="ir.ui.view">
|
||||
<field name="name">fusion.accounting.rule.list</field>
|
||||
<field name="model">fusion.accounting.rule</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Fusion Rules">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="rule_type" widget="badge"/>
|
||||
<field name="approval_tier" widget="badge"
|
||||
decoration-success="approval_tier == 'auto'"
|
||||
decoration-warning="approval_tier == 'needs_approval'"/>
|
||||
<field name="created_by"/>
|
||||
<field name="confidence_score" widget="progressbar"/>
|
||||
<field name="total_uses"/>
|
||||
<field name="active"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_fusion_rule_form" model="ir.ui.view">
|
||||
<field name="name">fusion.accounting.rule.form</field>
|
||||
<field name="model">fusion.accounting.rule</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Fusion Rule">
|
||||
<header>
|
||||
<button name="action_demote" string="Demote to Needs Approval" type="object"
|
||||
class="btn-warning" invisible="approval_tier != 'auto'"
|
||||
groups="fusion_accounting.group_fusion_accounting_admin"/>
|
||||
<button name="action_rollback" string="Rollback to Previous Version" type="object"
|
||||
class="btn-secondary" invisible="not parent_rule_id"
|
||||
groups="fusion_accounting.group_fusion_accounting_admin"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1><field name="name" placeholder="Rule Name"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="rule_type"/>
|
||||
<field name="approval_tier"/>
|
||||
<field name="created_by"/>
|
||||
<field name="version"/>
|
||||
<field name="parent_rule_id"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="confidence_score" widget="progressbar"/>
|
||||
<field name="total_uses"/>
|
||||
<field name="total_approved"/>
|
||||
<field name="total_rejected"/>
|
||||
<field name="promotion_threshold"/>
|
||||
<field name="min_sample_size"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Logic" name="logic">
|
||||
<group>
|
||||
<field name="description"/>
|
||||
<field name="match_logic"/>
|
||||
<field name="trigger_domain"/>
|
||||
<field name="match_code"/>
|
||||
</group>
|
||||
</page>
|
||||
<page string="Accounts" name="accounts">
|
||||
<group>
|
||||
<field name="fee_account_id"/>
|
||||
<field name="write_off_account_id"/>
|
||||
<field name="journal_ids" widget="many2many_tags"/>
|
||||
</group>
|
||||
</page>
|
||||
<page string="Notes" name="notes">
|
||||
<field name="notes"/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
<chatter/>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_fusion_rule_search" model="ir.ui.view">
|
||||
<field name="name">fusion.accounting.rule.search</field>
|
||||
<field name="model">fusion.accounting.rule</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="name"/>
|
||||
<field name="rule_type"/>
|
||||
<filter name="active" string="Active" domain="[('active', '=', True)]"/>
|
||||
<filter name="auto" string="Auto-Approved" domain="[('approval_tier', '=', 'auto')]"/>
|
||||
<filter name="admin_created" string="Admin Created" domain="[('created_by', '=', 'admin')]"/>
|
||||
<filter name="ai_created" string="AI Created" domain="[('created_by', '=', 'ai')]"/>
|
||||
<separator/>
|
||||
<group>
|
||||
<filter name="group_type" string="Type" domain="[]" context="{'group_by': 'rule_type'}"/>
|
||||
<filter name="group_tier" string="Approval Tier" domain="[]" context="{'group_by': 'approval_tier'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fusion_rule" model="ir.actions.act_window">
|
||||
<field name="name">Fusion Rules</field>
|
||||
<field name="res_model">fusion.accounting.rule</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="view_fusion_rule_search"/>
|
||||
<field name="context">{'search_default_active': 1}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">No rules defined yet</p>
|
||||
<p>Create rules to teach the AI your accounting patterns.</p>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
105
fusion_accounting/views/session_views.xml
Normal file
105
fusion_accounting/views/session_views.xml
Normal file
@@ -0,0 +1,105 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Session List View -->
|
||||
<record id="view_fusion_session_list" model="ir.ui.view">
|
||||
<field name="name">fusion.accounting.session.list</field>
|
||||
<field name="model">fusion.accounting.session</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="AI Sessions">
|
||||
<field name="name"/>
|
||||
<field name="user_id"/>
|
||||
<field name="state" widget="badge" decoration-success="state == 'active'" decoration-muted="state == 'closed'"/>
|
||||
<field name="ai_provider"/>
|
||||
<field name="ai_model"/>
|
||||
<field name="tool_call_count"/>
|
||||
<field name="create_date"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Session Form View (with basic chat) -->
|
||||
<record id="view_fusion_session_form" model="ir.ui.view">
|
||||
<field name="name">fusion.accounting.session.form</field>
|
||||
<field name="model">fusion.accounting.session</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="AI Session">
|
||||
<header>
|
||||
<button name="action_close_session" string="Close Session" type="object"
|
||||
class="btn-secondary" invisible="state == 'closed'"/>
|
||||
<field name="state" widget="statusbar" statusbar_visible="active,closed"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="name"/>
|
||||
<field name="user_id"/>
|
||||
<field name="context_domain"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="ai_provider"/>
|
||||
<field name="ai_model"/>
|
||||
<field name="tool_call_count"/>
|
||||
<field name="token_count_in"/>
|
||||
<field name="token_count_out"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Conversation" name="conversation">
|
||||
<field name="message_ids_json" widget="text" readonly="1"/>
|
||||
</page>
|
||||
<page string="Match History" name="history">
|
||||
<field name="match_history_ids">
|
||||
<list>
|
||||
<field name="tool_name"/>
|
||||
<field name="decision" widget="badge"
|
||||
decoration-success="decision == 'approved'"
|
||||
decoration-danger="decision == 'rejected'"
|
||||
decoration-warning="decision == 'pending'"
|
||||
decoration-info="decision == 'auto'"/>
|
||||
<field name="ai_confidence" widget="progressbar"/>
|
||||
<field name="amount"/>
|
||||
<field name="proposed_at"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
<chatter/>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Session Search View -->
|
||||
<record id="view_fusion_session_search" model="ir.ui.view">
|
||||
<field name="name">fusion.accounting.session.search</field>
|
||||
<field name="model">fusion.accounting.session</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="name"/>
|
||||
<field name="user_id"/>
|
||||
<filter name="active" string="Active" domain="[('state', '=', 'active')]"/>
|
||||
<filter name="closed" string="Closed" domain="[('state', '=', 'closed')]"/>
|
||||
<separator/>
|
||||
<group>
|
||||
<filter name="group_user" string="User" domain="[]" context="{'group_by': 'user_id'}"/>
|
||||
<filter name="group_state" string="Status" domain="[]" context="{'group_by': 'state'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Session Action -->
|
||||
<record id="action_fusion_session" model="ir.actions.act_window">
|
||||
<field name="name">AI Sessions</field>
|
||||
<field name="res_model">fusion.accounting.session</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="view_fusion_session_search"/>
|
||||
<field name="context">{'search_default_active': 1}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
No AI sessions yet
|
||||
</p>
|
||||
<p>Start a conversation with Fusion AI from the dashboard.</p>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
1
fusion_accounting/wizards/__init__.py
Normal file
1
fusion_accounting/wizards/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import rule_wizard
|
||||
42
fusion_accounting/wizards/rule_wizard.py
Normal file
42
fusion_accounting/wizards/rule_wizard.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from odoo import models, fields, api
|
||||
|
||||
|
||||
class FusionRuleWizard(models.TransientModel):
|
||||
_name = 'fusion.accounting.rule.wizard'
|
||||
_description = 'Create Fusion Rule from AI Suggestion'
|
||||
|
||||
name = fields.Char(string='Rule Name', required=True)
|
||||
rule_type = fields.Selection(
|
||||
selection=[
|
||||
('match', 'Match'), ('classify', 'Classify'),
|
||||
('audit', 'Audit'), ('fee', 'Fee'),
|
||||
('routing', 'Routing'), ('followup', 'Follow-Up'),
|
||||
],
|
||||
string='Type', required=True, default='match',
|
||||
)
|
||||
description = fields.Text(string='Description')
|
||||
match_logic = fields.Text(string='Match Logic')
|
||||
fee_account_id = fields.Many2one('account.account', string='Fee Account')
|
||||
write_off_account_id = fields.Many2one('account.account', string='Write-Off Account')
|
||||
journal_ids = fields.Many2many('account.journal', string='Journals')
|
||||
|
||||
def action_create_rule(self):
|
||||
self.ensure_one()
|
||||
rule = self.env['fusion.accounting.rule'].create({
|
||||
'name': self.name,
|
||||
'rule_type': self.rule_type,
|
||||
'description': self.description,
|
||||
'match_logic': self.match_logic,
|
||||
'fee_account_id': self.fee_account_id.id,
|
||||
'write_off_account_id': self.write_off_account_id.id,
|
||||
'journal_ids': [(6, 0, self.journal_ids.ids)],
|
||||
'created_by': 'admin',
|
||||
'approval_tier': 'needs_approval',
|
||||
})
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'fusion.accounting.rule',
|
||||
'res_id': rule.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
35
fusion_accounting/wizards/rule_wizard.xml
Normal file
35
fusion_accounting/wizards/rule_wizard.xml
Normal file
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_fusion_rule_wizard_form" model="ir.ui.view">
|
||||
<field name="name">fusion.accounting.rule.wizard.form</field>
|
||||
<field name="model">fusion.accounting.rule.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Create Fusion Rule">
|
||||
<group>
|
||||
<field name="name"/>
|
||||
<field name="rule_type"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="description"/>
|
||||
<field name="match_logic"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="fee_account_id"/>
|
||||
<field name="write_off_account_id"/>
|
||||
<field name="journal_ids" widget="many2many_tags"/>
|
||||
</group>
|
||||
<footer>
|
||||
<button name="action_create_rule" string="Create Rule" type="object" class="btn-primary"/>
|
||||
<button string="Cancel" class="btn-secondary" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fusion_rule_wizard" model="ir.actions.act_window">
|
||||
<field name="name">Create Fusion Rule</field>
|
||||
<field name="res_model">fusion.accounting.rule.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user