changes
This commit is contained in:
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