refactor(fusion_accounting): move AI module code into fusion_accounting_ai sub-module
git mv preserves history. fusion_accounting/ retains only __manifest__.py, __init__.py, CLAUDE.md, and docs/ — the meta-module shell. All Python, data, views, security, services, static, tests, wizards, report move to fusion_accounting_ai/. Manifest data list updated; security.xml move to _core deferred to Task 12. Made-with: Cursor
This commit is contained in:
@@ -1 +0,0 @@
|
||||
from . import chat_controller
|
||||
@@ -1,243 +0,0 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionAccountingChatController(http.Controller):
|
||||
|
||||
def _check_session_ownership(self, session):
|
||||
"""S1-S3: Verify the current user owns the session."""
|
||||
if session.user_id.id != request.env.user.id:
|
||||
# Allow managers to access any session
|
||||
if not request.env.user.has_group('fusion_accounting.group_fusion_accounting_manager'):
|
||||
return {'error': 'Access denied: you do not own this session'}
|
||||
return None
|
||||
|
||||
@http.route('/fusion_accounting/session/create', type='jsonrpc', auth='user')
|
||||
def create_session(self, context_domain=None, **kwargs):
|
||||
session = request.env['fusion.accounting.session'].create({
|
||||
'user_id': request.env.user.id,
|
||||
'company_id': request.env.company.id,
|
||||
'context_domain': context_domain,
|
||||
})
|
||||
return {'session_id': session.id, 'name': session.name}
|
||||
|
||||
@http.route('/fusion_accounting/session/close', type='jsonrpc', auth='user')
|
||||
def close_session(self, session_id, **kwargs):
|
||||
session = request.env['fusion.accounting.session'].browse(int(session_id))
|
||||
if not session.exists():
|
||||
return {'status': 'closed'}
|
||||
# S2: Ownership check
|
||||
error = self._check_session_ownership(session)
|
||||
if error:
|
||||
return error
|
||||
if session.state == 'active':
|
||||
session.action_close_session()
|
||||
return {'status': 'closed'}
|
||||
|
||||
@http.route('/fusion_accounting/chat', type='jsonrpc', auth='user')
|
||||
def chat(self, session_id, message, context=None, image=None, **kwargs):
|
||||
if not message and not image:
|
||||
return {'error': 'Message or image is required'}
|
||||
# S3: Ownership check
|
||||
session = request.env['fusion.accounting.session'].browse(int(session_id))
|
||||
if session.exists():
|
||||
error = self._check_session_ownership(session)
|
||||
if error:
|
||||
return error
|
||||
agent = request.env['fusion.accounting.agent']
|
||||
result = agent.chat(int(session_id), message or '', context=context, image=image)
|
||||
return result
|
||||
|
||||
@http.route('/fusion_accounting/approve', type='jsonrpc', auth='user')
|
||||
def approve_action(self, match_history_id, **kwargs):
|
||||
if not request.env.user.has_group('fusion_accounting.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):
|
||||
# E2: Wrap in try/except so dashboard doesn't return 500
|
||||
try:
|
||||
dashboard = request.env['fusion.accounting.dashboard'].new({
|
||||
'company_id': request.env.company.id,
|
||||
})
|
||||
return {
|
||||
'bank_recon': {'count': dashboard.bank_recon_count, 'amount': dashboard.bank_recon_amount},
|
||||
'ar': {'total': dashboard.ar_total, 'overdue_count': dashboard.ar_overdue_count},
|
||||
'ap': {'total': dashboard.ap_total, 'due_this_week': dashboard.ap_due_this_week},
|
||||
'hst': {'balance': dashboard.hst_balance},
|
||||
'audit': {'score': dashboard.audit_score, 'flags': dashboard.audit_flag_count},
|
||||
'month_end': {'status': dashboard.month_end_status, 'open_items': dashboard.month_end_open_items},
|
||||
# E1: Include needs_attention and recent_activity
|
||||
'needs_attention': json.loads(dashboard.needs_attention_json or '[]'),
|
||||
'recent_activity': json.loads(dashboard.recent_activity_json or '[]'),
|
||||
}
|
||||
except Exception as e:
|
||||
_logger.exception("Dashboard data computation failed")
|
||||
return {
|
||||
'error': 'Dashboard data could not be computed',
|
||||
'bank_recon': {'count': 0, 'amount': 0},
|
||||
'ar': {'total': 0, 'overdue_count': 0},
|
||||
'ap': {'total': 0, 'due_this_week': 0},
|
||||
'hst': {'balance': 0},
|
||||
'audit': {'score': 0, 'flags': 0},
|
||||
'month_end': {'status': 'Unknown', 'open_items': 0},
|
||||
'needs_attention': [],
|
||||
'recent_activity': [],
|
||||
}
|
||||
|
||||
@http.route('/fusion_accounting/approve_all', type='jsonrpc', auth='user')
|
||||
def approve_all(self, match_history_ids, **kwargs):
|
||||
if not request.env.user.has_group('fusion_accounting.group_fusion_accounting_manager'):
|
||||
return {'error': 'Insufficient permissions to approve actions'}
|
||||
agent = request.env['fusion.accounting.agent']
|
||||
results = []
|
||||
for mid in match_history_ids:
|
||||
try:
|
||||
result = agent.approve_action(int(mid))
|
||||
results.append({'id': mid, 'status': 'approved', 'result': result})
|
||||
except Exception as e:
|
||||
# S4: Sanitize exception — log full error, return generic message
|
||||
_logger.exception("Error approving match history %s", mid)
|
||||
results.append({'id': mid, 'status': 'error', 'error': 'Action could not be approved. Check server logs for details.'})
|
||||
return {'results': results}
|
||||
|
||||
@http.route('/fusion_accounting/reject_all', type='jsonrpc', auth='user')
|
||||
def reject_all(self, match_history_ids, reason='', **kwargs):
|
||||
if not request.env.user.has_group('fusion_accounting.group_fusion_accounting_manager'):
|
||||
return {'error': 'Insufficient permissions to reject actions'}
|
||||
agent = request.env['fusion.accounting.agent']
|
||||
results = []
|
||||
for mid in match_history_ids:
|
||||
try:
|
||||
result = agent.reject_action(int(mid), reason)
|
||||
# E3: Consistent return shape with approve_all
|
||||
results.append({'id': mid, 'status': 'rejected', 'result': result})
|
||||
except Exception as e:
|
||||
# S4: Sanitize exception
|
||||
_logger.exception("Error rejecting match history %s", mid)
|
||||
results.append({'id': mid, 'status': 'error', 'error': 'Action could not be rejected. Check server logs for details.'})
|
||||
return {'results': results}
|
||||
|
||||
@http.route('/fusion_accounting/chat/status', type='jsonrpc', auth='user')
|
||||
def chat_status(self, session_id, **kwargs):
|
||||
"""Poll the live execution state of a running chat — returns thinking text,
|
||||
tool calls in progress, and current status. Called every 500ms by the frontend
|
||||
while a chat request is in flight."""
|
||||
from ..services.agent import get_execution_state
|
||||
state = get_execution_state(int(session_id))
|
||||
return state
|
||||
|
||||
@http.route('/fusion_accounting/search_matches', type='jsonrpc', auth='user')
|
||||
def search_matches(self, statement_line_id, query='', **kwargs):
|
||||
"""Live search for matching journal items — called directly by the
|
||||
reconciliation table search bar (no AI round-trip)."""
|
||||
from ..services.tools.bank_reconciliation import search_matching_entries
|
||||
try:
|
||||
result = search_matching_entries(request.env, {
|
||||
'statement_line_id': int(statement_line_id),
|
||||
'query': query,
|
||||
})
|
||||
return result
|
||||
except Exception as e:
|
||||
_logger.exception("Search matches failed")
|
||||
return {'candidates': [], 'error': str(e)}
|
||||
|
||||
@http.route('/fusion_accounting/session/list', type='jsonrpc', auth='user')
|
||||
def session_list(self, limit=20, **kwargs):
|
||||
"""List recent sessions for the session picker dropdown."""
|
||||
sessions = request.env['fusion.accounting.session'].search([
|
||||
('user_id', '=', request.env.user.id),
|
||||
], order='write_date desc', limit=int(limit))
|
||||
return {
|
||||
'sessions': [{
|
||||
'id': s.id,
|
||||
'name': s.name,
|
||||
'state': s.state,
|
||||
'date': s.write_date.isoformat() if s.write_date else '',
|
||||
'message_count': len(json.loads(s.message_ids_json or '[]')),
|
||||
'ai_model': s.ai_model or '',
|
||||
} for s in sessions],
|
||||
}
|
||||
|
||||
@http.route('/fusion_accounting/session/latest', type='jsonrpc', auth='user')
|
||||
def session_latest(self, **kwargs):
|
||||
# Find the most recent active session that has messages first,
|
||||
# fall back to any active session (including empty ones)
|
||||
sessions = request.env['fusion.accounting.session'].search([
|
||||
('user_id', '=', request.env.user.id),
|
||||
('state', '=', 'active'),
|
||||
], order='write_date desc', limit=10)
|
||||
if not sessions:
|
||||
return {'session_id': None, 'messages': [], 'name': None}
|
||||
|
||||
# Prefer a session with actual messages
|
||||
session = None
|
||||
for s in sessions:
|
||||
msg_json = s.message_ids_json or '[]'
|
||||
if msg_json != '[]' and len(msg_json) > 5:
|
||||
session = s
|
||||
break
|
||||
# If no session has messages, use the newest one
|
||||
if not session:
|
||||
session = sessions[0]
|
||||
|
||||
# Clean up empty stale sessions (created but never used)
|
||||
for s in sessions:
|
||||
if s.id != session.id and (s.message_ids_json or '[]') == '[]':
|
||||
s.write({'state': 'closed'})
|
||||
|
||||
messages = json.loads(session.message_ids_json or '[]')
|
||||
display_messages = []
|
||||
for msg in messages:
|
||||
if isinstance(msg.get('content'), str) and msg['content'].strip():
|
||||
display_messages.append(msg)
|
||||
elif isinstance(msg.get('content'), list):
|
||||
for block in msg['content']:
|
||||
if isinstance(block, dict) and block.get('type') == 'text' and block['text'].strip():
|
||||
display_messages.append({'role': msg['role'], 'content': block['text']})
|
||||
|
||||
# Include any pending approvals so they show on page load
|
||||
agent = request.env['fusion.accounting.agent']
|
||||
pending = request.env['fusion.accounting.match.history'].search([
|
||||
('session_id', '=', session.id),
|
||||
('decision', '=', 'pending'),
|
||||
])
|
||||
pending_approvals = [agent._format_pending_approval(p) for p in pending]
|
||||
|
||||
return {
|
||||
'session_id': session.id,
|
||||
'messages': display_messages,
|
||||
'name': session.name,
|
||||
'pending_approvals': pending_approvals,
|
||||
}
|
||||
|
||||
@http.route('/fusion_accounting/session/history', type='jsonrpc', auth='user')
|
||||
def session_history(self, session_id, **kwargs):
|
||||
session = request.env['fusion.accounting.session'].browse(int(session_id))
|
||||
if not session.exists():
|
||||
return {'error': 'Session not found'}
|
||||
# S1: Ownership check
|
||||
error = self._check_session_ownership(session)
|
||||
if error:
|
||||
return error
|
||||
return {
|
||||
'messages': json.loads(session.message_ids_json or '[]'),
|
||||
'session_id': session.id,
|
||||
'state': session.state,
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
<!-- Session name sequence -->
|
||||
<record id="seq_fusion_accounting_session" model="ir.sequence">
|
||||
<field name="name">Fusion AI Session</field>
|
||||
<field name="code">fusion.accounting.session</field>
|
||||
<field name="prefix">FAS/%(year)s/</field>
|
||||
<field name="padding">5</field>
|
||||
</record>
|
||||
|
||||
<!-- Daily audit scan: expire stale pending approvals -->
|
||||
<record id="cron_fusion_audit_scan" model="ir.cron">
|
||||
<field name="name">Fusion AI: Periodic Audit Scan</field>
|
||||
<field name="model_id" ref="model_fusion_accounting_match_history"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">
|
||||
cutoff = datetime.datetime.now() - datetime.timedelta(days=30)
|
||||
stale = model.search([('decision', '=', 'pending'), ('proposed_at', '<', cutoff.strftime('%Y-%m-%d %H:%M:%S'))])
|
||||
stale.write({'decision': 'rejected', 'rejection_reason': 'Auto-expired after 30 days'})
|
||||
</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active">True</field>
|
||||
</record>
|
||||
|
||||
<!-- Weekly tier promotion check -->
|
||||
<record id="cron_fusion_tier_promotion" model="ir.cron">
|
||||
<field name="name">Fusion AI: Tier Promotion Check</field>
|
||||
<field name="model_id" ref="model_fusion_accounting_rule"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">
|
||||
for rule in model.search([('active', '=', True), ('approval_tier', '=', 'needs_approval')]):
|
||||
rule._check_promotion()
|
||||
</field>
|
||||
<field name="interval_number">7</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active">True</field>
|
||||
</record>
|
||||
|
||||
<!-- Weekly recurring pattern rebuild -->
|
||||
<record id="cron_fusion_recurring_patterns" model="ir.cron">
|
||||
<field name="name">Fusion AI: Rebuild Recurring Patterns</field>
|
||||
<field name="model_id" ref="model_fusion_recurring_pattern"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._rebuild_all_patterns(min_occurrences=3)</field>
|
||||
<field name="interval_number">7</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active">True</field>
|
||||
</record>
|
||||
|
||||
<!-- Daily auto-reconcile inter-account transfers (CC payments) -->
|
||||
<record id="cron_fusion_transfer_reconcile" model="ir.cron">
|
||||
<field name="name">Fusion AI: Auto-Reconcile Inter-Account Transfers</field>
|
||||
<field name="model_id" ref="model_fusion_accounting_agent"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_reconcile_transfers()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active">True</field>
|
||||
</record>
|
||||
|
||||
<!-- Daily auto-reconcile payroll cheques against open liability entries -->
|
||||
<record id="cron_fusion_payroll_cheque_reconcile" model="ir.cron">
|
||||
<field name="name">Fusion AI: Reconcile Payroll Cheques</field>
|
||||
<field name="model_id" ref="model_fusion_accounting_agent"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._reconcile_payroll_cheques()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active">True</field>
|
||||
</record>
|
||||
|
||||
<!-- Weekly vendor tax profile rebuild -->
|
||||
<record id="cron_fusion_vendor_profiles" model="ir.cron">
|
||||
<field name="name">Fusion AI: Rebuild Vendor Tax Profiles</field>
|
||||
<field name="model_id" ref="model_fusion_vendor_tax_profile"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._rebuild_all_profiles(min_bills=3)</field>
|
||||
<field name="interval_number">7</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active">True</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -1,22 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,837 +0,0 @@
|
||||
<?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. IMPORTANT: You MUST pass journal_ids to filter to specific journals (e.g., the card/POS journal). Without journal_ids, returns totals across ALL company journals which will be misleadingly large. Use this to verify card batch deposit amounts against the card payment journal for the prior business day.</field>
|
||||
<field name="domain">bank_reconciliation</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}, "journal_ids": {"type": "array", "items": {"type": "integer"}}}, "required": ["date_from", "date_to"]}</field>
|
||||
</record>
|
||||
|
||||
<!-- Domain 2: HST/GST Management -->
|
||||
<record id="tool_calculate_hst_balance" model="fusion.accounting.tool">
|
||||
<field name="name">calculate_hst_balance</field>
|
||||
<field name="display_name_field">Calculate HST Balance</field>
|
||||
<field name="description">Calculate net HST position (collected minus ITCs) for a period.</field>
|
||||
<field name="domain">hst_management</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
|
||||
</record>
|
||||
<record id="tool_get_tax_report" model="fusion.accounting.tool">
|
||||
<field name="name">get_tax_report</field>
|
||||
<field name="display_name_field">Get Tax Report</field>
|
||||
<field name="description">Generate a tax report for a period using Odoo's reporting engine.</field>
|
||||
<field name="domain">hst_management</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}, "report_ref": {"type": "string", "default": "account.generic_tax_report"}}}</field>
|
||||
</record>
|
||||
<record id="tool_find_missing_tax_invoices" model="fusion.accounting.tool">
|
||||
<field name="name">find_missing_tax_invoices</field>
|
||||
<field name="display_name_field">Find Missing Tax Invoices</field>
|
||||
<field name="description">Find customer invoices with taxable products but no tax applied.</field>
|
||||
<field name="domain">hst_management</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
|
||||
</record>
|
||||
<record id="tool_find_missing_itc_bills" model="fusion.accounting.tool">
|
||||
<field name="name">find_missing_itc_bills</field>
|
||||
<field name="display_name_field">Find Missing ITC Bills</field>
|
||||
<field name="description">Find vendor bills without input tax credits.</field>
|
||||
<field name="domain">hst_management</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
|
||||
</record>
|
||||
<record id="tool_get_tax_return_status" model="fusion.accounting.tool">
|
||||
<field name="name">get_tax_return_status</field>
|
||||
<field name="display_name_field">Get Tax Return Status</field>
|
||||
<field name="description">Check the status of periodic tax returns.</field>
|
||||
<field name="domain">hst_management</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
|
||||
</record>
|
||||
<record id="tool_generate_tax_return" model="fusion.accounting.tool">
|
||||
<field name="name">generate_tax_return</field>
|
||||
<field name="display_name_field">Generate Tax Return</field>
|
||||
<field name="description">Refresh all tax return data.</field>
|
||||
<field name="domain">hst_management</field>
|
||||
<field name="tier">2</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
|
||||
<field name="required_groups">fusion_accounting.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">[Tier 1: Read-only] Get a partner's AR and AP balance with open items. Shows: how much they owe us (receivable), how much we owe them (payable), and net balance. Use for "how much do we owe Pride Mobility?", "what's the balance for ADP?".</field>
|
||||
<field name="domain">accounts_receivable</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"partner_id": {"type": "integer", "description": "Partner ID (optional if partner_name provided)"}, "partner_name": {"type": "string", "description": "Partner name to search for (e.g. 'Pride Mobility')"}}}</field>
|
||||
</record>
|
||||
<record id="tool_send_followup" model="fusion.accounting.tool">
|
||||
<field name="name">send_followup</field>
|
||||
<field name="display_name_field">Send Follow-Up</field>
|
||||
<field name="description">Draft and send a follow-up email to a partner about overdue invoices.</field>
|
||||
<field name="domain">accounts_receivable</field>
|
||||
<field name="tier">2</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"partner_id": {"type": "integer"}, "send_email": {"type": "boolean"}, "print_letter": {"type": "boolean"}, "email_subject": {"type": "string"}, "body": {"type": "string"}}, "required": ["partner_id"]}</field>
|
||||
<field name="required_groups">fusion_accounting.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>
|
||||
|
||||
<record id="tool_register_adp_batch_payment" model="fusion.accounting.tool">
|
||||
<field name="name">register_adp_batch_payment</field>
|
||||
<field name="display_name_field">Register ADP Batch Payment</field>
|
||||
<field name="description">[Tier 3: Requires user approval] Register payments for a batch of ADP invoices from a remittance advice. Takes a list of invoice numbers with payment amounts and a payment date. Registers each payment via Odoo's payment wizard, creating outstanding receipt entries (PBNK2) on account 1050. After this, use suggest_bank_line_matches on the bank deposit to match the outstanding receipts. Use this when the user uploads an ADP remittance advice screenshot and says "mark these paid".</field>
|
||||
<field name="domain">adp</field>
|
||||
<field name="tier">3</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"invoices": {"type": "array", "items": {"type": "object", "properties": {"invoice_number": {"type": "string"}, "amount": {"type": "number"}}, "required": ["invoice_number", "amount"]}, "description": "List of invoices with number and payment amount"}, "payment_date": {"type": "string", "description": "Payment date from remittance (YYYY-MM-DD)"}, "journal_id": {"type": "integer", "description": "Bank journal ID (default 50 = Scotia Current)"}}, "required": ["invoices", "payment_date"]}</field>
|
||||
<field name="required_groups">fusion_accounting.group_fusion_accounting_manager</field>
|
||||
</record>
|
||||
|
||||
<!-- Domain 10: Reporting -->
|
||||
<record id="tool_get_profit_loss" model="fusion.accounting.tool">
|
||||
<field name="name">get_profit_loss</field>
|
||||
<field name="display_name_field">Get Profit & Loss</field>
|
||||
<field name="description">Generate P&L report for a period.</field>
|
||||
<field name="domain">reporting</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
|
||||
</record>
|
||||
<record id="tool_get_balance_sheet" model="fusion.accounting.tool">
|
||||
<field name="name">get_balance_sheet</field>
|
||||
<field name="display_name_field">Get Balance Sheet</field>
|
||||
<field name="description">Generate balance sheet report.</field>
|
||||
<field name="domain">reporting</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
|
||||
</record>
|
||||
<record id="tool_get_trial_balance" model="fusion.accounting.tool">
|
||||
<field name="name">get_trial_balance</field>
|
||||
<field name="display_name_field">Get Trial Balance</field>
|
||||
<field name="description">Generate trial balance report.</field>
|
||||
<field name="domain">reporting</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
|
||||
</record>
|
||||
<record id="tool_get_cash_flow" model="fusion.accounting.tool">
|
||||
<field name="name">get_cash_flow</field>
|
||||
<field name="display_name_field">Get Cash Flow</field>
|
||||
<field name="description">Generate cash flow statement.</field>
|
||||
<field name="domain">reporting</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
|
||||
</record>
|
||||
<record id="tool_compare_periods" model="fusion.accounting.tool">
|
||||
<field name="name">compare_periods</field>
|
||||
<field name="display_name_field">Compare Periods</field>
|
||||
<field name="description">Two period reports side by side for comparison.</field>
|
||||
<field name="domain">reporting</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"report_ref": {"type": "string"}, "period1_from": {"type": "string"}, "period1_to": {"type": "string"}, "period2_from": {"type": "string"}, "period2_to": {"type": "string"}}, "required": ["period1_from", "period1_to", "period2_from", "period2_to"]}</field>
|
||||
</record>
|
||||
<record id="tool_answer_financial_question" model="fusion.accounting.tool">
|
||||
<field name="name">answer_financial_question</field>
|
||||
<field name="display_name_field">Answer Financial Question</field>
|
||||
<field name="description">Natural language to report query for financial questions.</field>
|
||||
<field name="domain">reporting</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"question": {"type": "string"}}, "required": ["question"]}</field>
|
||||
</record>
|
||||
<record id="tool_export_report" model="fusion.accounting.tool">
|
||||
<field name="name">export_report</field>
|
||||
<field name="display_name_field">Export Report</field>
|
||||
<field name="description">Export a report to PDF or XLSX.</field>
|
||||
<field name="domain">reporting</field>
|
||||
<field name="tier">2</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"report_ref": {"type": "string"}, "format": {"type": "string", "enum": ["pdf", "xlsx"]}, "date_from": {"type": "string"}, "date_to": {"type": "string"}}, "required": ["report_ref"]}</field>
|
||||
<field name="required_groups">fusion_accounting.group_fusion_accounting_manager</field>
|
||||
</record>
|
||||
|
||||
<record id="tool_get_invoicing_summary" model="fusion.accounting.tool">
|
||||
<field name="name">get_invoicing_summary</field>
|
||||
<field name="display_name_field">Get Invoicing Summary</field>
|
||||
<field name="description">[Tier 1: Read-only] Get customer invoicing summary — monthly breakdown for a year, date range totals, or filtered by partner. Use this for questions like "how much did we invoice this year?", "show me invoicing by month", "how much did we bill ADP this quarter?".</field>
|
||||
<field name="domain">reporting</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"year": {"type": "integer", "description": "Year for monthly breakdown (default: current year)"}, "partner_name": {"type": "string", "description": "Filter by partner name (optional)"}, "date_from": {"type": "string", "description": "Start date for date range (YYYY-MM-DD)"}, "date_to": {"type": "string", "description": "End date for date range (YYYY-MM-DD)"}}}</field>
|
||||
</record>
|
||||
<record id="tool_get_billing_summary" model="fusion.accounting.tool">
|
||||
<field name="name">get_billing_summary</field>
|
||||
<field name="display_name_field">Get Billing Summary</field>
|
||||
<field name="description">[Tier 1: Read-only] Get vendor billing (purchases) summary — monthly breakdown for a year or date range. Use for "how much are our bills this month?", "show me vendor bills by month".</field>
|
||||
<field name="domain">reporting</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"year": {"type": "integer"}, "partner_name": {"type": "string"}, "date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
|
||||
</record>
|
||||
<record id="tool_get_collections_summary" model="fusion.accounting.tool">
|
||||
<field name="name">get_collections_summary</field>
|
||||
<field name="display_name_field">Get Collections Summary</field>
|
||||
<field name="description">[Tier 1: Read-only] Get payment collections summary — how much was collected (customer payments received) in a period, broken down by partner. Use for "how much are we collecting this month?", "show me collections for March".</field>
|
||||
<field name="domain">reporting</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
|
||||
</record>
|
||||
|
||||
<!-- Domain 11: Audit -->
|
||||
<record id="tool_audit_posted_entry" model="fusion.accounting.tool">
|
||||
<field name="name">audit_posted_entry</field>
|
||||
<field name="display_name_field">Audit Posted Entry</field>
|
||||
<field name="description">Run all entry-level checks on a single journal entry.</field>
|
||||
<field name="domain">audit</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"move_id": {"type": "integer"}}, "required": ["move_id"]}</field>
|
||||
</record>
|
||||
<record id="tool_audit_account_balances" model="fusion.accounting.tool">
|
||||
<field name="name">audit_account_balances</field>
|
||||
<field name="display_name_field">Audit Account Balances</field>
|
||||
<field name="description">Run all account-level checks (wrong direction, stale items).</field>
|
||||
<field name="domain">audit</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
|
||||
</record>
|
||||
<record id="tool_audit_tax_compliance" model="fusion.accounting.tool">
|
||||
<field name="name">audit_tax_compliance</field>
|
||||
<field name="display_name_field">Audit Tax Compliance</field>
|
||||
<field name="description">All tax checks (missing tax, wrong rate, exempt verification).</field>
|
||||
<field name="domain">audit</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
|
||||
</record>
|
||||
<record id="tool_audit_reconciliation_integrity" model="fusion.accounting.tool">
|
||||
<field name="name">audit_reconciliation_integrity</field>
|
||||
<field name="display_name_field">Audit Reconciliation Integrity</field>
|
||||
<field name="description">Verify partial/full reconcile consistency.</field>
|
||||
<field name="domain">audit</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
|
||||
</record>
|
||||
<record id="tool_check_hash_chain" model="fusion.accounting.tool">
|
||||
<field name="name">check_hash_chain</field>
|
||||
<field name="display_name_field">Check Hash Chain</field>
|
||||
<field name="description">Verify journal entry hash chain integrity.</field>
|
||||
<field name="domain">audit</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
|
||||
</record>
|
||||
<record id="tool_check_sequence_gaps" model="fusion.accounting.tool">
|
||||
<field name="name">check_sequence_gaps</field>
|
||||
<field name="display_name_field">Check Sequence Gaps</field>
|
||||
<field name="description">Check for sequence gaps in journals.</field>
|
||||
<field name="domain">audit</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
|
||||
</record>
|
||||
<record id="tool_flag_entry" model="fusion.accounting.tool">
|
||||
<field name="name">flag_entry</field>
|
||||
<field name="display_name_field">Flag Entry</field>
|
||||
<field name="description">Create a chatter note on a journal entry with flag and recommendation.</field>
|
||||
<field name="domain">audit</field>
|
||||
<field name="tier">2</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"move_id": {"type": "integer"}, "flag": {"type": "string"}, "recommendation": {"type": "string"}}, "required": ["move_id"]}</field>
|
||||
<field name="required_groups">fusion_accounting.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>
|
||||
|
||||
<!-- HST Filing Workflow Tools (added 2026-04-03) -->
|
||||
|
||||
<record id="tool_search_partners" model="fusion.accounting.tool">
|
||||
<field name="name">search_partners</field>
|
||||
<field name="display_name_field">Search Partners</field>
|
||||
<field name="description">Search for vendors/contacts by name keyword. Use this to resolve bank line descriptions (e.g., "AMAZON") to the correct Odoo partner record before creating bills. Pass supplier_only=true to filter to vendors only.</field>
|
||||
<field name="domain">accounts_payable</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"keyword": {"type": "string", "description": "Name keyword to search (min 2 chars)"}, "supplier_only": {"type": "boolean", "description": "Only return suppliers/vendors"}, "limit": {"type": "integer"}}, "required": ["keyword"]}</field>
|
||||
</record>
|
||||
|
||||
<record id="tool_find_similar_bank_lines" model="fusion.accounting.tool">
|
||||
<field name="name">find_similar_bank_lines</field>
|
||||
<field name="display_name_field">Find Similar Bank Lines</field>
|
||||
<field name="description">Search past RECONCILED bank lines with similar payment_ref descriptions. Returns the expense account, tax treatment, and partner used for each historical match. Use this to check how similar expenses were coded in the past before proposing a new bill.</field>
|
||||
<field name="domain">accounts_payable</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"keyword": {"type": "string", "description": "Keyword from payment_ref to search (min 3 chars)"}, "limit": {"type": "integer"}}, "required": ["keyword"]}</field>
|
||||
</record>
|
||||
|
||||
<record id="tool_get_bank_line_details" model="fusion.accounting.tool">
|
||||
<field name="name">get_bank_line_details</field>
|
||||
<field name="display_name_field">Get Bank Line Details</field>
|
||||
<field name="description">Get full details of a single unreconciled bank statement line. Also searches for existing vendor bills matching the amount and date, and suggests a partner based on the payment description. Use this to check if a bill already exists before creating a new one.</field>
|
||||
<field name="domain">bank_reconciliation</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"line_id": {"type": "integer", "description": "Bank statement line ID"}}, "required": ["line_id"]}</field>
|
||||
</record>
|
||||
|
||||
<record id="tool_create_vendor_bill" model="fusion.accounting.tool">
|
||||
<field name="name">create_vendor_bill</field>
|
||||
<field name="display_name_field">Create Vendor Bill</field>
|
||||
<field name="description">[Tier 3: Requires user approval] Create a vendor bill (account.move in_invoice) with expense lines and tax. Use after confirming the expense details with the user. Pass post=true to auto-post the bill after creation.</field>
|
||||
<field name="domain">accounts_payable</field>
|
||||
<field name="tier">3</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"partner_id": {"type": "integer", "description": "Vendor partner ID"}, "invoice_date": {"type": "string", "description": "Bill date (YYYY-MM-DD)"}, "lines": {"type": "array", "items": {"type": "object", "properties": {"description": {"type": "string"}, "account_id": {"type": "integer"}, "price_unit": {"type": "number"}, "quantity": {"type": "number"}, "tax_ids": {"type": "array", "items": {"type": "integer"}}}}, "description": "Invoice line items"}, "post": {"type": "boolean", "description": "Auto-post the bill after creation"}}, "required": ["partner_id", "invoice_date", "lines"]}</field>
|
||||
</record>
|
||||
|
||||
<record id="tool_register_bill_payment" model="fusion.accounting.tool">
|
||||
<field name="name">register_bill_payment</field>
|
||||
<field name="display_name_field">Register Bill Payment</field>
|
||||
<field name="description">[Tier 3: Requires user approval] Register a payment on a posted vendor bill from a specific bank journal. Optionally reconcile the payment to a bank statement line. Use after create_vendor_bill to complete the full bill+payment+reconciliation flow.</field>
|
||||
<field name="domain">accounts_payable</field>
|
||||
<field name="tier">3</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"bill_id": {"type": "integer", "description": "Posted bill ID (account.move)"}, "journal_id": {"type": "integer", "description": "Bank journal ID for payment"}, "payment_date": {"type": "string", "description": "Payment date (YYYY-MM-DD)"}, "amount": {"type": "number", "description": "Payment amount (defaults to bill total)"}, "statement_line_id": {"type": "integer", "description": "Bank statement line ID to reconcile with"}}, "required": ["bill_id", "journal_id"]}</field>
|
||||
</record>
|
||||
|
||||
<record id="tool_check_recurring_pattern" model="fusion.accounting.tool">
|
||||
<field name="name">check_recurring_pattern</field>
|
||||
<field name="display_name_field">Check Recurring Pattern</field>
|
||||
<field name="description">Check if a bank line matches a known recurring payment pattern. Returns the historical account coding, HST treatment, partner, and reconciliation model if one exists. ALWAYS call this FIRST for every unreconciled bank line — if a recurring pattern exists, follow its instructions instead of asking the user. Pass line_id to auto-extract ref and amount.</field>
|
||||
<field name="domain">bank_reconciliation</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"line_id": {"type": "integer", "description": "Bank statement line ID"}, "payment_ref": {"type": "string", "description": "Payment reference text (auto-extracted if line_id provided)"}, "amount": {"type": "number", "description": "Transaction amount (auto-extracted if line_id provided)"}}, "required": []}</field>
|
||||
</record>
|
||||
|
||||
<record id="tool_match_internal_transfers" model="fusion.accounting.tool">
|
||||
<field name="name">match_internal_transfers</field>
|
||||
<field name="display_name_field">Match Internal Transfers</field>
|
||||
<field name="description">[Tier 3: Requires user approval] Find and match inter-account transfers between two bank journals (e.g., Scotia Current ↔ Scotia Visa). Matches EXACT amounts within 2 days. ONLY matches when there is exactly one candidate — skips ambiguous cases. First call with execute=false to preview pairs, then execute=true to reconcile. Scotia Current=50, Scotia Visa=51, RBC Chequing=53, RBC Visa=28.</field>
|
||||
<field name="domain">bank_reconciliation</field>
|
||||
<field name="tier">3</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"journal_a_id": {"type": "integer", "description": "First bank journal ID"}, "journal_b_id": {"type": "integer", "description": "Second bank journal ID"}, "date_from": {"type": "string"}, "date_to": {"type": "string"}, "max_days_apart": {"type": "integer", "description": "Max days between matching lines (default 2)"}, "execute": {"type": "boolean", "description": "false=preview pairs only, true=actually reconcile"}}, "required": ["journal_a_id", "journal_b_id"]}</field>
|
||||
</record>
|
||||
|
||||
<record id="tool_suggest_bank_line_matches" model="fusion.accounting.tool">
|
||||
<field name="name">suggest_bank_line_matches</field>
|
||||
<field name="display_name_field">Suggest Bank Line Matches</field>
|
||||
<field name="description">[Tier 1: Read-only] Find candidate invoices/bills that could match a bank statement line. Extracts partner from the bank line reference, searches open receivables (for incoming payments) or payables (for outgoing payments), scores candidates by amount/partner/date proximity, and finds the best combination of entries that sum to the bank amount. Returns data for a reconciliation-mode fusion-table with editable amounts and search. The user reviews matches, adjusts amounts for partial payments, searches and adds more entries, then clicks Apply Match.</field>
|
||||
<field name="domain">bank_reconciliation</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"statement_line_id": {"type": "integer", "description": "Bank statement line ID to find matches for"}}, "required": ["statement_line_id"]}</field>
|
||||
</record>
|
||||
|
||||
<record id="tool_find_unreconciled_cheques" model="fusion.accounting.tool">
|
||||
<field name="name">find_unreconciled_cheques</field>
|
||||
<field name="display_name_field">Find Unreconciled Cheques</field>
|
||||
<field name="description">[Tier 1: Read-only] Find unreconciled cheque bank lines and classify them as payroll or non-payroll. Payroll cheques have a matching credit amount on 2201 Payroll Liabilities. Non-payroll cheques (vendor payments, rent, etc.) don't. Default journal: Scotia Current (50).</field>
|
||||
<field name="domain">bank_reconciliation</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"journal_id": {"type": "integer", "description": "Bank journal ID (default 50 = Scotia Current)"}, "limit": {"type": "integer", "description": "Max results (default 50)"}}}</field>
|
||||
</record>
|
||||
|
||||
<record id="tool_reconcile_payroll_cheques" model="fusion.accounting.tool">
|
||||
<field name="name">reconcile_payroll_cheques</field>
|
||||
<field name="display_name_field">Reconcile Payroll Cheques</field>
|
||||
<field name="description">[Tier 3: Requires user approval] Reconcile payroll cheque bank lines by applying the Payroll Cheque Clearing model. ONLY processes cheques whose amount matches an existing payroll liability entry on 2201. Non-payroll cheques (vendor/rent) are skipped automatically. Uses the pre-configured "Payroll Cheque Clearing" reconcile model (writeoff to Dr 2201).</field>
|
||||
<field name="domain">bank_reconciliation</field>
|
||||
<field name="tier">3</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"journal_id": {"type": "integer", "description": "Bank journal ID (default 50)"}, "line_ids": {"type": "array", "items": {"type": "integer"}, "description": "Optional: specific bank line IDs to reconcile. If empty, reconciles all matching payroll cheques."}}}</field>
|
||||
<field name="required_groups">fusion_accounting.group_fusion_accounting_manager</field>
|
||||
</record>
|
||||
|
||||
<record id="tool_create_expense_entry" model="fusion.accounting.tool">
|
||||
<field name="name">create_expense_entry</field>
|
||||
<field name="display_name_field">Create Direct GL Expense</field>
|
||||
<field name="description">[Tier 3: Requires user approval] Create a direct GL expense entry in the Miscellaneous Operations journal. Alternative to creating a vendor bill — posts immediately. If has_hst=true, automatically splits the amount into net expense + 13% HST ITC on the 2006 account. Use this for small expenses where a formal vendor bill is not needed.</field>
|
||||
<field name="domain">hst_management</field>
|
||||
<field name="tier">3</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"date": {"type": "string", "description": "Entry date (YYYY-MM-DD)"}, "description": {"type": "string", "description": "Expense description"}, "expense_account_id": {"type": "integer", "description": "GL expense account ID"}, "amount": {"type": "number", "description": "Total amount including HST if applicable"}, "has_hst": {"type": "boolean", "description": "Whether HST (13%) is included in the amount"}, "bank_journal_id": {"type": "integer", "description": "Bank journal for the credit side"}}, "required": ["date", "description", "expense_account_id", "amount"]}</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -1,9 +0,0 @@
|
||||
from . import accounting_config
|
||||
from . import accounting_tool
|
||||
from . import accounting_session
|
||||
from . import accounting_match_history
|
||||
from . import accounting_rule
|
||||
from . import accounting_dashboard
|
||||
from . import account_move_hook
|
||||
from . import vendor_tax_profile
|
||||
from . import recurring_pattern
|
||||
@@ -1,58 +0,0 @@
|
||||
import logging
|
||||
|
||||
from odoo import models, api
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AccountMoveAuditHook(models.Model):
|
||||
_inherit = 'account.move'
|
||||
|
||||
def action_post(self):
|
||||
res = super().action_post()
|
||||
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
if ICP.get_param('fusion_accounting.enable_post_audit', 'False') != 'True':
|
||||
return res
|
||||
|
||||
for move in self:
|
||||
try:
|
||||
self._fusion_audit_posted_entry(move)
|
||||
except Exception as e:
|
||||
_logger.warning("Fusion post-audit hook failed for %s: %s", move.name, e)
|
||||
|
||||
return res
|
||||
|
||||
def _fusion_audit_posted_entry(self, move):
|
||||
issues = []
|
||||
|
||||
total_debit = sum(l.debit for l in move.line_ids)
|
||||
total_credit = sum(l.credit for l in move.line_ids)
|
||||
if abs(total_debit - total_credit) > 0.01:
|
||||
issues.append(f'Unbalanced: debit={total_debit:.2f}, credit={total_credit:.2f}')
|
||||
|
||||
for line in move.line_ids:
|
||||
if not line.account_id:
|
||||
issues.append(f'Line missing account: {line.name}')
|
||||
# M6: Only flag missing tax when the product has taxes configured
|
||||
# (avoids false positives for HST-exempt healthcare services)
|
||||
if (line.product_id and not line.tax_ids
|
||||
and move.move_type in ('out_invoice', 'out_refund', 'in_invoice', 'in_refund')):
|
||||
# Check if the product has default taxes configured
|
||||
product_taxes = line.product_id.taxes_id if move.move_type in ('out_invoice', 'out_refund') else line.product_id.supplier_taxes_id
|
||||
if product_taxes:
|
||||
issues.append(f'Missing tax on product line: {line.product_id.name} (product has taxes configured but line has none)')
|
||||
|
||||
if not move.line_ids:
|
||||
issues.append('Entry has no lines')
|
||||
|
||||
if issues:
|
||||
body_parts = ['<strong>Fusion AI Auto-Audit</strong><ul>']
|
||||
for issue in issues:
|
||||
body_parts.append(f'<li>{issue}</li>')
|
||||
body_parts.append('</ul>')
|
||||
move.message_post(
|
||||
body=''.join(body_parts),
|
||||
message_type='comment',
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
@@ -1,84 +0,0 @@
|
||||
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',
|
||||
)
|
||||
@@ -1,334 +0,0 @@
|
||||
import json
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo import models, fields, api
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionAccountingDashboard(models.TransientModel):
|
||||
_name = 'fusion.accounting.dashboard'
|
||||
_description = 'Fusion Accounting Dashboard'
|
||||
|
||||
company_id = fields.Many2one(
|
||||
'res.company', string='Company',
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
bank_recon_count = fields.Integer(compute='_compute_bank_recon')
|
||||
bank_recon_amount = fields.Monetary(
|
||||
compute='_compute_bank_recon', currency_field='currency_id',
|
||||
)
|
||||
ar_total = fields.Monetary(
|
||||
compute='_compute_ar', currency_field='currency_id',
|
||||
)
|
||||
ar_overdue_count = fields.Integer(compute='_compute_ar')
|
||||
ap_total = fields.Monetary(
|
||||
compute='_compute_ap', currency_field='currency_id',
|
||||
)
|
||||
ap_due_this_week = fields.Integer(compute='_compute_ap')
|
||||
hst_balance = fields.Monetary(
|
||||
compute='_compute_hst', currency_field='currency_id',
|
||||
)
|
||||
audit_score = fields.Integer(compute='_compute_audit')
|
||||
audit_flag_count = fields.Integer(compute='_compute_audit')
|
||||
month_end_status = fields.Char(compute='_compute_month_end')
|
||||
month_end_open_items = fields.Integer(compute='_compute_month_end')
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency', string='Currency',
|
||||
default=lambda self: self.env.company.currency_id,
|
||||
)
|
||||
needs_attention_json = fields.Text(compute='_compute_action_centre')
|
||||
recent_activity_json = fields.Text(compute='_compute_action_centre')
|
||||
|
||||
@api.depends('company_id')
|
||||
def _compute_bank_recon(self):
|
||||
for rec in self:
|
||||
data = self.env['account.bank.statement.line'].read_group(
|
||||
[('is_reconciled', '=', False), ('company_id', '=', rec.company_id.id)],
|
||||
['amount:sum'], [],
|
||||
)
|
||||
row = data[0] if data else {}
|
||||
rec.bank_recon_count = row.get('__count', 0)
|
||||
rec.bank_recon_amount = abs(row.get('amount', 0) or 0)
|
||||
|
||||
@api.depends('company_id')
|
||||
def _compute_ar(self):
|
||||
for rec in self:
|
||||
data = self.env['account.move.line'].read_group(
|
||||
[
|
||||
('account_id.account_type', '=', 'asset_receivable'),
|
||||
('parent_state', '=', 'posted'),
|
||||
('reconciled', '=', False),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
],
|
||||
['amount_residual:sum'], [],
|
||||
)
|
||||
row = data[0] if data else {}
|
||||
rec.ar_total = row.get('amount_residual', 0) or 0
|
||||
|
||||
rec.ar_overdue_count = self.env['account.move.line'].search_count([
|
||||
('account_id.account_type', '=', 'asset_receivable'),
|
||||
('parent_state', '=', 'posted'),
|
||||
('reconciled', '=', False),
|
||||
('date_maturity', '<', fields.Date.today()),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
])
|
||||
|
||||
@api.depends('company_id')
|
||||
def _compute_ap(self):
|
||||
for rec in self:
|
||||
data = self.env['account.move.line'].read_group(
|
||||
[
|
||||
('account_id.account_type', '=', 'liability_payable'),
|
||||
('parent_state', '=', 'posted'),
|
||||
('reconciled', '=', False),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
],
|
||||
['amount_residual:sum'], [],
|
||||
)
|
||||
row = data[0] if data else {}
|
||||
rec.ap_total = abs(row.get('amount_residual', 0) or 0)
|
||||
|
||||
week_end = fields.Date.today() + timedelta(days=7)
|
||||
rec.ap_due_this_week = self.env['account.move.line'].search_count([
|
||||
('account_id.account_type', '=', 'liability_payable'),
|
||||
('parent_state', '=', 'posted'),
|
||||
('reconciled', '=', False),
|
||||
('date_maturity', '<=', week_end),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
])
|
||||
|
||||
@api.depends('company_id')
|
||||
def _compute_hst(self):
|
||||
for rec in self:
|
||||
collected_data = self.env['account.move.line'].read_group(
|
||||
[
|
||||
('account_id.code', '=like', '2005%'),
|
||||
('parent_state', '=', 'posted'),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
],
|
||||
['balance:sum'], [],
|
||||
)
|
||||
itc_data = self.env['account.move.line'].read_group(
|
||||
[
|
||||
('account_id.code', '=like', '2006%'),
|
||||
('parent_state', '=', 'posted'),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
],
|
||||
['balance:sum'], [],
|
||||
)
|
||||
collected = abs((collected_data[0] if collected_data else {}).get('balance', 0) or 0)
|
||||
itcs = abs((itc_data[0] if itc_data else {}).get('balance', 0) or 0)
|
||||
rec.hst_balance = collected - itcs
|
||||
|
||||
@api.depends('company_id')
|
||||
def _compute_audit(self):
|
||||
for rec in self:
|
||||
issues = 0
|
||||
|
||||
# Wrong-direction balances via read_group
|
||||
balance_data = self.env['account.move.line'].read_group(
|
||||
[('parent_state', '=', 'posted'), ('company_id', '=', rec.company_id.id)],
|
||||
['balance:sum'], ['account_id'],
|
||||
)
|
||||
acct_cache = {}
|
||||
acct_ids = [row['account_id'][0] for row in balance_data if row.get('account_id')]
|
||||
if acct_ids:
|
||||
for acct in self.env['account.account'].browse(acct_ids):
|
||||
acct_cache[acct.id] = acct.account_type
|
||||
for row in balance_data:
|
||||
if not row.get('account_id'):
|
||||
continue
|
||||
acct_type = acct_cache.get(row['account_id'][0], '')
|
||||
balance = row.get('balance', 0) or 0
|
||||
if acct_type in ('asset_receivable', 'asset_cash', 'asset_current',
|
||||
'asset_non_current', 'asset_fixed', 'expense',
|
||||
'expense_depreciation', 'expense_direct_cost'):
|
||||
if balance < -0.01:
|
||||
issues += 1
|
||||
elif acct_type in ('liability_payable', 'liability_current',
|
||||
'liability_non_current', 'equity', 'income',
|
||||
'income_other'):
|
||||
if balance > 0.01:
|
||||
issues += 1
|
||||
|
||||
# M4: Guard against made_sequence_gap field not existing
|
||||
try:
|
||||
gaps = self.env['account.move'].search_count([
|
||||
('state', '=', 'posted'),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
('made_sequence_gap', '=', True),
|
||||
])
|
||||
except (ValueError, KeyError):
|
||||
gaps = 0
|
||||
issues += gaps
|
||||
|
||||
pending_approvals = self.env['fusion.accounting.match.history'].search_count([
|
||||
('decision', '=', 'pending'),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
])
|
||||
|
||||
rec.audit_score = max(0, min(100, 100 - issues * 3))
|
||||
rec.audit_flag_count = issues + pending_approvals
|
||||
|
||||
@api.depends('company_id')
|
||||
def _compute_month_end(self):
|
||||
for rec in self:
|
||||
open_items = 0
|
||||
open_items += self.env['account.bank.statement.line'].search_count([
|
||||
('is_reconciled', '=', False),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
])
|
||||
open_items += self.env['account.move'].search_count([
|
||||
('state', '=', 'draft'),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
])
|
||||
|
||||
suspense_data = self.env['account.move.line'].read_group(
|
||||
[
|
||||
('account_id.code', '=like', '999%'),
|
||||
('parent_state', '=', 'posted'),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
],
|
||||
['balance:sum'], ['account_id'],
|
||||
)
|
||||
for row in suspense_data:
|
||||
if abs(row.get('balance', 0) or 0) > 0.01:
|
||||
open_items += 1
|
||||
|
||||
rec.month_end_open_items = open_items
|
||||
if open_items == 0:
|
||||
rec.month_end_status = 'Ready to Close'
|
||||
elif open_items < 5:
|
||||
rec.month_end_status = 'Almost Ready'
|
||||
else:
|
||||
rec.month_end_status = 'Open'
|
||||
|
||||
@api.depends('company_id')
|
||||
def _compute_action_centre(self):
|
||||
for rec in self:
|
||||
attention = []
|
||||
today = fields.Date.today()
|
||||
|
||||
# Pending AI approvals (highest priority)
|
||||
pending = self.env['fusion.accounting.match.history'].search_count([
|
||||
('decision', '=', 'pending'),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
])
|
||||
if pending > 0:
|
||||
attention.append({
|
||||
'priority': 0, 'severity': 'danger',
|
||||
'title': f'{pending} AI actions awaiting your approval',
|
||||
'domain': 'audit',
|
||||
'action': 'Review and approve or reject pending actions',
|
||||
'prompt': 'Show me all pending approval actions',
|
||||
})
|
||||
|
||||
# Unreconciled bank lines
|
||||
unrecon = self.env['account.bank.statement.line'].search_count([
|
||||
('is_reconciled', '=', False),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
])
|
||||
if unrecon > 0:
|
||||
attention.append({
|
||||
'priority': 1, 'severity': 'warning',
|
||||
'title': f'{unrecon} unreconciled bank lines',
|
||||
'domain': 'bank_reconciliation',
|
||||
'action': 'Review and reconcile bank statement lines',
|
||||
'prompt': 'Show me unreconciled bank lines across all journals with a breakdown by journal',
|
||||
})
|
||||
|
||||
# Overdue customer invoices
|
||||
overdue = self.env['account.move'].search_count([
|
||||
('move_type', '=', 'out_invoice'),
|
||||
('state', '=', 'posted'),
|
||||
('payment_state', 'in', ('not_paid', 'partial')),
|
||||
('invoice_date_due', '<', today),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
])
|
||||
if overdue > 0:
|
||||
attention.append({
|
||||
'priority': 2, 'severity': 'warning',
|
||||
'title': f'{overdue} overdue customer invoices',
|
||||
'domain': 'accounts_receivable',
|
||||
'action': 'Send follow-up reminders',
|
||||
'prompt': 'Show me overdue invoices sorted by amount',
|
||||
})
|
||||
|
||||
# Unpaid vendor bills due this week
|
||||
week_end = today + timedelta(days=7)
|
||||
due_bills = self.env['account.move'].search_count([
|
||||
('move_type', '=', 'in_invoice'),
|
||||
('state', '=', 'posted'),
|
||||
('payment_state', 'in', ('not_paid', 'partial')),
|
||||
('invoice_date_due', '<=', week_end),
|
||||
('invoice_date_due', '>=', today),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
])
|
||||
if due_bills > 0:
|
||||
attention.append({
|
||||
'priority': 3, 'severity': 'info',
|
||||
'title': f'{due_bills} vendor bills due this week',
|
||||
'domain': 'accounts_payable',
|
||||
'action': 'Review upcoming payments',
|
||||
'prompt': f'Show me vendor bills due between {today} and {week_end}',
|
||||
})
|
||||
|
||||
# Stale draft entries
|
||||
drafts = self.env['account.move'].search_count([
|
||||
('state', '=', 'draft'),
|
||||
('date', '<=', today - timedelta(days=30)),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
])
|
||||
if drafts > 0:
|
||||
attention.append({
|
||||
'priority': 4, 'severity': 'muted',
|
||||
'title': f'{drafts} stale draft entries (30+ days)',
|
||||
'domain': 'journal_review',
|
||||
'action': 'Post or delete stale draft entries',
|
||||
'prompt': 'Find all stale draft entries older than 30 days',
|
||||
})
|
||||
|
||||
# Unmatched customer payments (on outstanding receipts accounts)
|
||||
try:
|
||||
outstanding_accts = self.env['account.account'].search([
|
||||
('name', 'ilike', 'outstanding receipt'),
|
||||
('company_ids', 'in', rec.company_id.id),
|
||||
])
|
||||
if outstanding_accts:
|
||||
unmatched_payments = self.env['account.move.line'].search_count([
|
||||
('account_id', 'in', outstanding_accts.ids),
|
||||
('parent_state', '=', 'posted'),
|
||||
('reconciled', '=', False),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
])
|
||||
if unmatched_payments > 0:
|
||||
attention.append({
|
||||
'priority': 5, 'severity': 'info',
|
||||
'title': f'{unmatched_payments} unmatched customer payments',
|
||||
'domain': 'accounts_receivable',
|
||||
'action': 'Match payments to invoices',
|
||||
'prompt': 'Show me unmatched customer payments that need to be applied to invoices',
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
attention.sort(key=lambda x: x['priority'])
|
||||
rec.needs_attention_json = json.dumps(attention)
|
||||
|
||||
recent = self.env['fusion.accounting.match.history'].search([
|
||||
('company_id', '=', rec.company_id.id),
|
||||
], limit=10, order='proposed_at desc')
|
||||
rec.recent_activity_json = json.dumps([{
|
||||
'tool': r.tool_name,
|
||||
'decision': r.decision,
|
||||
'date': r.proposed_at.isoformat() if r.proposed_at else '',
|
||||
'amount': r.amount,
|
||||
} for r in recent])
|
||||
|
||||
def action_refresh(self):
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fusion_accounting.dashboard',
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
import json
|
||||
import logging
|
||||
from odoo import models, fields, api
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
TOOL_LABELS = {
|
||||
'get_unreconciled_bank_lines': 'Get Unreconciled Bank Lines',
|
||||
'get_unreconciled_receipts': 'Get Unreconciled Receipts',
|
||||
'match_bank_line_to_payments': 'Match Bank Line to Payments',
|
||||
'auto_reconcile_bank_lines': 'Auto-Reconcile Bank Lines',
|
||||
'apply_reconcile_model': 'Apply Reconcile Model',
|
||||
'unmatch_bank_line': 'Unmatch Bank Line',
|
||||
'get_reconcile_suggestions': 'Get Reconcile Suggestions',
|
||||
'sum_payments_by_date': 'Sum Payments by Date',
|
||||
'get_bank_line_details': 'Get Bank Line Details',
|
||||
'check_recurring_pattern': 'Check Recurring Pattern',
|
||||
'match_internal_transfers': 'Match Internal Transfers',
|
||||
'find_unreconciled_cheques': 'Find Unreconciled Cheques',
|
||||
'reconcile_payroll_cheques': 'Reconcile Payroll Cheques',
|
||||
'suggest_bank_line_matches': 'Suggest Bank Line Matches',
|
||||
'search_matching_entries': 'Search Matching Entries',
|
||||
'calculate_hst_balance': 'Calculate HST Balance',
|
||||
'create_expense_entry': 'Create Expense Entry',
|
||||
'find_missing_itc_bills': 'Find Missing ITC Bills',
|
||||
'find_missing_tax_invoices': 'Find Missing Tax Invoices',
|
||||
'get_tax_report': 'Get Tax Report',
|
||||
'get_ar_aging': 'Get AR Aging',
|
||||
'get_overdue_invoices': 'Get Overdue Invoices',
|
||||
'get_partner_balance': 'Get Partner Balance',
|
||||
'get_ap_aging': 'Get AP Aging',
|
||||
'get_unpaid_bills': 'Get Unpaid Bills',
|
||||
'find_duplicate_bills': 'Find Duplicate Bills',
|
||||
'create_vendor_bill': 'Create Vendor Bill',
|
||||
'register_bill_payment': 'Register Bill Payment',
|
||||
'get_profit_loss': 'Get Profit & Loss',
|
||||
'get_balance_sheet': 'Get Balance Sheet',
|
||||
'get_trial_balance': 'Get Trial Balance',
|
||||
'get_cash_flow': 'Get Cash Flow',
|
||||
'compare_periods': 'Compare Periods',
|
||||
'get_invoicing_summary': 'Get Invoicing Summary',
|
||||
'get_billing_summary': 'Get Billing Summary',
|
||||
'get_collections_summary': 'Get Collections Summary',
|
||||
'create_payroll_journal_entry': 'Create Payroll Journal Entry',
|
||||
'find_adp_without_payment': 'Find ADP Without Payment',
|
||||
'get_adp_receivable_aging': 'Get ADP Receivable Aging',
|
||||
'register_adp_batch_payment': 'Register ADP Batch Payment',
|
||||
'get_close_checklist': 'Get Month-End Checklist',
|
||||
'find_draft_entries': 'Find Draft Entries',
|
||||
'find_wrong_direction_balances': 'Find Wrong Direction Balances',
|
||||
'find_duplicate_entries': 'Find Duplicate Entries',
|
||||
'get_payroll_entries': 'Get Payroll Entries',
|
||||
'get_cra_remittance_status': 'Get CRA Remittance Status',
|
||||
}
|
||||
|
||||
|
||||
class FusionAccountingMatchHistory(models.Model):
|
||||
_name = 'fusion.accounting.match.history'
|
||||
_description = 'Fusion Accounting Match History'
|
||||
_order = 'proposed_at desc'
|
||||
_rec_name = 'display_label'
|
||||
|
||||
display_label = fields.Char(
|
||||
string='Label', compute='_compute_display_label', store=True,
|
||||
)
|
||||
session_id = fields.Many2one(
|
||||
'fusion.accounting.session', string='Session',
|
||||
index=True, ondelete='cascade',
|
||||
)
|
||||
tool_name = fields.Char(string='Tool Name', required=True, index=True)
|
||||
tool_display_name = fields.Char(
|
||||
string='Tool', compute='_compute_tool_display_name', store=True,
|
||||
)
|
||||
tool_params_pretty = fields.Text(
|
||||
string='Parameters', compute='_compute_pretty_json',
|
||||
)
|
||||
tool_result_pretty = fields.Text(
|
||||
string='Result', compute='_compute_pretty_json',
|
||||
)
|
||||
tool_params = fields.Text(string='Tool Parameters (JSON)')
|
||||
tool_result = fields.Text(string='Tool Result (JSON)')
|
||||
ai_reasoning = fields.Text(string='AI Reasoning')
|
||||
ai_confidence = fields.Float(string='AI Confidence', digits=(3, 2))
|
||||
rule_id = fields.Many2one(
|
||||
'fusion.accounting.rule', string='Applied Rule',
|
||||
ondelete='set null',
|
||||
)
|
||||
proposed_at = fields.Datetime(
|
||||
string='Proposed At',
|
||||
default=fields.Datetime.now,
|
||||
required=True,
|
||||
)
|
||||
decision = fields.Selection(
|
||||
selection=[
|
||||
('approved', 'Approved'),
|
||||
('rejected', 'Rejected'),
|
||||
('pending', 'Pending'),
|
||||
('auto', 'Auto-Executed'),
|
||||
],
|
||||
string='Decision',
|
||||
default='pending',
|
||||
index=True,
|
||||
)
|
||||
decided_at = fields.Datetime(string='Decided At')
|
||||
decided_by = fields.Many2one('res.users', string='Decided By')
|
||||
rejection_reason = fields.Text(string='Rejection Reason')
|
||||
correct_action = fields.Text(string='Correct Action (JSON)')
|
||||
bank_statement_line_id = fields.Many2one(
|
||||
'account.bank.statement.line', string='Bank Statement Line',
|
||||
ondelete='set null',
|
||||
)
|
||||
move_line_ids = fields.Many2many(
|
||||
'account.move.line', string='Journal Items',
|
||||
)
|
||||
amount = fields.Monetary(string='Amount', currency_field='currency_id')
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency', string='Currency',
|
||||
default=lambda self: self.env.company.currency_id,
|
||||
)
|
||||
partner_id = fields.Many2one('res.partner', string='Partner')
|
||||
company_id = fields.Many2one(
|
||||
'res.company', string='Company',
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
|
||||
@api.depends('tool_name')
|
||||
def _compute_tool_display_name(self):
|
||||
for rec in self:
|
||||
rec.tool_display_name = TOOL_LABELS.get(rec.tool_name, (rec.tool_name or '').replace('_', ' ').title())
|
||||
|
||||
@api.depends('tool_params', 'tool_result')
|
||||
def _compute_pretty_json(self):
|
||||
for rec in self:
|
||||
for src, dst in [('tool_params', 'tool_params_pretty'), ('tool_result', 'tool_result_pretty')]:
|
||||
raw = getattr(rec, src) or '{}'
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
setattr(rec, dst, json.dumps(parsed, indent=2, default=str, ensure_ascii=False))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
setattr(rec, dst, raw)
|
||||
|
||||
@api.depends('tool_name', 'proposed_at', 'decision')
|
||||
def _compute_display_label(self):
|
||||
for rec in self:
|
||||
label = TOOL_LABELS.get(rec.tool_name, (rec.tool_name or '').replace('_', ' ').title())
|
||||
date_str = rec.proposed_at.strftime('%b %d %H:%M') if rec.proposed_at else ''
|
||||
decision_str = dict(rec._fields['decision'].selection).get(rec.decision, '')
|
||||
rec.display_label = f"{label} — {decision_str} ({date_str})" if date_str else label
|
||||
|
||||
def action_approve(self):
|
||||
self.write({
|
||||
'decision': 'approved',
|
||||
'decided_at': fields.Datetime.now(),
|
||||
'decided_by': self.env.user.id,
|
||||
})
|
||||
for rec in self:
|
||||
if rec.rule_id:
|
||||
rec.rule_id._record_decision(approved=True)
|
||||
|
||||
def action_reject(self):
|
||||
self.write({
|
||||
'decision': 'rejected',
|
||||
'decided_at': fields.Datetime.now(),
|
||||
'decided_by': self.env.user.id,
|
||||
})
|
||||
for rec in self:
|
||||
if rec.rule_id:
|
||||
rec.rule_id._record_decision(approved=False)
|
||||
@@ -1,121 +0,0 @@
|
||||
import logging
|
||||
from odoo import models, fields, api
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionAccountingRule(models.Model):
|
||||
_name = 'fusion.accounting.rule'
|
||||
_description = 'Fusion Accounting Rule'
|
||||
_order = 'sequence, id'
|
||||
_inherit = ['mail.thread']
|
||||
|
||||
name = fields.Char(string='Name', required=True, tracking=True)
|
||||
rule_type = fields.Selection(
|
||||
selection=[
|
||||
('match', 'Match'),
|
||||
('classify', 'Classify'),
|
||||
('audit', 'Audit'),
|
||||
('fee', 'Fee'),
|
||||
('routing', 'Routing'),
|
||||
('followup', 'Follow-Up'),
|
||||
],
|
||||
string='Type',
|
||||
required=True,
|
||||
tracking=True,
|
||||
)
|
||||
description = fields.Text(
|
||||
string='Description',
|
||||
help='Natural language description read by the AI.',
|
||||
)
|
||||
trigger_domain = fields.Text(
|
||||
string='Trigger Domain (JSON)',
|
||||
help='Odoo domain filter for matching records.',
|
||||
)
|
||||
match_logic = fields.Text(
|
||||
string='Match Logic',
|
||||
help='Natural language matching instructions for the AI.',
|
||||
)
|
||||
match_code = fields.Text(
|
||||
string='Match Code (Python)',
|
||||
help='Optional deterministic Python matching code.',
|
||||
)
|
||||
fee_account_id = fields.Many2one(
|
||||
'account.account', string='Fee Account',
|
||||
)
|
||||
write_off_account_id = fields.Many2one(
|
||||
'account.account', string='Write-Off Account',
|
||||
)
|
||||
approval_tier = fields.Selection(
|
||||
selection=[('auto', 'Auto-Approved'), ('needs_approval', 'Needs Approval')],
|
||||
string='Approval Tier',
|
||||
default='needs_approval',
|
||||
tracking=True,
|
||||
)
|
||||
created_by = fields.Selection(
|
||||
selection=[('admin', 'Admin'), ('ai', 'AI')],
|
||||
string='Created By',
|
||||
default='admin',
|
||||
)
|
||||
confidence_score = fields.Float(
|
||||
string='Confidence Score', digits=(3, 2), default=0.0,
|
||||
)
|
||||
total_uses = fields.Integer(string='Total Uses', default=0)
|
||||
total_approved = fields.Integer(string='Total Approved', default=0)
|
||||
total_rejected = fields.Integer(string='Total Rejected', default=0)
|
||||
promotion_threshold = fields.Float(
|
||||
string='Promotion Threshold', default=0.95,
|
||||
)
|
||||
min_sample_size = fields.Integer(string='Min Sample Size', default=30)
|
||||
active = fields.Boolean(string='Active', default=True, tracking=True)
|
||||
version = fields.Integer(string='Version', default=1)
|
||||
parent_rule_id = fields.Many2one(
|
||||
'fusion.accounting.rule', string='Previous Version',
|
||||
ondelete='set null',
|
||||
)
|
||||
journal_ids = fields.Many2many(
|
||||
'account.journal', string='Journals',
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company', string='Company',
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
sequence = fields.Integer(string='Sequence', default=10)
|
||||
notes = fields.Text(string='Notes')
|
||||
|
||||
def _record_decision(self, approved=True):
|
||||
for rec in self:
|
||||
self.env.cr.execute("""
|
||||
UPDATE fusion_accounting_rule
|
||||
SET total_uses = total_uses + 1,
|
||||
total_approved = total_approved + %s,
|
||||
total_rejected = total_rejected + %s
|
||||
WHERE id = %s
|
||||
RETURNING total_uses, total_approved
|
||||
""", (int(approved), int(not approved), rec.id))
|
||||
row = self.env.cr.fetchone()
|
||||
rec.invalidate_recordset(['total_uses', 'total_approved', 'total_rejected'])
|
||||
if row and row[0] > 0:
|
||||
rec.confidence_score = row[1] / row[0]
|
||||
rec._check_promotion()
|
||||
|
||||
def _check_promotion(self):
|
||||
for rec in self:
|
||||
if (rec.approval_tier == 'needs_approval'
|
||||
and rec.total_uses >= rec.min_sample_size
|
||||
and rec.confidence_score >= rec.promotion_threshold):
|
||||
rec.write({'approval_tier': 'auto'})
|
||||
_logger.info(
|
||||
"Rule '%s' promoted to auto-approved (confidence=%.2f, uses=%d)",
|
||||
rec.name, rec.confidence_score, rec.total_uses,
|
||||
)
|
||||
|
||||
def action_demote(self):
|
||||
self.write({'approval_tier': 'needs_approval'})
|
||||
|
||||
def action_rollback(self):
|
||||
for rec in self:
|
||||
if rec.parent_rule_id:
|
||||
# M5: Use write() to trigger tracking on tracked fields
|
||||
rec.write({'active': False})
|
||||
rec.parent_rule_id.write({'active': True})
|
||||
@@ -1,60 +0,0 @@
|
||||
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'})
|
||||
@@ -1,60 +0,0 @@
|
||||
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.'),
|
||||
]
|
||||
@@ -1,216 +0,0 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from odoo import models, fields, api
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionRecurringPattern(models.Model):
|
||||
_name = 'fusion.recurring.pattern'
|
||||
_description = 'Recurring Bank Transaction Pattern (AI Cache)'
|
||||
_order = 'occurrences desc'
|
||||
|
||||
name = fields.Char(string='Pattern Name', required=True)
|
||||
ref_keyword = fields.Char(
|
||||
string='Reference Keyword',
|
||||
help='The payment_ref substring that identifies this pattern.',
|
||||
index=True,
|
||||
)
|
||||
amount = fields.Float(string='Amount', digits=(12, 2))
|
||||
amount_is_fixed = fields.Boolean(
|
||||
string='Fixed Amount',
|
||||
help='True if the amount is always the same. False if it varies.',
|
||||
)
|
||||
journal_id = fields.Many2one('account.journal', string='Bank Journal')
|
||||
company_id = fields.Many2one(
|
||||
'res.company', string='Company',
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
|
||||
# How this was coded historically
|
||||
expense_account_id = fields.Many2one(
|
||||
'account.account', string='Expense Account',
|
||||
)
|
||||
expense_account_code = fields.Char(
|
||||
related='expense_account_id.code', string='Account Code', store=True,
|
||||
)
|
||||
has_hst = fields.Boolean(string='Has HST')
|
||||
partner_id = fields.Many2one('res.partner', string='Partner')
|
||||
reconcile_model_id = fields.Many2one(
|
||||
'account.reconcile.model', string='Reconciliation Model',
|
||||
help='If this pattern was handled by a reconciliation model.',
|
||||
)
|
||||
|
||||
# AI-readable instructions
|
||||
action_note = fields.Text(
|
||||
string='Action (AI-Readable)',
|
||||
help='Plain English instructions for the AI on how to handle this pattern.',
|
||||
)
|
||||
|
||||
# Stats
|
||||
occurrences = fields.Integer(string='Times Seen')
|
||||
first_seen = fields.Date(string='First Seen')
|
||||
last_seen = fields.Date(string='Last Seen')
|
||||
last_computed = fields.Datetime(string='Last Computed')
|
||||
|
||||
_sql_constraints = [
|
||||
('pattern_uniq', 'unique(ref_keyword, amount, company_id)',
|
||||
'One pattern per keyword+amount per company'),
|
||||
]
|
||||
|
||||
def _rebuild_all_patterns(self, min_occurrences=3, since='2024-01-01'):
|
||||
"""Scan reconciled bank lines for recurring patterns and cache how they were coded."""
|
||||
_logger.info("Rebuilding recurring patterns (min=%d, since=%s)...", min_occurrences, since)
|
||||
companies = self.env['res.company'].search([])
|
||||
total_created = 0
|
||||
total_updated = 0
|
||||
|
||||
for company in companies:
|
||||
# Step 1: Find recurring ref+amount combinations
|
||||
self.env.cr.execute("""
|
||||
SELECT LEFT(bsl.payment_ref, 60) as ref_pattern,
|
||||
bsl.amount,
|
||||
count(*) as occurrences,
|
||||
MIN(am.date) as first_seen,
|
||||
MAX(am.date) as last_seen,
|
||||
MODE() WITHIN GROUP (ORDER BY am.journal_id) as journal_id
|
||||
FROM account_bank_statement_line bsl
|
||||
JOIN account_move am ON bsl.move_id = am.id
|
||||
WHERE bsl.is_reconciled = true
|
||||
AND am.company_id = %s
|
||||
AND am.date >= %s
|
||||
AND bsl.payment_ref IS NOT NULL
|
||||
AND bsl.payment_ref != ''
|
||||
GROUP BY LEFT(bsl.payment_ref, 60), bsl.amount
|
||||
HAVING count(*) >= %s
|
||||
ORDER BY count(*) DESC
|
||||
LIMIT 200
|
||||
""", (company.id, since, min_occurrences))
|
||||
patterns = self.env.cr.dictfetchall()
|
||||
|
||||
for pat in patterns:
|
||||
ref = pat['ref_pattern'].strip()
|
||||
if not ref or len(ref) < 3:
|
||||
continue
|
||||
|
||||
# Step 2: Trace how one instance was coded
|
||||
self.env.cr.execute("""
|
||||
SELECT aml.account_id, aml.tax_line_id, aml.partner_id
|
||||
FROM account_bank_statement_line bsl
|
||||
JOIN account_move am ON bsl.move_id = am.id
|
||||
JOIN account_move_line aml ON aml.move_id = am.id
|
||||
WHERE bsl.is_reconciled = true
|
||||
AND bsl.payment_ref ILIKE %s
|
||||
AND bsl.amount = %s
|
||||
AND am.company_id = %s
|
||||
AND aml.display_type NOT IN ('line_section', 'line_note')
|
||||
AND aml.account_id NOT IN (
|
||||
SELECT default_account_id FROM account_journal
|
||||
WHERE company_id = %s AND default_account_id IS NOT NULL
|
||||
)
|
||||
ORDER BY bsl.id DESC
|
||||
LIMIT 5
|
||||
""", (f'%{ref[:40]}%', pat['amount'], company.id, company.id))
|
||||
coded_lines = self.env.cr.dictfetchall()
|
||||
|
||||
expense_account_id = None
|
||||
has_hst = False
|
||||
partner_id = None
|
||||
|
||||
for cl in coded_lines:
|
||||
if cl['tax_line_id']:
|
||||
has_hst = True
|
||||
elif cl['account_id'] and not expense_account_id:
|
||||
acct = self.env['account.account'].browse(cl['account_id'])
|
||||
if acct.exists() and acct.account_type in (
|
||||
'expense', 'expense_direct_cost', 'expense_depreciation',
|
||||
'asset_non_current', 'liability_non_current',
|
||||
):
|
||||
expense_account_id = cl['account_id']
|
||||
if cl['partner_id'] and not partner_id:
|
||||
partner_id = cl['partner_id']
|
||||
|
||||
# Build a friendly name
|
||||
clean_ref = re.sub(r'[X*]{3,}[\w-]*', '', ref).strip()
|
||||
clean_ref = re.sub(r'\s{2,}', ' ', clean_ref)[:50]
|
||||
|
||||
# Build AI action note
|
||||
acct_name = ''
|
||||
if expense_account_id:
|
||||
acct = self.env['account.account'].browse(expense_account_id)
|
||||
acct_name = f'{acct.code} {acct.name}' if acct.exists() else ''
|
||||
|
||||
partner_name = ''
|
||||
if partner_id:
|
||||
p = self.env['res.partner'].browse(partner_id)
|
||||
partner_name = p.name if p.exists() else ''
|
||||
|
||||
action_parts = [f'RECURRING PAYMENT (seen {pat["occurrences"]} times).']
|
||||
if expense_account_id:
|
||||
action_parts.append(f'Post to account: {acct_name}.')
|
||||
if has_hst:
|
||||
action_parts.append('HST applies — split with 13% ITC.')
|
||||
else:
|
||||
action_parts.append('No HST — post without tax.')
|
||||
if partner_name:
|
||||
action_parts.append(f'Partner: {partner_name}.')
|
||||
action_parts.append('Apply same coding as previous occurrences — no user input needed.')
|
||||
|
||||
action_note = ' '.join(action_parts)
|
||||
|
||||
# Step 3: Check if a reconciliation model already handles this pattern
|
||||
reco_model_id = None
|
||||
try:
|
||||
reco_models = self.env['account.reconcile.model'].search([
|
||||
('company_id', '=', company.id),
|
||||
('active', '=', True),
|
||||
('match_label_param', '!=', False),
|
||||
])
|
||||
ref_lower = ref.lower()
|
||||
for rm in reco_models:
|
||||
if rm.match_label_param and rm.match_label_param.lower() in ref_lower:
|
||||
reco_model_id = rm.id
|
||||
action_parts.append(
|
||||
f'Reconciliation model "{rm.name}" (ID:{rm.id}) already handles this — '
|
||||
f'use apply_reconcile_model to apply it automatically.'
|
||||
)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Upsert
|
||||
existing = self.search([
|
||||
('ref_keyword', '=', ref),
|
||||
('amount', '=', pat['amount']),
|
||||
('company_id', '=', company.id),
|
||||
], limit=1)
|
||||
|
||||
vals = {
|
||||
'name': clean_ref,
|
||||
'ref_keyword': ref,
|
||||
'amount': pat['amount'],
|
||||
'amount_is_fixed': True,
|
||||
'journal_id': pat['journal_id'],
|
||||
'company_id': company.id,
|
||||
'expense_account_id': expense_account_id,
|
||||
'has_hst': has_hst,
|
||||
'partner_id': partner_id,
|
||||
'reconcile_model_id': reco_model_id,
|
||||
'action_note': action_note,
|
||||
'occurrences': pat['occurrences'],
|
||||
'first_seen': pat['first_seen'],
|
||||
'last_seen': pat['last_seen'],
|
||||
'last_computed': fields.Datetime.now(),
|
||||
}
|
||||
|
||||
if existing:
|
||||
existing.write(vals)
|
||||
total_updated += 1
|
||||
else:
|
||||
self.create(vals)
|
||||
total_created += 1
|
||||
|
||||
_logger.info("Recurring patterns rebuilt: %d created, %d updated", total_created, total_updated)
|
||||
return {'created': total_created, 'updated': total_updated}
|
||||
@@ -1,221 +0,0 @@
|
||||
import json
|
||||
import logging
|
||||
from odoo import models, fields, api
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionVendorTaxProfile(models.Model):
|
||||
_name = 'fusion.vendor.tax.profile'
|
||||
_description = 'Vendor Tax Profile (AI Cache)'
|
||||
_order = 'total_bills desc'
|
||||
_rec_name = 'partner_id'
|
||||
|
||||
partner_id = fields.Many2one(
|
||||
'res.partner', string='Vendor', required=True, index=True,
|
||||
ondelete='cascade',
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company', string='Company',
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
total_bills = fields.Integer(string='Total Bills')
|
||||
bills_with_hst = fields.Integer(string='Bills with HST')
|
||||
bills_zero_rated = fields.Integer(string='Bills Zero-Rated')
|
||||
avg_tax_pct = fields.Float(string='Avg Tax %', digits=(5, 2))
|
||||
|
||||
# Classification
|
||||
tax_classification = fields.Selection([
|
||||
('always_hst', 'Always HST (13%)'),
|
||||
('mostly_hst', 'Mostly HST (>10%)'),
|
||||
('shipping_only', 'HST on Shipping Only (<2%)'),
|
||||
('never_hst', 'Never HST (0%)'),
|
||||
('mixed', 'Mixed / Inconsistent'),
|
||||
], string='Tax Classification')
|
||||
|
||||
# Most common expense account
|
||||
primary_account_id = fields.Many2one(
|
||||
'account.account', string='Primary Expense Account',
|
||||
)
|
||||
primary_account_code = fields.Char(
|
||||
related='primary_account_id.code', string='Account Code', store=True,
|
||||
)
|
||||
|
||||
# AI-readable note
|
||||
tax_note = fields.Text(
|
||||
string='Tax Note (AI-Readable)',
|
||||
help='Plain English note the AI reads to understand tax treatment.',
|
||||
)
|
||||
|
||||
# PO-tracked vendor — bills come from purchase orders, never from bank recon
|
||||
is_po_vendor = fields.Boolean(
|
||||
string='PO-Tracked Vendor',
|
||||
help='Bills for this vendor are created from Purchase Orders. '
|
||||
'Do NOT create bills during bank reconciliation — just match to existing bills.',
|
||||
)
|
||||
po_count = fields.Integer(string='Purchase Orders')
|
||||
|
||||
# Vendor details for matching
|
||||
is_foreign = fields.Boolean(string='Foreign Vendor')
|
||||
vendor_country = fields.Char(string='Vendor Country')
|
||||
|
||||
# Timestamps
|
||||
last_computed = fields.Datetime(string='Last Computed')
|
||||
|
||||
_sql_constraints = [
|
||||
('partner_company_uniq', 'unique(partner_id, company_id)',
|
||||
'One tax profile per vendor per company'),
|
||||
]
|
||||
|
||||
def _rebuild_all_profiles(self, min_bills=3):
|
||||
"""Rebuild all vendor tax profiles from posted bill history.
|
||||
Called by cron or manually."""
|
||||
_logger.info("Rebuilding vendor tax profiles (min_bills=%d)...", min_bills)
|
||||
companies = self.env['res.company'].search([])
|
||||
|
||||
total_created = 0
|
||||
total_updated = 0
|
||||
|
||||
for company in companies:
|
||||
# Find all vendors with enough bills
|
||||
self.env.cr.execute("""
|
||||
SELECT m.partner_id, count(*) as bill_count,
|
||||
SUM(CASE WHEN m.amount_tax > 0.01 THEN 1 ELSE 0 END) as with_tax,
|
||||
SUM(CASE WHEN m.amount_tax <= 0.01 THEN 1 ELSE 0 END) as no_tax,
|
||||
COALESCE(AVG(CASE WHEN m.amount_untaxed > 0
|
||||
THEN m.amount_tax / m.amount_untaxed * 100
|
||||
ELSE 0 END), 0) as avg_tax_pct
|
||||
FROM account_move m
|
||||
WHERE m.move_type = 'in_invoice'
|
||||
AND m.state = 'posted'
|
||||
AND m.company_id = %s
|
||||
AND m.partner_id IS NOT NULL
|
||||
GROUP BY m.partner_id
|
||||
HAVING count(*) >= %s
|
||||
""", (company.id, min_bills))
|
||||
vendor_stats = self.env.cr.dictfetchall()
|
||||
|
||||
for vs in vendor_stats:
|
||||
partner = self.env['res.partner'].browse(vs['partner_id'])
|
||||
if not partner.exists():
|
||||
continue
|
||||
|
||||
# Classify
|
||||
avg_pct = round(vs['avg_tax_pct'], 2)
|
||||
total = vs['bill_count']
|
||||
with_tax = vs['with_tax']
|
||||
no_tax = vs['no_tax']
|
||||
|
||||
if no_tax == total:
|
||||
classification = 'never_hst'
|
||||
note = f'{partner.name} NEVER charges HST. All {total} bills are zero-rated. Do NOT apply HST.'
|
||||
elif avg_pct >= 12.0:
|
||||
classification = 'always_hst'
|
||||
note = f'{partner.name} consistently charges HST at ~{avg_pct}%. Apply HST PURCHASE (13%) to all product lines.'
|
||||
elif avg_pct >= 10.0:
|
||||
classification = 'mostly_hst'
|
||||
note = f'{partner.name} usually charges HST (~{avg_pct}%). {no_tax} of {total} bills had no tax. Apply HST by default but verify zero-rated items.'
|
||||
elif avg_pct < 2.0 and with_tax > 0:
|
||||
classification = 'shipping_only'
|
||||
note = (
|
||||
f'{partner.name} products are zero-rated (avg tax only {avg_pct}% of subtotal). '
|
||||
f'HST applies ONLY to shipping/freight charges, NOT to product lines. '
|
||||
f'When creating a bill, use NO TAX PURCHASE on product lines and HST PURCHASE (13%) only on shipping lines.'
|
||||
)
|
||||
else:
|
||||
classification = 'mixed'
|
||||
note = (
|
||||
f'{partner.name} has mixed tax treatment ({with_tax} bills with HST, {no_tax} without, avg {avg_pct}%). '
|
||||
f'Check each bill individually — some items may be zero-rated while others have HST.'
|
||||
)
|
||||
|
||||
# Find primary expense account
|
||||
self.env.cr.execute("""
|
||||
SELECT aml.account_id, count(*) as cnt
|
||||
FROM account_move_line aml
|
||||
JOIN account_move m ON aml.move_id = m.id
|
||||
WHERE m.partner_id = %s
|
||||
AND m.move_type = 'in_invoice'
|
||||
AND m.state = 'posted'
|
||||
AND m.company_id = %s
|
||||
AND aml.display_type = 'product'
|
||||
GROUP BY aml.account_id
|
||||
ORDER BY count(*) DESC
|
||||
LIMIT 1
|
||||
""", (vs['partner_id'], company.id))
|
||||
acct_row = self.env.cr.fetchone()
|
||||
primary_account_id = acct_row[0] if acct_row else False
|
||||
|
||||
# Check if foreign vendor
|
||||
is_foreign = False
|
||||
country = ''
|
||||
if partner.country_id:
|
||||
country = partner.country_id.name
|
||||
is_foreign = partner.country_id.code != 'CA'
|
||||
elif partner.vat and not partner.vat.startswith('CA'):
|
||||
is_foreign = True
|
||||
|
||||
# Only override to never_hst if foreign AND bills actually confirm no tax
|
||||
# (Don't override if bill data shows they DO charge HST — e.g., Amazon Canada)
|
||||
if is_foreign and avg_pct < 1.0 and no_tax > with_tax:
|
||||
classification = 'never_hst'
|
||||
note = f'{partner.name} is a FOREIGN vendor ({country or "non-Canadian"}) and bills confirm no HST. Do NOT apply any Canadian tax.'
|
||||
|
||||
# Check if this is a PO-tracked vendor (has confirmed purchase orders)
|
||||
is_po_vendor = False
|
||||
vendor_po_count = 0
|
||||
try:
|
||||
self.env.cr.execute("""
|
||||
SELECT count(*) FROM purchase_order
|
||||
WHERE partner_id = %s AND state IN ('purchase', 'done')
|
||||
AND company_id = %s
|
||||
""", (vs['partner_id'], company.id))
|
||||
po_row = self.env.cr.fetchone()
|
||||
vendor_po_count = po_row[0] if po_row else 0
|
||||
is_po_vendor = vendor_po_count >= 3
|
||||
except Exception:
|
||||
pass # purchase module may not be installed
|
||||
|
||||
if is_po_vendor:
|
||||
note = (
|
||||
f'PO-TRACKED VENDOR ({vendor_po_count} purchase orders). '
|
||||
f'Bills are created from Purchase Orders — do NOT create bills during bank reconciliation. '
|
||||
f'Instead, find the existing unpaid bill and match the bank payment to it. '
|
||||
f'Tax treatment: {note}'
|
||||
)
|
||||
|
||||
# Upsert
|
||||
existing = self.search([
|
||||
('partner_id', '=', vs['partner_id']),
|
||||
('company_id', '=', company.id),
|
||||
], limit=1)
|
||||
|
||||
vals = {
|
||||
'partner_id': vs['partner_id'],
|
||||
'company_id': company.id,
|
||||
'total_bills': total,
|
||||
'bills_with_hst': with_tax,
|
||||
'bills_zero_rated': no_tax,
|
||||
'avg_tax_pct': avg_pct,
|
||||
'tax_classification': classification,
|
||||
'primary_account_id': primary_account_id,
|
||||
'tax_note': note,
|
||||
'is_po_vendor': is_po_vendor,
|
||||
'po_count': vendor_po_count,
|
||||
'is_foreign': is_foreign,
|
||||
'vendor_country': country,
|
||||
'last_computed': fields.Datetime.now(),
|
||||
}
|
||||
|
||||
if existing:
|
||||
existing.write(vals)
|
||||
total_updated += 1
|
||||
else:
|
||||
self.create(vals)
|
||||
total_created += 1
|
||||
|
||||
_logger.info(
|
||||
"Vendor tax profiles rebuilt: %d created, %d updated",
|
||||
total_created, total_updated,
|
||||
)
|
||||
return {'created': total_created, 'updated': total_updated}
|
||||
@@ -1,84 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,19 +0,0 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_fusion_session_user,fusion.accounting.session.user,model_fusion_accounting_session,group_fusion_accounting_user,1,1,1,0
|
||||
access_fusion_session_admin,fusion.accounting.session.admin,model_fusion_accounting_session,group_fusion_accounting_admin,1,1,1,1
|
||||
access_fusion_history_user,fusion.accounting.match.history.user,model_fusion_accounting_match_history,group_fusion_accounting_user,1,0,0,0
|
||||
access_fusion_history_manager,fusion.accounting.match.history.manager,model_fusion_accounting_match_history,group_fusion_accounting_manager,1,1,1,0
|
||||
access_fusion_history_admin,fusion.accounting.match.history.admin,model_fusion_accounting_match_history,group_fusion_accounting_admin,1,1,1,1
|
||||
access_fusion_rule_user,fusion.accounting.rule.user,model_fusion_accounting_rule,group_fusion_accounting_user,1,0,0,0
|
||||
access_fusion_rule_manager,fusion.accounting.rule.manager,model_fusion_accounting_rule,group_fusion_accounting_manager,1,1,1,0
|
||||
access_fusion_rule_admin,fusion.accounting.rule.admin,model_fusion_accounting_rule,group_fusion_accounting_admin,1,1,1,1
|
||||
access_fusion_tool_user,fusion.accounting.tool.user,model_fusion_accounting_tool,group_fusion_accounting_user,1,0,0,0
|
||||
access_fusion_tool_admin,fusion.accounting.tool.admin,model_fusion_accounting_tool,group_fusion_accounting_admin,1,1,1,1
|
||||
access_fusion_dashboard_user,fusion.accounting.dashboard.user,model_fusion_accounting_dashboard,group_fusion_accounting_user,1,1,1,1
|
||||
access_fusion_rule_wizard_manager,fusion.accounting.rule.wizard.manager,model_fusion_accounting_rule_wizard,group_fusion_accounting_manager,1,1,1,1
|
||||
access_fusion_recurring_pattern_user,fusion.recurring.pattern.user,model_fusion_recurring_pattern,group_fusion_accounting_user,1,0,0,0
|
||||
access_fusion_recurring_pattern_manager,fusion.recurring.pattern.manager,model_fusion_recurring_pattern,group_fusion_accounting_manager,1,1,1,0
|
||||
access_fusion_recurring_pattern_admin,fusion.recurring.pattern.admin,model_fusion_recurring_pattern,group_fusion_accounting_admin,1,1,1,1
|
||||
access_fusion_vendor_profile_user,fusion.vendor.tax.profile.user,model_fusion_vendor_tax_profile,group_fusion_accounting_user,1,0,0,0
|
||||
access_fusion_vendor_profile_manager,fusion.vendor.tax.profile.manager,model_fusion_vendor_tax_profile,group_fusion_accounting_manager,1,1,1,0
|
||||
access_fusion_vendor_profile_admin,fusion.vendor.tax.profile.admin,model_fusion_vendor_tax_profile,group_fusion_accounting_admin,1,1,1,1
|
||||
|
@@ -1,5 +0,0 @@
|
||||
from . import adapters
|
||||
from . import tools
|
||||
from . import prompts
|
||||
from . import agent
|
||||
from . import scoring
|
||||
@@ -1,2 +0,0 @@
|
||||
from . import claude
|
||||
from . import openai_adapter
|
||||
@@ -1,141 +0,0 @@
|
||||
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, model_override=None):
|
||||
client = self._get_client()
|
||||
model = model_override or self._get_model_name()
|
||||
|
||||
api_messages = []
|
||||
for msg in messages:
|
||||
if msg['role'] in ('user', 'assistant'):
|
||||
api_messages.append(msg)
|
||||
|
||||
kwargs = {
|
||||
'model': model,
|
||||
'max_tokens': 16384,
|
||||
'system': system_prompt,
|
||||
'messages': api_messages,
|
||||
}
|
||||
if tools:
|
||||
kwargs['tools'] = self._format_tools(tools)
|
||||
|
||||
if self._supports_extended_thinking(model) and tools:
|
||||
kwargs['thinking'] = {
|
||||
'type': 'enabled',
|
||||
'budget_tokens': 8192,
|
||||
}
|
||||
|
||||
try:
|
||||
response = client.messages.create(**kwargs)
|
||||
except anthropic_sdk.BadRequestError as e:
|
||||
if 'thinking' in str(e).lower():
|
||||
kwargs.pop('thinking', None)
|
||||
response = client.messages.create(**kwargs)
|
||||
else:
|
||||
raise UserError(_("Claude API error: %s", str(e)))
|
||||
except Exception as e:
|
||||
_logger.error("Claude API error: %s", e)
|
||||
raise UserError(_("Claude API error: %s", str(e)))
|
||||
|
||||
text_parts = []
|
||||
tool_calls = []
|
||||
thinking_text = []
|
||||
for block in response.content:
|
||||
if block.type == 'text':
|
||||
text_parts.append(block.text)
|
||||
elif block.type == 'tool_use':
|
||||
tool_calls.append({
|
||||
'id': block.id,
|
||||
'name': block.name,
|
||||
'arguments': block.input,
|
||||
})
|
||||
elif block.type == 'thinking':
|
||||
thinking_text.append(block.thinking)
|
||||
|
||||
if thinking_text:
|
||||
_logger.debug("Claude thinking: %s", thinking_text[0][:500])
|
||||
|
||||
return {
|
||||
'text': '\n'.join(text_parts),
|
||||
'tool_calls': tool_calls if tool_calls else None,
|
||||
'tokens_in': response.usage.input_tokens,
|
||||
'tokens_out': response.usage.output_tokens,
|
||||
'stop_reason': response.stop_reason,
|
||||
'raw_content': response.content,
|
||||
}
|
||||
|
||||
def append_tool_results(self, messages, ai_response, tool_results):
|
||||
assistant_content = []
|
||||
for block in ai_response.get('raw_content', []):
|
||||
if hasattr(block, 'type'):
|
||||
if block.type == 'text':
|
||||
assistant_content.append({'type': 'text', 'text': block.text})
|
||||
elif block.type == 'tool_use':
|
||||
assistant_content.append({
|
||||
'type': 'tool_use',
|
||||
'id': block.id,
|
||||
'name': block.name,
|
||||
'input': block.input,
|
||||
})
|
||||
|
||||
messages.append({'role': 'assistant', 'content': assistant_content})
|
||||
|
||||
tool_result_content = []
|
||||
for tr in tool_results:
|
||||
tool_result_content.append({
|
||||
'type': 'tool_result',
|
||||
'tool_use_id': tr['tool_call_id'],
|
||||
'content': tr['result'],
|
||||
})
|
||||
messages.append({'role': 'user', 'content': tool_result_content})
|
||||
|
||||
return messages
|
||||
@@ -1,137 +0,0 @@
|
||||
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, model_override=None):
|
||||
client = self._get_client()
|
||||
model = model_override or self._get_model_name()
|
||||
is_reasoning = self._is_reasoning_model(model)
|
||||
|
||||
if is_reasoning:
|
||||
api_messages = [{'role': 'developer', 'content': system_prompt}]
|
||||
else:
|
||||
api_messages = [{'role': 'system', 'content': system_prompt}]
|
||||
for msg in messages:
|
||||
if msg['role'] in ('user', 'assistant', 'tool'):
|
||||
api_messages.append(msg)
|
||||
|
||||
kwargs = {
|
||||
'model': model,
|
||||
'messages': api_messages,
|
||||
}
|
||||
if is_reasoning:
|
||||
kwargs['max_completion_tokens'] = 16384
|
||||
kwargs['reasoning_effort'] = 'medium'
|
||||
else:
|
||||
kwargs['max_tokens'] = 4096
|
||||
|
||||
if tools:
|
||||
kwargs['tools'] = self._format_tools(tools)
|
||||
|
||||
try:
|
||||
response = client.chat.completions.create(**kwargs)
|
||||
except Exception as e:
|
||||
_logger.error("OpenAI API error: %s", e)
|
||||
raise UserError(_("OpenAI API error: %s", str(e)))
|
||||
|
||||
choice = response.choices[0]
|
||||
message = choice.message
|
||||
|
||||
tool_calls = []
|
||||
if message.tool_calls:
|
||||
for tc in message.tool_calls:
|
||||
try:
|
||||
args = json.loads(tc.function.arguments)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
_logger.warning("Malformed tool args from OpenAI: %s", tc.function.arguments)
|
||||
args = {}
|
||||
tool_calls.append({
|
||||
'id': tc.id,
|
||||
'name': tc.function.name,
|
||||
'arguments': args,
|
||||
})
|
||||
|
||||
return {
|
||||
'text': message.content or '',
|
||||
'tool_calls': tool_calls if tool_calls else None,
|
||||
'tokens_in': response.usage.prompt_tokens,
|
||||
'tokens_out': response.usage.completion_tokens,
|
||||
'stop_reason': choice.finish_reason,
|
||||
'raw_message': message,
|
||||
}
|
||||
|
||||
def append_tool_results(self, messages, ai_response, tool_results):
|
||||
raw_msg = ai_response.get('raw_message')
|
||||
assistant_msg = {'role': 'assistant', 'content': raw_msg.content or ''}
|
||||
if raw_msg.tool_calls:
|
||||
assistant_msg['tool_calls'] = [
|
||||
{
|
||||
'id': tc.id,
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': tc.function.name,
|
||||
'arguments': tc.function.arguments,
|
||||
},
|
||||
}
|
||||
for tc in raw_msg.tool_calls
|
||||
]
|
||||
messages.append(assistant_msg)
|
||||
|
||||
for tr in tool_results:
|
||||
messages.append({
|
||||
'role': 'tool',
|
||||
'tool_call_id': tr['tool_call_id'],
|
||||
'content': tr['result'],
|
||||
})
|
||||
|
||||
return messages
|
||||
@@ -1,947 +0,0 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# In-memory execution state for live status polling.
|
||||
# Key: session_id, Value: {thinking, tool_calls, status}
|
||||
# Cleared after each chat() call completes.
|
||||
_execution_state = {}
|
||||
|
||||
|
||||
def get_execution_state(session_id):
|
||||
"""Get the current execution state for a session (called by polling endpoint)."""
|
||||
return _execution_state.get(session_id, {'status': 'idle', 'thinking': '', 'tool_calls': []})
|
||||
|
||||
|
||||
# Inter-account transfer pairs: (source_journal, cc_journal, cc_account_pattern)
|
||||
# Source sends "MB-CREDIT CARD" (outgoing), CC receives "PAYMENT FROM" (incoming)
|
||||
TRANSFER_PAIRS = [
|
||||
# (source_journal_id, cc_journal_id, outstanding_account_id)
|
||||
(50, 51, 493), # Scotia Current → Passport Visa, Outstanding Receipts - All Banks
|
||||
(53, 28, 493), # RBC Chequing → RBC Visa, Outstanding Receipts - All Banks
|
||||
]
|
||||
|
||||
|
||||
class FusionAccountingAgent(models.AbstractModel):
|
||||
_name = 'fusion.accounting.agent'
|
||||
_description = 'Fusion Accounting AI Agent Orchestrator'
|
||||
|
||||
def _get_config(self, key, default=None):
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
return ICP.get_param(f'fusion_accounting.{key}', default)
|
||||
|
||||
# Domains that need deeper reasoning → use Sonnet
|
||||
COMPLEX_DOMAINS = {'audit', 'month_end', 'hst_management', 'payroll_management'}
|
||||
# Keywords in user messages that suggest complex analysis → use Sonnet
|
||||
COMPLEX_KEYWORDS = {
|
||||
'audit', 'analyze', 'analyse', 'review all', 'full report', 'investigate',
|
||||
'month-end', 'month end', 'close the books', 'hst filing', 'tax return',
|
||||
'what went wrong', 'why is', 'explain the difference', 'compare',
|
||||
}
|
||||
|
||||
def _get_adapter(self):
|
||||
provider = self._get_config('ai_provider', 'claude')
|
||||
if provider == 'claude':
|
||||
return self.env['fusion.accounting.adapter.claude']
|
||||
return self.env['fusion.accounting.adapter.openai']
|
||||
|
||||
def _route_model(self, session, user_message, has_image=False):
|
||||
"""Smart model routing: Haiku for routine tool calling, Sonnet for complex analysis.
|
||||
Returns (model_name, can_escalate) — can_escalate=True means Haiku is trying first
|
||||
and we should check if it needs help."""
|
||||
provider = session.ai_provider or self._get_config('ai_provider', 'claude')
|
||||
if provider != 'claude':
|
||||
return None, False
|
||||
|
||||
# Always use Sonnet for images (vision quality matters for OCR)
|
||||
if has_image:
|
||||
return 'claude-sonnet-4-6', False
|
||||
|
||||
# Use Sonnet for complex domains
|
||||
if session.context_domain in self.COMPLEX_DOMAINS:
|
||||
return 'claude-sonnet-4-6', False
|
||||
|
||||
# Use Sonnet if the message contains complex analysis keywords
|
||||
msg_lower = (user_message or '').lower()
|
||||
if any(kw in msg_lower for kw in self.COMPLEX_KEYWORDS):
|
||||
return 'claude-sonnet-4-6', False
|
||||
|
||||
# Default: Haiku with escalation enabled
|
||||
return 'claude-haiku-4-5', True
|
||||
|
||||
def _should_escalate(self, response, tool_calls_log, turn):
|
||||
"""Check if Haiku's response suggests it needs Sonnet's help."""
|
||||
text = (response.get('text') or '').lower()
|
||||
|
||||
# Haiku said it can't do something
|
||||
uncertainty_phrases = [
|
||||
"i'm not sure", "i cannot determine", "i don't have enough",
|
||||
"unable to", "i'm unable", "this is complex", "beyond my",
|
||||
"i need more context", "difficult to assess", "i apologize",
|
||||
"i'm having trouble", "let me think about this differently",
|
||||
]
|
||||
if any(phrase in text for phrase in uncertainty_phrases):
|
||||
return True
|
||||
|
||||
# Haiku made no tool calls on first turn when it probably should have
|
||||
# (user asked a question but Haiku just gave text without using tools)
|
||||
if turn == 0 and not response.get('tool_calls') and not text:
|
||||
return True
|
||||
|
||||
# Haiku had multiple tool errors
|
||||
error_count = sum(1 for tc in tool_calls_log if tc.get('status') == 'error')
|
||||
if error_count >= 2:
|
||||
return True
|
||||
|
||||
# Response is very short for a data question (Haiku might be confused)
|
||||
if turn == 0 and not response.get('tool_calls') and len(text) < 50:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _get_tool_registry(self):
|
||||
return self.env['fusion.accounting.tool'].search([('active', '=', True)])
|
||||
|
||||
def _get_tools_for_user(self, user=None):
|
||||
user = user or self.env.user
|
||||
tools = self._get_tool_registry()
|
||||
filtered = self.env['fusion.accounting.tool']
|
||||
for tool in tools:
|
||||
if not tool.required_groups:
|
||||
filtered |= tool
|
||||
continue
|
||||
group_xmlids = [g.strip() for g in tool.required_groups.split(',') if g.strip()]
|
||||
if all(user.has_group(g) for g in group_xmlids):
|
||||
filtered |= tool
|
||||
return filtered
|
||||
|
||||
def _build_tool_definitions(self, tools):
|
||||
definitions = []
|
||||
for tool in tools:
|
||||
# A2: Include tier info in description so AI knows which tools need approval
|
||||
tier_label = {'1': 'Read-only', '2': 'Auto-approved', '3': 'Requires user approval'}.get(tool.tier, '')
|
||||
desc = tool.description or ''
|
||||
if tier_label:
|
||||
desc = f"[Tier {tool.tier}: {tier_label}] {desc}"
|
||||
defn = {
|
||||
'name': tool.name,
|
||||
'description': desc,
|
||||
}
|
||||
if tool.parameters_schema:
|
||||
try:
|
||||
defn['parameters'] = json.loads(tool.parameters_schema)
|
||||
except json.JSONDecodeError:
|
||||
defn['parameters'] = {'type': 'object', 'properties': {}}
|
||||
else:
|
||||
defn['parameters'] = {'type': 'object', 'properties': {}}
|
||||
definitions.append(defn)
|
||||
return definitions
|
||||
|
||||
def _load_rules(self, domain=None):
|
||||
rule_domain = [('active', '=', True), ('company_id', '=', self.env.company.id)]
|
||||
if domain:
|
||||
rule_domain.append(('rule_type', '=', domain))
|
||||
rules = self.env['fusion.accounting.rule'].search(rule_domain, order='sequence')
|
||||
admin_rules = rules.filtered(lambda r: r.created_by == 'admin')
|
||||
ai_auto = rules.filtered(lambda r: r.created_by == 'ai' and r.approval_tier == 'auto')
|
||||
ai_pending = rules.filtered(lambda r: r.created_by == 'ai' and r.approval_tier == 'needs_approval')
|
||||
return admin_rules | ai_auto | ai_pending
|
||||
|
||||
def _load_match_history(self, domain=None, limit=None):
|
||||
limit = limit or int(self._get_config('history_in_prompt', '50'))
|
||||
history_domain = [('company_id', '=', self.env.company.id)]
|
||||
if domain:
|
||||
history_domain.append(('tool_name', 'ilike', domain))
|
||||
return self.env['fusion.accounting.match.history'].search(
|
||||
history_domain, limit=limit, order='proposed_at desc',
|
||||
)
|
||||
|
||||
def _build_system_prompt(self, rules, history, context=None, domain=None):
|
||||
from .prompts.system_prompt import build_system_prompt
|
||||
from .prompts.domain_prompts import get_domain_prompt
|
||||
base = build_system_prompt(rules, history, context)
|
||||
if domain:
|
||||
domain_prompt = get_domain_prompt(domain)
|
||||
if domain_prompt:
|
||||
base = f"{base}\n\n{domain_prompt}"
|
||||
return base
|
||||
|
||||
def _execute_tool(self, tool_name, params, session_id=None):
|
||||
from .tools import TOOL_DISPATCH
|
||||
if tool_name not in TOOL_DISPATCH:
|
||||
return {'error': f'Unknown tool: {tool_name}'}
|
||||
try:
|
||||
result = TOOL_DISPATCH[tool_name](self.env, params)
|
||||
return result
|
||||
except Exception as e:
|
||||
_logger.error("Tool execution error (%s): %s", tool_name, e)
|
||||
return {'error': str(e)}
|
||||
|
||||
def _log_match_history(self, session, tool_name, params, result, reasoning='',
|
||||
confidence=0.0, rule=None, tier='1'):
|
||||
vals = {
|
||||
'session_id': session.id if session else False,
|
||||
'tool_name': tool_name,
|
||||
'tool_params': json.dumps(params, indent=2, default=str) if params else '{}',
|
||||
'tool_result': json.dumps(result, indent=2, default=str) if result else '{}',
|
||||
'ai_reasoning': reasoning,
|
||||
'ai_confidence': confidence,
|
||||
'rule_id': rule.id if rule else False,
|
||||
'proposed_at': fields.Datetime.now(),
|
||||
'decision': 'auto' if tier in ('1', '2') else 'pending',
|
||||
'company_id': self.env.company.id,
|
||||
}
|
||||
return self.env['fusion.accounting.match.history'].sudo().create(vals)
|
||||
|
||||
def chat(self, session_id, user_message, context=None, image=None):
|
||||
session = self.env['fusion.accounting.session'].browse(session_id)
|
||||
if not session.exists():
|
||||
raise UserError(_("Session not found."))
|
||||
|
||||
adapter = self._get_adapter()
|
||||
provider = self._get_config('ai_provider', 'claude')
|
||||
|
||||
# Pin provider to session to prevent cross-adapter message contamination (C5)
|
||||
if session.ai_provider and session.ai_provider != provider:
|
||||
_logger.warning(
|
||||
"Session %s was started with %s but current provider is %s. "
|
||||
"Keeping original provider to avoid message format conflicts.",
|
||||
session.name, session.ai_provider, provider,
|
||||
)
|
||||
provider = session.ai_provider
|
||||
if provider == 'claude':
|
||||
adapter = self.env['fusion.accounting.adapter.claude']
|
||||
else:
|
||||
adapter = self.env['fusion.accounting.adapter.openai']
|
||||
|
||||
tools = self._get_tools_for_user()
|
||||
tool_definitions = self._build_tool_definitions(tools)
|
||||
rules = self._load_rules()
|
||||
history = self._load_match_history()
|
||||
system_prompt = self._build_system_prompt(
|
||||
rules, history, context, domain=session.context_domain,
|
||||
)
|
||||
|
||||
messages_json = json.loads(session.message_ids_json or '[]')
|
||||
|
||||
# Build user message — may include image for vision
|
||||
if image and isinstance(image, dict) and image.get('base64'):
|
||||
user_content = []
|
||||
if user_message:
|
||||
user_content.append({'type': 'text', 'text': user_message})
|
||||
user_content.append({
|
||||
'type': 'image',
|
||||
'source': {
|
||||
'type': 'base64',
|
||||
'media_type': image.get('media_type', 'image/png'),
|
||||
'data': image['base64'],
|
||||
},
|
||||
})
|
||||
messages_json.append({'role': 'user', 'content': user_content})
|
||||
else:
|
||||
messages_json.append({'role': 'user', 'content': user_message})
|
||||
|
||||
# Smart model routing: Haiku for routine, Sonnet for complex
|
||||
has_image = bool(image and isinstance(image, dict) and image.get('base64'))
|
||||
model_override, can_escalate = self._route_model(session, user_message, has_image=has_image)
|
||||
escalated = False
|
||||
if model_override:
|
||||
_logger.info("Model routing: %s → %s (escalation=%s)", session.name, model_override, can_escalate)
|
||||
|
||||
max_turns = max(int(self._get_config('max_tool_calls', '20')), 1)
|
||||
total_tokens_in = 0
|
||||
total_tokens_out = 0
|
||||
response = {'text': '', 'tool_calls': None}
|
||||
has_pending_tier3 = False
|
||||
tool_calls_log = [] # Track tool calls for frontend display
|
||||
reconciliation_data = None # Raw data from suggest_bank_line_matches
|
||||
|
||||
# Initialize live execution state for polling
|
||||
_execution_state[session.id] = {
|
||||
'status': 'thinking',
|
||||
'thinking': '',
|
||||
'tool_calls': [],
|
||||
'turn': 0,
|
||||
}
|
||||
|
||||
for turn in range(max_turns):
|
||||
_execution_state[session.id]['status'] = 'calling_ai'
|
||||
_execution_state[session.id]['turn'] = turn + 1
|
||||
|
||||
response = adapter.call_with_tools(
|
||||
system_prompt=system_prompt,
|
||||
messages=messages_json,
|
||||
tools=tool_definitions,
|
||||
model_override=model_override,
|
||||
)
|
||||
total_tokens_in += response.get('tokens_in', 0)
|
||||
total_tokens_out += response.get('tokens_out', 0)
|
||||
|
||||
# Check if Haiku needs to escalate to Sonnet
|
||||
if can_escalate and not escalated and self._should_escalate(response, tool_calls_log, turn):
|
||||
_logger.info("Escalating %s from Haiku → Sonnet (turn %d)", session.name, turn)
|
||||
model_override = 'claude-sonnet-4-6'
|
||||
escalated = True
|
||||
can_escalate = False
|
||||
_execution_state[session.id]['status'] = 'escalating'
|
||||
# Re-call with Sonnet
|
||||
response = adapter.call_with_tools(
|
||||
system_prompt=system_prompt,
|
||||
messages=messages_json,
|
||||
tools=tool_definitions,
|
||||
model_override=model_override,
|
||||
)
|
||||
total_tokens_in += response.get('tokens_in', 0)
|
||||
total_tokens_out += response.get('tokens_out', 0)
|
||||
|
||||
# Capture thinking text for live display
|
||||
thinking = ''
|
||||
for block in (response.get('raw_content') or []):
|
||||
if hasattr(block, 'type') and block.type == 'thinking':
|
||||
thinking = block.thinking
|
||||
break
|
||||
if thinking:
|
||||
_execution_state[session.id]['thinking'] = thinking[:500] # Truncated for live display
|
||||
|
||||
if response.get('tool_calls'):
|
||||
tool_results = []
|
||||
_execution_state[session.id]['status'] = 'calling_tools'
|
||||
|
||||
for tc in response['tool_calls']:
|
||||
tool_name = tc['name']
|
||||
tool_params = tc.get('arguments', {})
|
||||
tool_rec = tools.filtered(lambda t: t.name == tool_name)
|
||||
tier = tool_rec.tier if tool_rec else '1'
|
||||
|
||||
# Update live state: show which tool is running
|
||||
_execution_state[session.id]['tool_calls'].append({
|
||||
'name': tool_name, 'status': 'running',
|
||||
})
|
||||
|
||||
if tier == '3':
|
||||
has_pending_tier3 = True
|
||||
history_rec = self._log_match_history(
|
||||
session, tool_name, tool_params, None,
|
||||
reasoning=thinking or '',
|
||||
confidence=tc.get('confidence', 0.0),
|
||||
tier='3',
|
||||
)
|
||||
tool_results.append({
|
||||
'tool_call_id': tc.get('id', ''),
|
||||
'result': json.dumps({
|
||||
'status': 'pending_approval',
|
||||
'match_history_id': history_rec.id,
|
||||
'message': f'Action requires user approval. Match history ID: {history_rec.id}',
|
||||
}),
|
||||
})
|
||||
tool_calls_log.append({
|
||||
'name': tool_name,
|
||||
'tier': tier,
|
||||
'status': 'pending_approval',
|
||||
'summary': self._build_tool_call_summary(tool_name, tool_params, None),
|
||||
})
|
||||
_execution_state[session.id]['tool_calls'][-1]['status'] = 'pending'
|
||||
else:
|
||||
t0 = time.time()
|
||||
result = self._execute_tool(tool_name, tool_params, session.id)
|
||||
elapsed = round((time.time() - t0) * 1000)
|
||||
self._log_match_history(
|
||||
session, tool_name, tool_params, result,
|
||||
reasoning=thinking or '',
|
||||
tier=tier,
|
||||
)
|
||||
tool_results.append({
|
||||
'tool_call_id': tc.get('id', ''),
|
||||
'result': json.dumps(result) if not isinstance(result, str) else result,
|
||||
})
|
||||
tc_status = 'error' if isinstance(result, dict) and result.get('error') else 'ok'
|
||||
tc_summary = self._build_tool_call_summary(tool_name, tool_params, result)
|
||||
|
||||
# Capture reconciliation data for direct frontend rendering
|
||||
if tool_name == 'suggest_bank_line_matches' and tc_status == 'ok':
|
||||
reconciliation_data = result
|
||||
tool_calls_log.append({
|
||||
'name': tool_name,
|
||||
'tier': tier,
|
||||
'status': tc_status,
|
||||
'summary': tc_summary,
|
||||
'duration_ms': elapsed,
|
||||
})
|
||||
# Update live state
|
||||
_execution_state[session.id]['tool_calls'][-1].update({
|
||||
'status': tc_status, 'summary': tc_summary, 'duration_ms': elapsed,
|
||||
})
|
||||
try:
|
||||
self._check_rule_proposal(tool_name, tool_params, session)
|
||||
except Exception:
|
||||
_logger.exception("Error in _check_rule_proposal for tool %s", tool_name)
|
||||
|
||||
messages_json = adapter.append_tool_results(
|
||||
messages_json, response, tool_results,
|
||||
)
|
||||
session.write({'tool_call_count': session.tool_call_count + len(tool_results)})
|
||||
|
||||
# C2: Short-circuit loop when Tier 3 actions are pending —
|
||||
# force a final text response so the AI can present approval cards
|
||||
if has_pending_tier3:
|
||||
try:
|
||||
response = adapter.call_with_tools(
|
||||
system_prompt=system_prompt,
|
||||
messages=messages_json,
|
||||
tools=[],
|
||||
model_override=model_override,
|
||||
)
|
||||
total_tokens_in += response.get('tokens_in', 0)
|
||||
total_tokens_out += response.get('tokens_out', 0)
|
||||
messages_json.append({
|
||||
'role': 'assistant',
|
||||
'content': response.get('text', 'I have proposed actions that require your approval.'),
|
||||
})
|
||||
except Exception:
|
||||
messages_json.append({
|
||||
'role': 'assistant',
|
||||
'content': 'I have proposed actions that require your approval. Please review the pending items above.',
|
||||
})
|
||||
break
|
||||
else:
|
||||
assistant_text = response.get('text', '')
|
||||
messages_json.append({'role': 'assistant', 'content': assistant_text})
|
||||
break
|
||||
else:
|
||||
# Loop exhausted — force a final text response without tools
|
||||
try:
|
||||
response = adapter.call_with_tools(
|
||||
system_prompt=system_prompt,
|
||||
messages=messages_json,
|
||||
tools=[],
|
||||
model_override=model_override,
|
||||
)
|
||||
total_tokens_in += response.get('tokens_in', 0)
|
||||
total_tokens_out += response.get('tokens_out', 0)
|
||||
messages_json.append({
|
||||
'role': 'assistant',
|
||||
'content': response.get('text', 'I reached the maximum number of steps. Please continue the conversation.'),
|
||||
})
|
||||
except Exception:
|
||||
_logger.warning("Failed to get final summary after max tool calls")
|
||||
|
||||
session.write({
|
||||
'message_ids_json': json.dumps(messages_json),
|
||||
'token_count_in': session.token_count_in + total_tokens_in,
|
||||
'token_count_out': session.token_count_out + total_tokens_out,
|
||||
'ai_provider': provider,
|
||||
'ai_model': model_override or adapter._get_model_name(),
|
||||
})
|
||||
|
||||
pending = self.env['fusion.accounting.match.history'].search([
|
||||
('session_id', '=', session.id),
|
||||
('decision', '=', 'pending'),
|
||||
])
|
||||
|
||||
# Clear live execution state
|
||||
_execution_state.pop(session.id, None)
|
||||
|
||||
# Add escalation marker to tool calls log if it happened
|
||||
if escalated:
|
||||
tool_calls_log.insert(0, {
|
||||
'name': 'model_escalation',
|
||||
'tier': '-',
|
||||
'status': 'ok',
|
||||
'summary': 'Escalated from Haiku to Sonnet for deeper analysis',
|
||||
'duration_ms': 0,
|
||||
})
|
||||
|
||||
result_payload = {
|
||||
'text': response.get('text', ''),
|
||||
'tool_calls_log': tool_calls_log,
|
||||
'pending_approvals': [self._format_pending_approval(p) for p in pending],
|
||||
'session_id': session.id,
|
||||
'model_used': model_override or adapter._get_model_name(),
|
||||
}
|
||||
|
||||
# Attach raw reconciliation data so frontend renders it directly
|
||||
# (instead of relying on AI to format fusion-table JSON correctly)
|
||||
if reconciliation_data:
|
||||
result_payload['reconciliation_table'] = reconciliation_data
|
||||
|
||||
return result_payload
|
||||
|
||||
def _build_tool_call_summary(self, tool_name, params, result):
|
||||
"""Build a one-line summary of what a tool call did, for the collapsed tool log."""
|
||||
try:
|
||||
# Result-based summaries (when we have output)
|
||||
if result and isinstance(result, dict) and not result.get('error'):
|
||||
count = result.get('count')
|
||||
status = result.get('status')
|
||||
if status == 'created':
|
||||
name = result.get('name', '')
|
||||
return f"Created {name}" if name else "Created successfully"
|
||||
if status == 'matched':
|
||||
return "Matched successfully"
|
||||
if count is not None:
|
||||
return f"Found {count} result{'s' if count != 1 else ''}"
|
||||
if 'balance' in result:
|
||||
return f"Balance: ${result['balance']:,.2f}"
|
||||
if 'total' in result:
|
||||
return f"Total: ${result['total']:,.2f}"
|
||||
if 'entries' in result:
|
||||
return f"Found {len(result['entries'])} entries"
|
||||
if 'accounts' in result:
|
||||
return f"Found {len(result['accounts'])} accounts"
|
||||
if status:
|
||||
return str(status)
|
||||
|
||||
if result and isinstance(result, dict) and result.get('error'):
|
||||
err = str(result['error'])
|
||||
return f"Error: {err[:80]}"
|
||||
|
||||
# Params-based summaries (for pending approvals, no result yet)
|
||||
if params:
|
||||
ref = params.get('ref', params.get('reference', params.get('name', '')))
|
||||
amount = params.get('amount')
|
||||
lines = params.get('lines', [])
|
||||
if lines:
|
||||
total = sum(l.get('debit', 0) for l in lines)
|
||||
return f"{ref} — ${total:,.2f}" if ref else f"${total:,.2f} journal entry"
|
||||
if ref and amount:
|
||||
return f"{ref} — ${abs(amount):,.2f}"
|
||||
if ref:
|
||||
return str(ref)
|
||||
|
||||
return "Completed"
|
||||
except Exception:
|
||||
return "Completed"
|
||||
|
||||
def _format_pending_approval(self, history):
|
||||
"""Build a rich approval payload so the UI can show exactly what's being approved."""
|
||||
params = {}
|
||||
try:
|
||||
params = json.loads(history.tool_params or '{}')
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Extract amount from params — look in common locations
|
||||
amount = history.amount or 0.0
|
||||
if not amount:
|
||||
# Try to compute from journal entry lines
|
||||
lines = params.get('lines', [])
|
||||
if lines:
|
||||
amount = sum(l.get('debit', 0) for l in lines)
|
||||
# Or from direct amount field
|
||||
if not amount:
|
||||
amount = abs(params.get('amount', 0))
|
||||
|
||||
# Build a human-readable summary of what this action will do
|
||||
summary = self._build_approval_summary(history.tool_name, params)
|
||||
|
||||
return {
|
||||
'id': history.id,
|
||||
'tool_name': history.tool_name,
|
||||
'params': history.tool_params,
|
||||
'reasoning': history.ai_reasoning,
|
||||
'confidence': history.ai_confidence,
|
||||
'amount': amount,
|
||||
'summary': summary,
|
||||
}
|
||||
|
||||
def _resolve_account_label(self, account_id):
|
||||
"""Resolve an account ID to 'code - name' for display."""
|
||||
if not account_id:
|
||||
return '?'
|
||||
try:
|
||||
acct = self.env['account.account'].browse(int(account_id))
|
||||
if acct.exists():
|
||||
return f"{acct.code} {acct.name}"
|
||||
except Exception:
|
||||
pass
|
||||
return str(account_id)
|
||||
|
||||
def _build_approval_summary(self, tool_name, params):
|
||||
"""Generate a short human-readable description of what a Tier 3 action will do."""
|
||||
try:
|
||||
if tool_name == 'create_payroll_journal_entry':
|
||||
ref = params.get('ref', 'Payroll Entry')
|
||||
date = params.get('date', '?')
|
||||
lines = params.get('lines', [])
|
||||
total = sum(l.get('debit', 0) for l in lines)
|
||||
acct_names = []
|
||||
for l in lines:
|
||||
aid = l.get('account_id', '')
|
||||
acct_label = self._resolve_account_label(aid)
|
||||
if l.get('debit'):
|
||||
acct_names.append(f"Dr {acct_label}: ${l['debit']:,.2f}")
|
||||
elif l.get('credit'):
|
||||
acct_names.append(f"Cr {acct_label}: ${l['credit']:,.2f}")
|
||||
detail = ' / '.join(acct_names) if acct_names else ''
|
||||
return f"{ref} on {date} — ${total:,.2f}\n{detail}"
|
||||
|
||||
elif tool_name == 'create_vendor_bill':
|
||||
partner = params.get('partner_name', params.get('partner_id', '?'))
|
||||
amount = params.get('amount', 0)
|
||||
ref = params.get('ref', params.get('reference', ''))
|
||||
date = params.get('date', '?')
|
||||
return f"Vendor bill for {partner} — ${abs(amount):,.2f} on {date}" + (f" ({ref})" if ref else "")
|
||||
|
||||
elif tool_name == 'register_bill_payment':
|
||||
bill_id = params.get('bill_id', '?')
|
||||
amount = params.get('amount', 0)
|
||||
journal = params.get('journal_id', '?')
|
||||
return f"Pay bill #{bill_id} — ${abs(amount):,.2f} from journal {journal}"
|
||||
|
||||
elif tool_name == 'create_expense_entry':
|
||||
ref = params.get('ref', params.get('memo', 'Expense'))
|
||||
amount = params.get('amount', 0)
|
||||
account = params.get('expense_account_id', '?')
|
||||
return f"{ref} — ${abs(amount):,.2f} to account {account}"
|
||||
|
||||
elif tool_name == 'register_hst_payment':
|
||||
amount = params.get('amount', 0)
|
||||
date = params.get('date', '?')
|
||||
return f"HST remittance — ${abs(amount):,.2f} on {date}"
|
||||
|
||||
elif tool_name in ('apply_payment', 'send_followup', 'create_payment_reminder'):
|
||||
partner = params.get('partner_name', params.get('partner_id', '?'))
|
||||
amount = params.get('amount', 0)
|
||||
return f"{tool_name.replace('_', ' ').title()} for {partner}" + (f" — ${abs(amount):,.2f}" if amount else "")
|
||||
|
||||
elif tool_name == 'flag_entry':
|
||||
move_id = params.get('move_id', '?')
|
||||
reason = params.get('reason', '')
|
||||
return f"Flag entry #{move_id}" + (f": {reason}" if reason else "")
|
||||
|
||||
else:
|
||||
# Generic fallback: show key params
|
||||
parts = []
|
||||
for key in ('ref', 'reference', 'name', 'partner_name', 'date', 'move_id'):
|
||||
if key in params:
|
||||
parts.append(f"{key}: {params[key]}")
|
||||
if 'amount' in params:
|
||||
parts.append(f"${abs(params['amount']):,.2f}")
|
||||
return ' | '.join(parts) if parts else json.dumps(params)[:120]
|
||||
|
||||
except Exception:
|
||||
return str(params)[:120]
|
||||
|
||||
def approve_action(self, match_history_id):
|
||||
history = self.env['fusion.accounting.match.history'].browse(match_history_id)
|
||||
if not history.exists() or history.decision != 'pending':
|
||||
raise UserError(_("Action not found or already decided."))
|
||||
|
||||
params = json.loads(history.tool_params or '{}')
|
||||
result = self._execute_tool(history.tool_name, params, history.session_id.id)
|
||||
|
||||
history.write({
|
||||
'decision': 'approved',
|
||||
'decided_at': fields.Datetime.now(),
|
||||
'decided_by': self.env.user.id,
|
||||
'tool_result': json.dumps(result) if not isinstance(result, str) else result,
|
||||
})
|
||||
if history.rule_id:
|
||||
history.rule_id.sudo()._record_decision(approved=True)
|
||||
|
||||
# C1: Update session messages_json so next chat turn has coherent history
|
||||
self._update_session_after_decision(history, result)
|
||||
|
||||
# M8: Trigger promotion check after approval
|
||||
try:
|
||||
self.env['fusion.accounting.scoring'].check_promotions()
|
||||
except Exception:
|
||||
_logger.exception("Error checking promotions after approval")
|
||||
|
||||
return result
|
||||
|
||||
def _check_rule_proposal(self, tool_name, params, session):
|
||||
"""Detect repeated patterns and propose new rules when 3+ identical matches."""
|
||||
recent = self.env['fusion.accounting.match.history'].search([
|
||||
('tool_name', '=', tool_name),
|
||||
('decision', 'in', ('approved', 'auto')),
|
||||
('company_id', '=', self.env.company.id),
|
||||
], limit=20, order='proposed_at desc')
|
||||
|
||||
if len(recent) < 3:
|
||||
return
|
||||
|
||||
from collections import Counter
|
||||
param_keys = []
|
||||
for h in recent:
|
||||
try:
|
||||
p = json.loads(h.tool_params or '{}')
|
||||
key_parts = []
|
||||
for k in sorted(p.keys()):
|
||||
if k not in ('limit', 'date_from', 'date_to'):
|
||||
key_parts.append(f'{k}={json.dumps(p[k], sort_keys=True)}')
|
||||
if key_parts:
|
||||
param_keys.append('|'.join(key_parts))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
counts = Counter(param_keys)
|
||||
for pattern, count in counts.most_common(3):
|
||||
if count < 3:
|
||||
break
|
||||
existing = self.env['fusion.accounting.rule'].search([
|
||||
('match_logic', 'ilike', pattern[:50]),
|
||||
('company_id', '=', self.env.company.id),
|
||||
], limit=1)
|
||||
if existing:
|
||||
continue
|
||||
|
||||
self.env['fusion.accounting.rule'].create({
|
||||
'name': f'AI Pattern: {tool_name} ({pattern[:40]})',
|
||||
'rule_type': 'match',
|
||||
'description': f'Automatically detected pattern from {count} approved uses of {tool_name}.',
|
||||
'match_logic': f'When using {tool_name} with parameters matching: {pattern}',
|
||||
'created_by': 'ai',
|
||||
'approval_tier': 'needs_approval',
|
||||
'company_id': self.env.company.id,
|
||||
})
|
||||
_logger.info("AI proposed rule for pattern: %s (%d matches)", tool_name, count)
|
||||
|
||||
def reject_action(self, match_history_id, reason=''):
|
||||
history = self.env['fusion.accounting.match.history'].browse(match_history_id)
|
||||
if not history.exists() or history.decision != 'pending':
|
||||
raise UserError(_("Action not found or already decided."))
|
||||
|
||||
history.write({
|
||||
'decision': 'rejected',
|
||||
'decided_at': fields.Datetime.now(),
|
||||
'decided_by': self.env.user.id,
|
||||
'rejection_reason': reason,
|
||||
})
|
||||
if history.rule_id:
|
||||
history.rule_id.sudo()._record_decision(approved=False)
|
||||
|
||||
# C1: Update session messages_json so next chat turn has coherent history
|
||||
reject_result = {'status': 'rejected', 'reason': reason}
|
||||
self._update_session_after_decision(history, reject_result)
|
||||
|
||||
return reject_result
|
||||
|
||||
def _update_session_after_decision(self, history, result):
|
||||
"""Update session messages_json to replace pending_approval placeholder
|
||||
with actual tool result, preventing dangling tool_use blocks."""
|
||||
session = history.session_id
|
||||
if not session or not session.message_ids_json:
|
||||
return
|
||||
try:
|
||||
messages = json.loads(session.message_ids_json)
|
||||
result_str = json.dumps(result) if not isinstance(result, str) else result
|
||||
updated = False
|
||||
for msg in messages:
|
||||
if msg.get('role') != 'user':
|
||||
continue
|
||||
content = msg.get('content')
|
||||
if isinstance(content, list):
|
||||
for block in content:
|
||||
if (isinstance(block, dict) and block.get('type') == 'tool_result'
|
||||
and 'pending_approval' in str(block.get('content', ''))):
|
||||
# Check if this is the matching tool_result block
|
||||
if str(history.id) in str(block.get('content', '')):
|
||||
block['content'] = result_str
|
||||
updated = True
|
||||
break
|
||||
if updated:
|
||||
break
|
||||
if updated:
|
||||
session.write({'message_ids_json': json.dumps(messages)})
|
||||
except Exception:
|
||||
_logger.warning("Failed to update session messages after decision for history %s", history.id)
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# Cron: Auto-Reconcile Inter-Account Transfers
|
||||
# ----------------------------------------------------------------
|
||||
@api.model
|
||||
def _cron_reconcile_transfers(self):
|
||||
"""Automatically reconcile inter-account credit card payments.
|
||||
|
||||
When a payment is made from a bank account (e.g. Scotia Current) to a
|
||||
credit card (e.g. Scotia Passport Visa), two bank statement lines appear:
|
||||
- Source side: "MB-CREDIT CARD" (negative) — reconciled by model 38/35
|
||||
- CC side: "PAYMENT FROM *7814" (positive) — needs matching
|
||||
|
||||
The source-side reconciliation creates outstanding entries on account 493.
|
||||
This cron matches the CC-side lines against those outstanding entries by
|
||||
exact amount and closest date (within 3 days).
|
||||
"""
|
||||
AML = self.env['account.move.line'].sudo()
|
||||
BSL = self.env['account.bank.statement.line'].sudo()
|
||||
company_partner_id = self.env.company.partner_id.id
|
||||
|
||||
total_reconciled = 0
|
||||
|
||||
for source_jid, cc_jid, outstanding_acct_id in TRANSFER_PAIRS:
|
||||
# Find all unreconciled INCOMING lines on the credit card journal
|
||||
cc_lines = BSL.search([
|
||||
('journal_id', '=', cc_jid),
|
||||
('is_reconciled', '=', False),
|
||||
('amount', '>', 0), # Incoming payments only
|
||||
('company_id', '=', self.env.company.id),
|
||||
])
|
||||
if not cc_lines:
|
||||
continue
|
||||
|
||||
journal_name = cc_lines[0].journal_id.name
|
||||
_logger.info(
|
||||
"Transfer reconcile: %s — %d incoming unreconciled lines",
|
||||
journal_name, len(cc_lines),
|
||||
)
|
||||
|
||||
reconciled = 0
|
||||
skipped = 0
|
||||
|
||||
for line in cc_lines:
|
||||
line_date = line.move_id.date
|
||||
amount = line.amount
|
||||
|
||||
# Find outstanding entries with exact matching amount
|
||||
candidates = AML.search([
|
||||
('account_id', '=', outstanding_acct_id),
|
||||
('partner_id', '=', company_partner_id),
|
||||
('reconciled', '=', False),
|
||||
('amount_residual', '=', amount),
|
||||
])
|
||||
|
||||
if not candidates:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# Pick the candidate closest in date (within 3 days)
|
||||
best = None
|
||||
best_gap = 999
|
||||
for c in candidates:
|
||||
gap = abs((c.date - line_date).days)
|
||||
if gap < best_gap:
|
||||
best_gap = gap
|
||||
best = c
|
||||
|
||||
if best_gap > 7:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# Set partner and reconcile
|
||||
try:
|
||||
line.partner_id = company_partner_id
|
||||
line.set_line_bank_statement_line(best.ids)
|
||||
reconciled += 1
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
"Transfer reconcile failed: line %s (%s, $%.2f): %s",
|
||||
line.id, line.payment_ref, amount, e,
|
||||
)
|
||||
|
||||
# Commit every 50 lines to avoid long transactions
|
||||
if reconciled % 50 == 0 and reconciled > 0:
|
||||
self.env.cr.commit()
|
||||
|
||||
self.env.cr.commit()
|
||||
total_reconciled += reconciled
|
||||
_logger.info(
|
||||
"Transfer reconcile: %s — reconciled %d, skipped %d",
|
||||
journal_name, reconciled, skipped,
|
||||
)
|
||||
|
||||
_logger.info("Transfer reconcile complete: %d total reconciled", total_reconciled)
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# One-time: Match payroll cheque bank lines against open payroll liability entries
|
||||
# ----------------------------------------------------------------
|
||||
@api.model
|
||||
def _reconcile_payroll_cheques(self):
|
||||
"""Reconcile payroll cheque bank lines using writeoff to Payroll Liabilities (2201).
|
||||
|
||||
Your payroll JEs post:
|
||||
Dr Salaries / Dr ER CPP-EI / Dr CRA Taxes
|
||||
Cr 2201 Payroll Liabilities (net pay = cheque amount)
|
||||
|
||||
When the cheque clears the bank, the bank line shows:
|
||||
"Cheque 1773 : Cheque" = -$1,477.95
|
||||
|
||||
This method finds cheque bank lines that have a matching payroll liability
|
||||
entry (same amount) and applies a reconcile model that writes off to account
|
||||
433 (Payroll Liabilities). This debits 433 to clear the liability.
|
||||
|
||||
Non-payroll cheques (no matching entry on 433) are skipped.
|
||||
"""
|
||||
PAYROLL_LIABILITY_ACCT_ID = 433 # code 2201
|
||||
SCOTIA_CURRENT_JOURNAL_ID = 50
|
||||
|
||||
AML = self.env['account.move.line'].sudo()
|
||||
BSL = self.env['account.bank.statement.line'].sudo()
|
||||
RecModel = self.env['account.reconcile.model'].sudo()
|
||||
|
||||
# Find the payroll cheque reconcile model (must be pre-created via XML or manually)
|
||||
model = RecModel.search([
|
||||
('name', 'ilike', 'Payroll Cheque'),
|
||||
('company_id', '=', self.env.company.id),
|
||||
], limit=1)
|
||||
|
||||
if not model:
|
||||
_logger.warning("Payroll cheque reconcile: no 'Payroll Cheque' model found — create one manually")
|
||||
return
|
||||
|
||||
# Find all unreconciled cheque lines on Scotia Current (negative = outgoing)
|
||||
# Only process lines after lock date to avoid lock date errors
|
||||
cheque_lines = BSL.search([
|
||||
('journal_id', '=', SCOTIA_CURRENT_JOURNAL_ID),
|
||||
('is_reconciled', '=', False),
|
||||
('amount', '<', 0),
|
||||
('payment_ref', 'ilike', 'cheque'),
|
||||
('company_id', '=', self.env.company.id),
|
||||
], order='move_id asc')
|
||||
|
||||
# Filter to post-lock-date lines only
|
||||
lock_date = self.env.company.fiscalyear_lock_date
|
||||
if lock_date:
|
||||
cheque_lines = cheque_lines.filtered(lambda l: l.move_id.date > lock_date)
|
||||
|
||||
_logger.info("Payroll cheque reconcile: found %d unreconciled cheque lines (post lock date)", len(cheque_lines))
|
||||
|
||||
# Build set of all known payroll liability credit amounts
|
||||
payroll_credit_amounts = set()
|
||||
for aml in AML.search([
|
||||
('account_id', '=', PAYROLL_LIABILITY_ACCT_ID),
|
||||
('parent_state', '=', 'posted'),
|
||||
('credit', '>', 0),
|
||||
]):
|
||||
payroll_credit_amounts.add(round(aml.credit, 2))
|
||||
|
||||
# Filter: only reconcile cheques that have a matching payroll liability entry
|
||||
payroll_lines = cheque_lines.filtered(
|
||||
lambda l: round(abs(l.amount), 2) in payroll_credit_amounts
|
||||
)
|
||||
|
||||
_logger.info(
|
||||
"Payroll cheque reconcile: %d payroll, %d non-payroll (skipped)",
|
||||
len(payroll_lines), len(cheque_lines) - len(payroll_lines),
|
||||
)
|
||||
|
||||
if not payroll_lines:
|
||||
_logger.info("Payroll cheque reconcile: nothing to reconcile")
|
||||
return
|
||||
|
||||
# Apply the reconcile model to payroll cheque lines
|
||||
try:
|
||||
model._apply_reconcile_models(payroll_lines)
|
||||
self.env.cr.commit()
|
||||
except Exception as e:
|
||||
_logger.exception("Payroll cheque reconcile batch failed: %s", e)
|
||||
self.env.cr.rollback()
|
||||
return
|
||||
|
||||
# Count results
|
||||
still_unreconciled = payroll_lines.filtered(lambda l: not l.is_reconciled)
|
||||
reconciled = len(payroll_lines) - len(still_unreconciled)
|
||||
|
||||
for line in still_unreconciled[:10]:
|
||||
_logger.info("Payroll cheque still unreconciled: %s $%.2f", line.payment_ref, abs(line.amount))
|
||||
|
||||
_logger.info(
|
||||
"Payroll cheque reconcile complete: %d reconciled, %d still unreconciled",
|
||||
reconciled, len(still_unreconciled),
|
||||
)
|
||||
@@ -1,2 +0,0 @@
|
||||
from . import system_prompt
|
||||
from . import domain_prompts
|
||||
@@ -1,237 +0,0 @@
|
||||
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.
|
||||
|
||||
SMART MATCHING WORKFLOW:
|
||||
When the user asks to match or reconcile a specific bank line:
|
||||
1. Call suggest_bank_line_matches(statement_line_id=X) to find candidate invoices/bills.
|
||||
2. Present the results as a reconciliation-mode fusion-table. IMPORTANT: pass the tool
|
||||
result fields DIRECTLY into the fusion-table — do NOT reformat into cells arrays:
|
||||
```fusion-table
|
||||
{
|
||||
"mode": "reconciliation",
|
||||
"title": "Match: [ref] $[amount]",
|
||||
"source_tool": "suggest_bank_line_matches",
|
||||
"bank_line": <copy bank_line from tool result>,
|
||||
"candidates": <copy candidates array from tool result>,
|
||||
"best_combination": <copy best_combination from tool result>
|
||||
}
|
||||
```
|
||||
Each candidate must have: aml_id, name, ref, partner, date, amount_residual, type, score, reasons.
|
||||
Do NOT convert candidates into {"id":..., "cells":[...]} format — use the raw tool output.
|
||||
3. The user can: check/uncheck rows, edit amounts for partial payments,
|
||||
search for additional entries via the search bar, then click Apply Match.
|
||||
4. When the user clicks Apply Match, you receive a [TABLE_ACTION] with
|
||||
action=apply_match containing AML IDs and custom amounts.
|
||||
5. Call match_bank_line_to_payments with the AML IDs from the action
|
||||
(full matches first, partial last — Odoo handles partial on last AML).
|
||||
6. Partial payment: if apply_amount < amount_residual, it's partial.
|
||||
Only ONE AML can be partial (the last one). Odoo leaves the residual open.
|
||||
|
||||
Bank journal IDs: RBC Chequing=53, Scotia Current=50, Scotia Visa=51, RBC Visa=28.
|
||||
""",
|
||||
|
||||
'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.
|
||||
- HST Purchase tax ID is 20 (13%). No Tax Purchase ID is 32 (0%).
|
||||
|
||||
HST FILING WORKFLOW (4 phases — follow this order):
|
||||
|
||||
PHASE 1 — REPORTS: Run all at once:
|
||||
calculate_hst_balance, get_tax_report, find_missing_itc_bills,
|
||||
find_missing_tax_invoices, audit_tax_compliance.
|
||||
Present summary with HST position (owing vs refund).
|
||||
|
||||
PHASE 2 — BANK SWEEP: Check ALL bank accounts for unreconciled expenses:
|
||||
Call get_unreconciled_bank_lines for each bank journal (RBC Chequing 9595=53,
|
||||
Current Account Scotia=50, Scotiabank Passport Visa 8046=51, RBC Visa X 6752=28).
|
||||
Present ALL unreconciled expense lines (negative amounts) as a fusion-table
|
||||
with your recommendation per row.
|
||||
|
||||
PHASE 3 — PER-LINE PROCESSING: For each flagged expense line:
|
||||
0. FIRST: check_recurring_pattern(line_id=X) — if match found, follow action_note
|
||||
instructions EXACTLY (account, HST, partner, reconcile model). No user input needed
|
||||
for recurring payments. If a reconcile_model_id is returned, use apply_reconcile_model.
|
||||
1. get_bank_line_details — check if a vendor bill already exists for same amount/date
|
||||
2. find_similar_bank_lines — check history AND vendor_tax_pattern for coding/tax pattern
|
||||
3. CRITICAL: Check vendor_tax_pattern.is_po_vendor flag:
|
||||
- If is_po_vendor=true: This vendor's bills come from Purchase Orders. Do NOT create
|
||||
a new bill. Instead, use get_unpaid_bills to find the existing bill and propose
|
||||
match_bank_line_to_payments to match the bank payment to that bill.
|
||||
- If is_po_vendor=false: Proceed with bill creation workflow below.
|
||||
4. If bill already exists → propose match_bank_line_to_payments
|
||||
5. If no bill but history match → propose create_vendor_bill with same coding pattern
|
||||
6. If no bill and no history → ask user: "Does this expense include HST?"
|
||||
7. search_partners — find the vendor by keyword from the bank description
|
||||
8. Once confirmed → create_vendor_bill + register_bill_payment (Tier 3, needs approval)
|
||||
9. Alternative: user can choose "Direct GL" → create_expense_entry (Tier 3)
|
||||
For expenses that obviously have no HST (bank fees, interest charges, insurance),
|
||||
proactively recommend "No HST" and explain why.
|
||||
|
||||
PO-TRACKED VENDORS (do NOT create bills for these — bills come from Purchase Orders):
|
||||
When find_similar_bank_lines returns is_po_vendor=true or the vendor_tax_pattern
|
||||
note starts with "PO-TRACKED VENDOR", the bill already exists or will be created
|
||||
from a PO. Your job is ONLY to find the existing unpaid bill and match the bank
|
||||
payment to it. If no unpaid bill exists, flag it for the user: "This is a PO vendor
|
||||
but no matching bill was found — the PO may not have been billed yet."
|
||||
|
||||
PHASE 4 — VERIFICATION: Re-run calculate_hst_balance and get_tax_report
|
||||
to show the updated HST position after all expenses are recorded.
|
||||
|
||||
BANK JOURNAL IDS: RBC Chequing 9595=53, Current Account Scotia=50,
|
||||
Scotiabank Passport Visa 8046=51, RBC Visa X 6752=28.
|
||||
MISC JOURNAL: ID=3 (for direct GL expense entries).
|
||||
""",
|
||||
|
||||
'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 (ASSISTIVE DEVICE PROGRAM) RECONCILIATION CONTEXT:
|
||||
- ADP Receivable tracked on account 1101.
|
||||
- ADP invoices have customer portion + ADP portion = total.
|
||||
- Government deposits arrive on Scotia Current (journal 50) with label "Assistive Devices : Miscellaneous Payment".
|
||||
- ADP partner in Odoo: "ADP (Assistive Device Program)" (id 3421).
|
||||
|
||||
ADP PAYMENT MATCHING WORKFLOW:
|
||||
1. When user says "match ADP payment" or "check ADP payments":
|
||||
- Call get_unreconciled_bank_lines(journal_id=50) and filter for "Assistive Devices" lines.
|
||||
- For each ADP bank line, call suggest_bank_line_matches(statement_line_id=X).
|
||||
- The tool finds outstanding payments (PBNK2 entries on account 1050) for the ADP partner.
|
||||
- Present as reconciliation fusion-table.
|
||||
|
||||
2. When user uploads an ADP remittance advice image:
|
||||
- Read the image. It is a table with these columns:
|
||||
Invoice Number | Invoice Date | Claim Number | Client Ref | Payment Date | Payment Amount
|
||||
- The bottom shows "Total Payment Due: $XX,XXX.XX" — this is the bank deposit amount.
|
||||
- Extract every row: invoice number and payment amount.
|
||||
- Find the bank line on Scotia Current matching the total amount.
|
||||
- Call suggest_bank_line_matches for that bank line.
|
||||
- The outstanding payments on 1050 should sum to the total.
|
||||
|
||||
3. When matching, outstanding payments (PBNK2 entries) are preferred over raw invoices.
|
||||
Each PBNK2 entry represents a registered payment batch. Two or more PBNK2 entries
|
||||
may combine to equal the bank deposit total.
|
||||
""",
|
||||
|
||||
'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.
|
||||
""",
|
||||
}
|
||||
|
||||
|
||||
# A3/A5: Aliases so common domain variations still match a prompt
|
||||
DOMAIN_ALIASES = {
|
||||
'bank': 'bank_reconciliation',
|
||||
'bank_recon': 'bank_reconciliation',
|
||||
'hst': 'hst_management',
|
||||
'gst': 'hst_management',
|
||||
'tax': 'hst_management',
|
||||
'ar': 'accounts_receivable',
|
||||
'receivable': 'accounts_receivable',
|
||||
'ap': 'accounts_payable',
|
||||
'payable': 'accounts_payable',
|
||||
'journal': 'journal_review',
|
||||
'close': 'month_end',
|
||||
'month_end_close': 'month_end',
|
||||
'payroll': 'payroll_management',
|
||||
'payroll_verify': 'payroll_verification',
|
||||
'stock': 'inventory',
|
||||
'cogs': 'inventory',
|
||||
'report': 'reporting',
|
||||
'reports': 'reporting',
|
||||
'financial': 'reporting',
|
||||
}
|
||||
|
||||
|
||||
def get_domain_prompt(domain):
|
||||
if not domain:
|
||||
return ''
|
||||
# Try exact match first, then aliases
|
||||
prompt = DOMAIN_PROMPTS.get(domain, '')
|
||||
if not prompt:
|
||||
resolved = DOMAIN_ALIASES.get(domain, domain)
|
||||
prompt = DOMAIN_PROMPTS.get(resolved, '')
|
||||
return prompt
|
||||
@@ -1,188 +0,0 @@
|
||||
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 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.
|
||||
|
||||
INTERACTIVE TABLES (fusion-table) — MANDATORY FOR ACTIONABLE DATA:
|
||||
IMPORTANT: When a tool returns a list of records that the user could act on, you MUST use
|
||||
a ```fusion-table block instead of a Markdown table. This is REQUIRED — never use plain
|
||||
Markdown tables for actionable data. The fusion-table renders an interactive widget with
|
||||
checkboxes, your AI recommendations per row, user input fields, and bulk action buttons.
|
||||
|
||||
YOU MUST USE fusion-table FOR: missing ITCs/tax (find_missing_itc_bills, find_missing_tax_invoices),
|
||||
duplicate entries (find_duplicate_bills, find_duplicate_entries), overdue invoices (get_overdue_invoices),
|
||||
unreconciled lines (get_unreconciled_bank_lines, get_unreconciled_receipts, get_unmatched_payments,
|
||||
find_unreconciled_suspense), draft entries (find_draft_entries), wrong balances
|
||||
(find_wrong_direction_balances), sequence gaps (find_sequence_gaps), wrong accounts
|
||||
(find_wrong_account_entries), unpaid bills (get_unpaid_bills), and any other list where
|
||||
the user needs to review, dismiss, flag, or create rules for individual rows.
|
||||
|
||||
USE REGULAR MARKDOWN TABLES ONLY FOR: P&L (get_profit_loss), balance sheet (get_balance_sheet),
|
||||
trial balance (get_trial_balance), cash flow (get_cash_flow), period summaries, tax reports,
|
||||
and any purely informational/read-only data where there is nothing to act on per row.
|
||||
|
||||
Format: wrap a JSON object in a ```fusion-table fenced code block:
|
||||
|
||||
```fusion-table
|
||||
{
|
||||
"mode": "interactive",
|
||||
"title": "Descriptive Title",
|
||||
"columns": ["Col1", "Col2", "Col3"],
|
||||
"rows": [
|
||||
{"id": 123, "cells": ["val1", "val2", "val3"], "recommendation": {"action": "dismiss", "reason": "Brief explanation"}},
|
||||
{"id": 456, "cells": ["val1", "val2", "val3"], "recommendation": {"action": "flag", "reason": "Brief explanation"}}
|
||||
],
|
||||
"actions": ["dismiss", "flag", "create_rule"],
|
||||
"source_tool": "tool_name_that_produced_this"
|
||||
}
|
||||
```
|
||||
|
||||
- "mode": "interactive" (actionable) or "readonly" (informational but structured)
|
||||
- "id": the Odoo record ID (account.move id, account.bank.statement.line id, etc.)
|
||||
- "recommendation.action": one of "dismiss", "flag", "create_rule"
|
||||
- "recommendation.reason": short explanation of why you recommend this action
|
||||
- "actions": which bulk action buttons to show
|
||||
- "source_tool": the tool name that produced the data
|
||||
- You MUST provide a recommendation for each row when using interactive mode.
|
||||
- Format monetary amounts as "$X,XXX.XX" in cells.
|
||||
- Always include the record ID so actions can target the correct Odoo record.
|
||||
- Add a brief text summary before or after the fusion-table block for context.
|
||||
|
||||
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.
|
||||
|
||||
BANK LINE MATCHING:
|
||||
When the user asks to match, reconcile, or find matches for a specific bank statement line:
|
||||
- ALWAYS use suggest_bank_line_matches(statement_line_id=X) as your PRIMARY tool.
|
||||
- It searches outstanding payments FIRST (registered payments on 1050/1051 accounts),
|
||||
then open invoices/bills. Outstanding payments are the correct match — not raw invoices.
|
||||
- Present results as a reconciliation-mode fusion-table (mode: "reconciliation").
|
||||
- Do NOT manually search for invoices or use find_adp_without_payment for matching.
|
||||
- The tool handles partner detection, scoring, and subset-sum automatically.
|
||||
- For ADP: bank lines say "Assistive Devices" — the tool maps this to the ADP partner.
|
||||
|
||||
ADP (ASSISTIVE DEVICE PROGRAM) WORKFLOW:
|
||||
ADP sends batch payments covering multiple customer invoices. The bank deposit label is
|
||||
"Assistive Devices : Miscellaneous Payment". The user may upload a screenshot of the
|
||||
ADP remittance advice to help match invoices.
|
||||
|
||||
When handling ADP payments:
|
||||
1. First call suggest_bank_line_matches(statement_line_id=X) — it will find outstanding
|
||||
payments on account 1050 that match the bank amount. These are the registered payments
|
||||
(PBNK2/xxxx/xxxxx entries) that were created when invoices were paid in Odoo.
|
||||
2. Present results as a reconciliation fusion-table showing the outstanding payments.
|
||||
3. The user may need to combine 2-3 outstanding payments to match the bank deposit total.
|
||||
|
||||
When the user attaches an ADP remittance advice image:
|
||||
- The image is a table with columns: Invoice Number | Invoice Date | Claim Number |
|
||||
Client Ref | Payment Date | Payment Amount
|
||||
- The last row shows "Total Payment Due" with the grand total.
|
||||
- Extract ALL invoice numbers and their payment amounts from the image.
|
||||
- Present a summary table of what you extracted for confirmation.
|
||||
- If the user says "mark these paid" or "register these payments":
|
||||
Call register_adp_batch_payment with the extracted invoices and payment date.
|
||||
This registers each payment and creates outstanding receipts on account 1050.
|
||||
Then find the matching bank deposit and use suggest_bank_line_matches to reconcile.
|
||||
- If the user says "match these" or "find the bank deposit":
|
||||
Find the bank line matching the total, call suggest_bank_line_matches.
|
||||
|
||||
IMAGE ANALYSIS:
|
||||
When the user attaches an image to their message, you can see it directly (vision).
|
||||
- Read all text, numbers, and tables from the image.
|
||||
- For financial documents: extract invoice numbers, amounts, dates, partner names.
|
||||
- For remittance advices: extract the line items and grand total.
|
||||
- Always confirm what you extracted before taking action.
|
||||
|
||||
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'
|
||||
conf_str = f', confidence={rule.confidence_score:.0%}, uses={rule.total_uses}' if rule.total_uses > 0 else ''
|
||||
lines.append(
|
||||
f'- [{priority}/{tier}{conf_str}] {rule.name} ({rule.rule_type}): '
|
||||
f'{rule.description or rule.match_logic or "No description"}'
|
||||
)
|
||||
if rule.match_logic:
|
||||
logic_text = rule.match_logic[:500] # Prevent prompt bloat
|
||||
lines.append(f' Match logic: {logic_text}')
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def _build_history_section(history):
|
||||
if not history:
|
||||
return ''
|
||||
lines = ['RECENT MATCH HISTORY (learn from these patterns):']
|
||||
# A4: Don't hard-cap at 50 — the caller (_load_match_history) already
|
||||
# respects the history_in_prompt config setting
|
||||
for h in history:
|
||||
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}'
|
||||
@@ -1,61 +0,0 @@
|
||||
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
|
||||
@@ -1,19 +0,0 @@
|
||||
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)
|
||||
@@ -1,406 +0,0 @@
|
||||
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]],
|
||||
}
|
||||
|
||||
|
||||
def search_partners(env, params):
|
||||
"""Search for partners/vendors by name keyword."""
|
||||
keyword = params.get('keyword', '')
|
||||
if not keyword or len(keyword) < 2:
|
||||
return {'error': 'Keyword must be at least 2 characters'}
|
||||
domain = [('name', 'ilike', keyword), ('company_id', 'in', [env.company.id, False])]
|
||||
if params.get('supplier_only'):
|
||||
domain.append(('supplier_rank', '>', 0))
|
||||
partners = env['res.partner'].search(domain, limit=int(params.get('limit', 20)))
|
||||
return {
|
||||
'count': len(partners),
|
||||
'partners': [{
|
||||
'id': p.id,
|
||||
'name': p.name,
|
||||
'supplier_rank': p.supplier_rank,
|
||||
'customer_rank': p.customer_rank,
|
||||
'vat': p.vat or '',
|
||||
'email': p.email or '',
|
||||
'phone': p.phone or '',
|
||||
} for p in partners],
|
||||
}
|
||||
|
||||
|
||||
def find_similar_bank_lines(env, params):
|
||||
"""Find past reconciled bank lines with similar description to suggest coding patterns.
|
||||
Also checks vendor bill tax patterns if a partner is identified."""
|
||||
keyword = params.get('keyword', '')
|
||||
if not keyword or len(keyword) < 3:
|
||||
return {'error': 'Keyword must be at least 3 characters'}
|
||||
# Find reconciled bank lines with matching payment_ref
|
||||
lines = env['account.bank.statement.line'].search([
|
||||
('is_reconciled', '=', True),
|
||||
('payment_ref', 'ilike', keyword),
|
||||
('company_id', '=', env.company.id),
|
||||
], order='date desc', limit=int(params.get('limit', 10)))
|
||||
|
||||
matches = []
|
||||
found_partner_id = None
|
||||
for line in lines:
|
||||
move = line.move_id
|
||||
if not move:
|
||||
continue
|
||||
expense_info = {'account_code': '', 'account_name': '', 'tax_applied': False, 'tax_amount': 0.0}
|
||||
for ml in move.line_ids:
|
||||
if ml.account_id.account_type in ('expense', 'expense_direct_cost', 'expense_depreciation'):
|
||||
expense_info['account_code'] = ml.account_id.code
|
||||
expense_info['account_name'] = ml.account_id.name
|
||||
expense_info['tax_applied'] = bool(ml.tax_ids)
|
||||
expense_info['tax_amount'] = sum(t.amount for t in ml.tax_ids) if ml.tax_ids else 0.0
|
||||
break
|
||||
if line.partner_id and not found_partner_id:
|
||||
found_partner_id = line.partner_id.id
|
||||
matches.append({
|
||||
'id': line.id,
|
||||
'date': str(line.date),
|
||||
'payment_ref': line.payment_ref or '',
|
||||
'amount': line.amount,
|
||||
'partner': line.partner_id.name if line.partner_id else '',
|
||||
'partner_id': line.partner_id.id if line.partner_id else None,
|
||||
'expense_account': expense_info['account_code'],
|
||||
'expense_account_name': expense_info['account_name'],
|
||||
'tax_applied': expense_info['tax_applied'],
|
||||
'tax_rate': expense_info['tax_amount'],
|
||||
})
|
||||
|
||||
result = {
|
||||
'keyword': keyword,
|
||||
'count': len(matches),
|
||||
'matches': matches,
|
||||
'suggestion': matches[0] if matches else None,
|
||||
}
|
||||
|
||||
# Check vendor tax profile cache first (fast), fall back to live query
|
||||
partner_id = found_partner_id or (int(params['partner_id']) if params.get('partner_id') else None)
|
||||
if partner_id:
|
||||
profile = env['fusion.vendor.tax.profile'].search([
|
||||
('partner_id', '=', partner_id),
|
||||
('company_id', '=', env.company.id),
|
||||
], limit=1)
|
||||
if profile:
|
||||
result['vendor_tax_pattern'] = {
|
||||
'source': 'cached_profile',
|
||||
'total_bills': profile.total_bills,
|
||||
'bills_with_tax': profile.bills_with_hst,
|
||||
'bills_no_tax': profile.bills_zero_rated,
|
||||
'avg_tax_pct': profile.avg_tax_pct,
|
||||
'tax_classification': profile.tax_classification,
|
||||
'tax_note': profile.tax_note,
|
||||
'primary_account_id': profile.primary_account_id.id if profile.primary_account_id else None,
|
||||
'primary_account_code': profile.primary_account_code or '',
|
||||
'is_foreign': profile.is_foreign,
|
||||
'is_po_vendor': profile.is_po_vendor,
|
||||
'po_count': profile.po_count,
|
||||
}
|
||||
else:
|
||||
# No cached profile — live query for new/small vendors
|
||||
bills = env['account.move'].search([
|
||||
('move_type', '=', 'in_invoice'), ('state', '=', 'posted'),
|
||||
('partner_id', '=', partner_id),
|
||||
], order='date desc', limit=10)
|
||||
tax_stats = {'source': 'live_query', 'total_bills': len(bills),
|
||||
'bills_with_tax': 0, 'bills_no_tax': 0,
|
||||
'avg_tax_pct': 0.0, 'tax_note': ''}
|
||||
tax_pcts = []
|
||||
for bill in bills:
|
||||
if bill.amount_tax > 0.01:
|
||||
tax_stats['bills_with_tax'] += 1
|
||||
if bill.amount_untaxed > 0:
|
||||
tax_pcts.append(round(bill.amount_tax / bill.amount_untaxed * 100, 2))
|
||||
else:
|
||||
tax_stats['bills_no_tax'] += 1
|
||||
if tax_pcts:
|
||||
tax_stats['avg_tax_pct'] = round(sum(tax_pcts) / len(tax_pcts), 2)
|
||||
if tax_stats['total_bills'] > 0:
|
||||
if tax_stats['bills_no_tax'] == tax_stats['total_bills']:
|
||||
tax_stats['tax_note'] = 'This vendor NEVER charges HST. All bills are zero-rated.'
|
||||
elif tax_stats['avg_tax_pct'] < 2.0 and tax_stats['bills_with_tax'] > 0:
|
||||
tax_stats['tax_note'] = (
|
||||
f'HST only on shipping (avg {tax_stats["avg_tax_pct"]}%). '
|
||||
f'Do NOT apply HST to full amount.'
|
||||
)
|
||||
elif tax_stats['avg_tax_pct'] >= 12.0:
|
||||
tax_stats['tax_note'] = f'Consistently charges HST at ~{tax_stats["avg_tax_pct"]}%.'
|
||||
result['vendor_tax_pattern'] = tax_stats
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def create_vendor_bill(env, params):
|
||||
"""[Tier 3] Create a vendor bill (account.move with move_type='in_invoice').
|
||||
Requires user approval before execution."""
|
||||
partner_id = int(params['partner_id'])
|
||||
invoice_date = params.get('invoice_date', str(fields.Date.today()))
|
||||
bill_lines = params.get('lines', [])
|
||||
if not bill_lines:
|
||||
return {'error': 'At least one invoice line is required'}
|
||||
|
||||
partner = env['res.partner'].browse(partner_id)
|
||||
if not partner.exists():
|
||||
return {'error': f'Partner not found: {partner_id}'}
|
||||
|
||||
invoice_line_vals = []
|
||||
for line in bill_lines:
|
||||
line_vals = {
|
||||
'name': line.get('description', 'Expense'),
|
||||
'price_unit': float(line.get('price_unit', 0)),
|
||||
'quantity': float(line.get('quantity', 1)),
|
||||
}
|
||||
if line.get('account_id'):
|
||||
line_vals['account_id'] = int(line['account_id'])
|
||||
if line.get('tax_ids'):
|
||||
line_vals['tax_ids'] = [(6, 0, [int(t) for t in line['tax_ids']])]
|
||||
invoice_line_vals.append((0, 0, line_vals))
|
||||
|
||||
try:
|
||||
bill = env['account.move'].create({
|
||||
'move_type': 'in_invoice',
|
||||
'partner_id': partner_id,
|
||||
'invoice_date': invoice_date,
|
||||
'date': invoice_date,
|
||||
'invoice_line_ids': invoice_line_vals,
|
||||
'company_id': env.company.id,
|
||||
})
|
||||
|
||||
if params.get('post', False):
|
||||
bill.action_post()
|
||||
|
||||
return {
|
||||
'status': 'created',
|
||||
'bill_id': bill.id,
|
||||
'bill_name': bill.name,
|
||||
'partner': partner.name,
|
||||
'amount_total': bill.amount_total,
|
||||
'state': bill.state,
|
||||
}
|
||||
except Exception as e:
|
||||
_logger.error("Failed to create vendor bill: %s", e)
|
||||
return {'error': str(e)}
|
||||
|
||||
|
||||
def register_bill_payment(env, params):
|
||||
"""[Tier 3] Register payment on a posted vendor bill and optionally reconcile to bank line.
|
||||
Requires user approval before execution."""
|
||||
bill_id = int(params['bill_id'])
|
||||
journal_id = int(params['journal_id'])
|
||||
bill = env['account.move'].browse(bill_id)
|
||||
if not bill.exists() or bill.state != 'posted':
|
||||
return {'error': 'Bill not found or not posted'}
|
||||
|
||||
payment_date = params.get('payment_date', str(fields.Date.today()))
|
||||
|
||||
try:
|
||||
# Use the payment register wizard
|
||||
ctx = {
|
||||
'active_model': 'account.move',
|
||||
'active_ids': [bill_id],
|
||||
}
|
||||
wizard = env['account.payment.register'].with_context(**ctx).create({
|
||||
'journal_id': journal_id,
|
||||
'payment_date': payment_date,
|
||||
})
|
||||
# Optionally set amount if provided (otherwise defaults to bill amount)
|
||||
if params.get('amount'):
|
||||
wizard.amount = float(params['amount'])
|
||||
|
||||
payments = wizard.action_create_payments()
|
||||
|
||||
# Find the created payment
|
||||
payment = None
|
||||
if isinstance(payments, dict) and payments.get('res_id'):
|
||||
payment = env['account.payment'].browse(payments['res_id'])
|
||||
elif isinstance(payments, dict) and payments.get('domain'):
|
||||
payment = env['account.payment'].search(payments['domain'], limit=1)
|
||||
else:
|
||||
# Fallback: find the latest payment for this bill
|
||||
payment = env['account.payment'].search([
|
||||
('partner_id', '=', bill.partner_id.id),
|
||||
], order='create_date desc', limit=1)
|
||||
|
||||
result = {
|
||||
'status': 'paid',
|
||||
'bill_id': bill_id,
|
||||
'bill_name': bill.name,
|
||||
'payment_state': bill.payment_state,
|
||||
}
|
||||
if payment:
|
||||
result['payment_id'] = payment.id
|
||||
result['payment_name'] = payment.name
|
||||
|
||||
# Optionally reconcile to a bank statement line
|
||||
if params.get('statement_line_id') and payment:
|
||||
try:
|
||||
st_line = env['account.bank.statement.line'].browse(int(params['statement_line_id']))
|
||||
if st_line.exists() and not st_line.is_reconciled:
|
||||
# Find the payment's move lines on the bank's outstanding account
|
||||
pay_move_lines = payment.move_id.line_ids.filtered(
|
||||
lambda l: l.account_id.reconcile and not l.reconciled
|
||||
)
|
||||
if pay_move_lines:
|
||||
st_line.set_line_bank_statement_line(pay_move_lines.ids)
|
||||
result['reconciled'] = True
|
||||
result['statement_line_id'] = st_line.id
|
||||
except Exception as e:
|
||||
_logger.warning("Payment created but bank reconciliation failed: %s", e)
|
||||
result['reconcile_error'] = str(e)
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
_logger.error("Failed to register payment: %s", e)
|
||||
return {'error': str(e)}
|
||||
|
||||
|
||||
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,
|
||||
'search_partners': search_partners,
|
||||
'find_similar_bank_lines': find_similar_bank_lines,
|
||||
'create_vendor_bill': create_vendor_bill,
|
||||
'register_bill_payment': register_bill_payment,
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
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):
|
||||
"""Get AR and AP balance for a partner. Accepts partner_id or partner_name."""
|
||||
partner = None
|
||||
if params.get('partner_id'):
|
||||
partner = env['res.partner'].browse(int(params['partner_id']))
|
||||
elif params.get('partner_name'):
|
||||
partner = env['res.partner'].search([
|
||||
('name', 'ilike', params['partner_name']),
|
||||
], limit=1)
|
||||
if not partner or not partner.exists():
|
||||
return {'error': f"Partner not found: {params.get('partner_name', params.get('partner_id', '?'))}"}
|
||||
|
||||
# AR balance (receivable)
|
||||
ar_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),
|
||||
])
|
||||
ar_balance = sum(aml.amount_residual for aml in ar_amls)
|
||||
|
||||
# AP balance (payable)
|
||||
ap_amls = env['account.move.line'].search([
|
||||
('partner_id', '=', partner.id),
|
||||
('account_id.account_type', '=', 'liability_payable'),
|
||||
('parent_state', '=', 'posted'),
|
||||
('reconciled', '=', False),
|
||||
('company_id', '=', env.company.id),
|
||||
])
|
||||
ap_balance = sum(aml.amount_residual for aml in ap_amls)
|
||||
|
||||
open_items = [{
|
||||
'id': aml.id,
|
||||
'move_name': aml.move_id.name,
|
||||
'ref': aml.ref or '',
|
||||
'date': str(aml.date),
|
||||
'amount_residual': aml.amount_residual,
|
||||
'type': 'receivable' if aml.account_id.account_type == 'asset_receivable' else 'payable',
|
||||
'date_maturity': str(aml.date_maturity) if aml.date_maturity else '',
|
||||
} for aml in (ar_amls | ap_amls)[:30]]
|
||||
|
||||
return {
|
||||
'partner': partner.name,
|
||||
'partner_id': partner.id,
|
||||
'ar_balance': ar_balance,
|
||||
'ap_balance': ap_balance,
|
||||
'net_balance': ar_balance + ap_balance,
|
||||
'they_owe_us': ar_balance if ar_balance > 0 else 0,
|
||||
'we_owe_them': abs(ap_balance) if ap_balance < 0 else 0,
|
||||
'open_items': open_items,
|
||||
}
|
||||
|
||||
|
||||
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,
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
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_ids', 'in', 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_ids', 'in', 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,
|
||||
}
|
||||
|
||||
|
||||
def register_adp_batch_payment(env, params):
|
||||
"""Register payments for a batch of ADP invoices from a remittance advice.
|
||||
|
||||
Takes a list of invoice numbers with payment amounts and a payment date.
|
||||
Registers a payment for each invoice via Odoo's payment wizard, which
|
||||
creates outstanding receipt entries (PBNK2) on account 1050.
|
||||
|
||||
After calling this, use suggest_bank_line_matches on the bank deposit line
|
||||
to match the outstanding receipts against the bank line.
|
||||
"""
|
||||
invoices_data = params.get('invoices', [])
|
||||
payment_date = params.get('payment_date')
|
||||
journal_id = int(params.get('journal_id', 50)) # Default Scotia Current
|
||||
|
||||
if not invoices_data:
|
||||
return {'error': 'No invoices provided'}
|
||||
if not payment_date:
|
||||
return {'error': 'payment_date is required (YYYY-MM-DD)'}
|
||||
|
||||
ADP_PARTNER_ID = 3421 # ADP (Assistive Device Program)
|
||||
results = []
|
||||
total_paid = 0.0
|
||||
errors = []
|
||||
|
||||
for inv_data in invoices_data:
|
||||
inv_number = str(inv_data.get('invoice_number', '')).strip()
|
||||
amount = float(inv_data.get('amount', 0))
|
||||
|
||||
if not inv_number or not amount:
|
||||
errors.append(f"Skipped: missing invoice_number or amount in {inv_data}")
|
||||
continue
|
||||
|
||||
# Find the invoice by name/number
|
||||
invoice = env['account.move'].search([
|
||||
('name', 'ilike', inv_number),
|
||||
('move_type', '=', 'out_invoice'),
|
||||
('state', '=', 'posted'),
|
||||
('company_id', '=', env.company.id),
|
||||
], limit=1)
|
||||
|
||||
if not invoice:
|
||||
# Try without leading zeros or with different format
|
||||
invoice = env['account.move'].search([
|
||||
('name', '=like', f'%{inv_number}'),
|
||||
('move_type', '=', 'out_invoice'),
|
||||
('state', '=', 'posted'),
|
||||
], limit=1)
|
||||
|
||||
if not invoice:
|
||||
errors.append(f"Invoice {inv_number} not found")
|
||||
continue
|
||||
|
||||
if invoice.payment_state == 'paid':
|
||||
results.append({
|
||||
'invoice': inv_number,
|
||||
'status': 'already_paid',
|
||||
'move_id': invoice.id,
|
||||
})
|
||||
continue
|
||||
|
||||
# Check if amount matches residual (allow partial)
|
||||
if amount > invoice.amount_residual + 0.01:
|
||||
errors.append(
|
||||
f"Invoice {inv_number}: payment ${amount:.2f} exceeds "
|
||||
f"residual ${invoice.amount_residual:.2f}"
|
||||
)
|
||||
continue
|
||||
|
||||
# Register payment via the payment wizard
|
||||
try:
|
||||
payment_vals = {
|
||||
'payment_type': 'inbound',
|
||||
'partner_type': 'customer',
|
||||
'partner_id': invoice.partner_id.id or ADP_PARTNER_ID,
|
||||
'amount': amount,
|
||||
'date': payment_date,
|
||||
'journal_id': journal_id,
|
||||
'ref': f'ADP Remittance - {inv_number}',
|
||||
}
|
||||
# Use the payment register wizard
|
||||
ctx = {
|
||||
'active_model': 'account.move',
|
||||
'active_ids': [invoice.id],
|
||||
}
|
||||
wizard = env['account.payment.register'].with_context(**ctx).create({
|
||||
'payment_date': payment_date,
|
||||
'amount': amount,
|
||||
'journal_id': journal_id,
|
||||
'payment_method_line_id': env['account.payment.method.line'].search([
|
||||
('journal_id', '=', journal_id),
|
||||
('payment_type', '=', 'inbound'),
|
||||
], limit=1).id,
|
||||
})
|
||||
wizard.action_create_payments()
|
||||
|
||||
results.append({
|
||||
'invoice': inv_number,
|
||||
'status': 'paid',
|
||||
'amount': amount,
|
||||
'move_id': invoice.id,
|
||||
'move_name': invoice.name,
|
||||
})
|
||||
total_paid += amount
|
||||
except Exception as e:
|
||||
_logger.warning("ADP payment failed for %s: %s", inv_number, e)
|
||||
errors.append(f"Invoice {inv_number}: payment failed — {e}")
|
||||
|
||||
env.cr.commit()
|
||||
|
||||
return {
|
||||
'status': 'completed',
|
||||
'paid_count': len([r for r in results if r.get('status') == 'paid']),
|
||||
'already_paid_count': len([r for r in results if r.get('status') == 'already_paid']),
|
||||
'total_paid': total_paid,
|
||||
'results': results,
|
||||
'errors': errors,
|
||||
'message': (
|
||||
f"Registered payments for {len([r for r in results if r.get('status') == 'paid'])} invoices "
|
||||
f"totalling ${total_paid:,.2f}. "
|
||||
+ (f"{len(errors)} errors." if errors else "No errors.")
|
||||
+ " Now use suggest_bank_line_matches to match the bank deposit."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
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,
|
||||
'register_adp_batch_payment': register_adp_batch_payment,
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
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):
|
||||
try:
|
||||
AuditStatus = env['account.audit.account.status']
|
||||
except KeyError:
|
||||
return {'error': 'Audit status model (account.audit.account.status) is not available. The account_audit Enterprise module may not be installed.'}
|
||||
statuses = AuditStatus.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):
|
||||
try:
|
||||
AuditStatus = env['account.audit.account.status']
|
||||
except KeyError:
|
||||
return {'error': 'Audit status model (account.audit.account.status) is not available. The account_audit Enterprise module may not be installed.'}
|
||||
status_id = int(params['status_id'])
|
||||
new_status = params['status']
|
||||
rec = AuditStatus.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,
|
||||
}
|
||||
@@ -1,961 +0,0 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from odoo import fields
|
||||
|
||||
_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_ids', 'in', 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):
|
||||
"""Sum payment/journal activity for a date range.
|
||||
IMPORTANT: Always pass journal_ids to filter to specific journals.
|
||||
Without journal_ids, returns totals across ALL journals which is
|
||||
almost never what you want for reconciliation."""
|
||||
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),
|
||||
]
|
||||
scope = 'all journals'
|
||||
if journal_ids:
|
||||
jids = [int(j) for j in journal_ids]
|
||||
domain.append(('journal_id', 'in', jids))
|
||||
journals = env['account.journal'].browse(jids)
|
||||
scope = ', '.join(j.name for j in journals if j.exists())
|
||||
else:
|
||||
# Without journal filter, include a warning and break down by journal
|
||||
pass
|
||||
|
||||
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)
|
||||
|
||||
result = {
|
||||
'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),
|
||||
'scope': scope,
|
||||
}
|
||||
|
||||
# If no journal filter, add per-journal breakdown so AI doesn't
|
||||
# mistake company-wide totals for a specific journal's activity
|
||||
if not journal_ids:
|
||||
result['warning'] = (
|
||||
'No journal_ids filter was provided. These totals are across ALL '
|
||||
'journals in the company. To get card payment totals, pass the '
|
||||
'specific card/POS journal IDs.'
|
||||
)
|
||||
journal_totals = {}
|
||||
for l in lines:
|
||||
jname = l.journal_id.name
|
||||
if jname not in journal_totals:
|
||||
journal_totals[jname] = {'debit': 0.0, 'credit': 0.0, 'count': 0}
|
||||
journal_totals[jname]['debit'] += l.debit
|
||||
journal_totals[jname]['credit'] += l.credit
|
||||
journal_totals[jname]['count'] += 1
|
||||
result['by_journal'] = [
|
||||
{'journal': jn, 'debit': v['debit'], 'credit': v['credit'], 'count': v['count']}
|
||||
for jn, v in sorted(journal_totals.items(), key=lambda x: -x[1]['debit'])
|
||||
][:15]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_bank_line_details(env, params):
|
||||
"""Get full details of a single bank statement line plus matching suggestions."""
|
||||
line_id = int(params['line_id'])
|
||||
line = env['account.bank.statement.line'].browse(line_id)
|
||||
if not line.exists():
|
||||
return {'error': 'Bank statement line not found'}
|
||||
|
||||
result = {
|
||||
'id': line.id,
|
||||
'date': str(line.date),
|
||||
'payment_ref': line.payment_ref or '',
|
||||
'partner_name': line.partner_name or (line.partner_id.name if line.partner_id else ''),
|
||||
'partner_id': line.partner_id.id if line.partner_id else None,
|
||||
'amount': line.amount,
|
||||
'journal': line.journal_id.name,
|
||||
'journal_id': line.journal_id.id,
|
||||
'is_reconciled': line.is_reconciled,
|
||||
'existing_bills': [],
|
||||
'suggested_partner': None,
|
||||
}
|
||||
|
||||
# Search for existing vendor bills matching amount ± $0.50 and date ± 3 days
|
||||
abs_amount = abs(line.amount)
|
||||
from datetime import timedelta as td
|
||||
date_from = line.date - td(days=3)
|
||||
date_to = line.date + td(days=3)
|
||||
matching_bills = env['account.move'].search([
|
||||
('move_type', '=', 'in_invoice'),
|
||||
('state', '=', 'posted'),
|
||||
('amount_total', '>=', abs_amount - 0.50),
|
||||
('amount_total', '<=', abs_amount + 0.50),
|
||||
('date', '>=', str(date_from)),
|
||||
('date', '<=', str(date_to)),
|
||||
('company_id', '=', env.company.id),
|
||||
], limit=5)
|
||||
for bill in matching_bills:
|
||||
result['existing_bills'].append({
|
||||
'id': bill.id,
|
||||
'name': bill.name,
|
||||
'partner': bill.partner_id.name if bill.partner_id else '',
|
||||
'amount_total': bill.amount_total,
|
||||
'date': str(bill.date),
|
||||
'payment_state': bill.payment_state,
|
||||
})
|
||||
|
||||
# Try to suggest a partner from payment_ref keyword
|
||||
if line.payment_ref and not line.partner_id:
|
||||
# Extract meaningful words from payment_ref (skip common banking terms)
|
||||
skip_words = {'misc', 'payment', 'online', 'banking', 'pad', 'business',
|
||||
'deposit', 'cheque', 'transfer', 'e-transfer', 'sent', 'autodeposit'}
|
||||
words = [w for w in line.payment_ref.split() if len(w) > 2 and w.lower() not in skip_words]
|
||||
for word in words[:3]:
|
||||
partners = env['res.partner'].search([
|
||||
('name', 'ilike', word),
|
||||
('supplier_rank', '>', 0),
|
||||
], limit=3)
|
||||
if partners:
|
||||
result['suggested_partner'] = {
|
||||
'id': partners[0].id,
|
||||
'name': partners[0].name,
|
||||
'match_word': word,
|
||||
}
|
||||
break
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def check_recurring_pattern(env, params):
|
||||
"""Check if a bank line matches a known recurring payment pattern.
|
||||
Returns the historical coding (account, HST, partner, reconcile model) if found."""
|
||||
line_id = params.get('line_id')
|
||||
payment_ref = params.get('payment_ref', '')
|
||||
amount = params.get('amount')
|
||||
|
||||
# If line_id provided, get the ref and amount from the line
|
||||
if line_id:
|
||||
line = env['account.bank.statement.line'].browse(int(line_id))
|
||||
if line.exists():
|
||||
payment_ref = line.payment_ref or ''
|
||||
amount = line.amount
|
||||
|
||||
if not payment_ref:
|
||||
return {'match': False, 'reason': 'No payment reference to match'}
|
||||
|
||||
# Search cached patterns by keyword
|
||||
patterns = env['fusion.recurring.pattern'].search([
|
||||
('company_id', '=', env.company.id),
|
||||
])
|
||||
|
||||
best_match = None
|
||||
for pat in patterns:
|
||||
if not pat.ref_keyword:
|
||||
continue
|
||||
# Check if the pattern keyword appears in the payment_ref
|
||||
if pat.ref_keyword.lower()[:30] in payment_ref.lower():
|
||||
# If amount matches too, it's a strong match
|
||||
if amount and pat.amount_is_fixed and abs(pat.amount - amount) < 0.01:
|
||||
best_match = pat
|
||||
break
|
||||
# Keyword-only match (amount may vary)
|
||||
if not best_match or pat.occurrences > best_match.occurrences:
|
||||
best_match = pat
|
||||
|
||||
if not best_match:
|
||||
return {'match': False, 'payment_ref': payment_ref}
|
||||
|
||||
result = {
|
||||
'match': True,
|
||||
'pattern_id': best_match.id,
|
||||
'pattern_name': best_match.name,
|
||||
'occurrences': best_match.occurrences,
|
||||
'first_seen': str(best_match.first_seen) if best_match.first_seen else '',
|
||||
'last_seen': str(best_match.last_seen) if best_match.last_seen else '',
|
||||
'expense_account_id': best_match.expense_account_id.id if best_match.expense_account_id else None,
|
||||
'expense_account_code': best_match.expense_account_code or '',
|
||||
'expense_account_name': best_match.expense_account_id.name if best_match.expense_account_id else '',
|
||||
'has_hst': best_match.has_hst,
|
||||
'partner_id': best_match.partner_id.id if best_match.partner_id else None,
|
||||
'partner_name': best_match.partner_id.name if best_match.partner_id else '',
|
||||
'action_note': best_match.action_note or '',
|
||||
'amount_is_fixed': best_match.amount_is_fixed,
|
||||
}
|
||||
if best_match.reconcile_model_id:
|
||||
result['reconcile_model_id'] = best_match.reconcile_model_id.id
|
||||
result['reconcile_model_name'] = best_match.reconcile_model_id.name
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def match_internal_transfers(env, params):
|
||||
"""[Tier 3] Find and match inter-account transfers between two bank journals.
|
||||
Matches exact amounts within a date window. Only matches when there is exactly
|
||||
ONE candidate on each side (no ambiguous matches). Requires user approval.
|
||||
|
||||
Typical use: Scotia Current Account ↔ Scotia Visa payments."""
|
||||
journal_a_id = int(params['journal_a_id']) # e.g., Scotia Current (50)
|
||||
journal_b_id = int(params['journal_b_id']) # e.g., Scotia Visa (51)
|
||||
date_from = params.get('date_from', '2025-01-01')
|
||||
date_to = params.get('date_to', '2025-03-31')
|
||||
max_days_apart = int(params.get('max_days_apart', 2))
|
||||
|
||||
# Get unreconciled positive lines from both journals
|
||||
# (transfers show as positive on the RECEIVING side)
|
||||
lines_a = env['account.bank.statement.line'].search([
|
||||
('is_reconciled', '=', False),
|
||||
('journal_id', '=', journal_a_id),
|
||||
('company_id', '=', env.company.id),
|
||||
])
|
||||
lines_a = lines_a.filtered(
|
||||
lambda l: l.move_id.date >= fields.Date.from_string(date_from)
|
||||
and l.move_id.date <= fields.Date.from_string(date_to)
|
||||
and l.amount > 0 # money coming IN on this account
|
||||
)
|
||||
|
||||
lines_b = env['account.bank.statement.line'].search([
|
||||
('is_reconciled', '=', False),
|
||||
('journal_id', '=', journal_b_id),
|
||||
('company_id', '=', env.company.id),
|
||||
])
|
||||
lines_b = lines_b.filtered(
|
||||
lambda l: l.move_id.date >= fields.Date.from_string(date_from)
|
||||
and l.move_id.date <= fields.Date.from_string(date_to)
|
||||
and l.amount > 0 # money coming IN on this account
|
||||
)
|
||||
|
||||
matched_pairs = []
|
||||
used_a = set()
|
||||
used_b = set()
|
||||
|
||||
# For each line in A, find exact-amount match in B within date window
|
||||
for la in sorted(lines_a, key=lambda l: l.move_id.date):
|
||||
if la.id in used_a:
|
||||
continue
|
||||
candidates = []
|
||||
for lb in lines_b:
|
||||
if lb.id in used_b:
|
||||
continue
|
||||
if abs(la.amount - lb.amount) < 0.01:
|
||||
days = abs((la.move_id.date - lb.move_id.date).days)
|
||||
if days <= max_days_apart:
|
||||
candidates.append(lb)
|
||||
# Only match if EXACTLY ONE candidate — skip ambiguous
|
||||
if len(candidates) == 1:
|
||||
lb = candidates[0]
|
||||
matched_pairs.append({
|
||||
'line_a_id': la.id,
|
||||
'line_a_date': str(la.move_id.date),
|
||||
'line_a_ref': la.payment_ref or '',
|
||||
'line_a_journal': la.journal_id.name,
|
||||
'line_b_id': lb.id,
|
||||
'line_b_date': str(lb.move_id.date),
|
||||
'line_b_ref': lb.payment_ref or '',
|
||||
'line_b_journal': lb.journal_id.name,
|
||||
'amount': la.amount,
|
||||
'days_apart': abs((la.move_id.date - lb.move_id.date).days),
|
||||
})
|
||||
used_a.add(la.id)
|
||||
used_b.add(lb.id)
|
||||
|
||||
if not matched_pairs:
|
||||
return {
|
||||
'status': 'no_matches',
|
||||
'message': 'No unambiguous transfer pairs found.',
|
||||
'lines_a_checked': len(lines_a),
|
||||
'lines_b_checked': len(lines_b),
|
||||
}
|
||||
|
||||
# If this is just a dry-run check (no execute flag), return the pairs for review
|
||||
if not params.get('execute', False):
|
||||
return {
|
||||
'status': 'pairs_found',
|
||||
'count': len(matched_pairs),
|
||||
'pairs': matched_pairs,
|
||||
'message': f'Found {len(matched_pairs)} unambiguous transfer pairs. Set execute=true to reconcile them.',
|
||||
}
|
||||
|
||||
# Execute: create internal transfer journal entries to reconcile both sides
|
||||
reconciled = []
|
||||
for pair in matched_pairs:
|
||||
try:
|
||||
line_a = env['account.bank.statement.line'].browse(pair['line_a_id'])
|
||||
line_b = env['account.bank.statement.line'].browse(pair['line_b_id'])
|
||||
|
||||
# Create an internal transfer payment
|
||||
payment = env['account.payment'].create({
|
||||
'payment_type': 'outbound',
|
||||
'partner_type': 'supplier',
|
||||
'partner_id': env.company.partner_id.id, # Self as partner for internal transfer
|
||||
'amount': pair['amount'],
|
||||
'journal_id': journal_a_id,
|
||||
'destination_journal_id': journal_b_id,
|
||||
'date': line_a.move_id.date,
|
||||
'ref': f'Internal Transfer: {pair["line_a_ref"]} ↔ {pair["line_b_ref"]}',
|
||||
'is_internal_transfer': True,
|
||||
})
|
||||
payment.action_post()
|
||||
|
||||
# Now match the payment's move lines to the bank statement lines
|
||||
# The payment creates lines on both journals' outstanding accounts
|
||||
for move_line in payment.move_id.line_ids:
|
||||
if move_line.journal_id.id == journal_a_id and not move_line.reconciled:
|
||||
try:
|
||||
line_a.set_line_bank_statement_line(move_line.ids)
|
||||
except Exception:
|
||||
pass
|
||||
# Check paired transfer for the other side
|
||||
if payment.paired_internal_transfer_payment_id:
|
||||
paired = payment.paired_internal_transfer_payment_id
|
||||
for move_line in paired.move_id.line_ids:
|
||||
if move_line.journal_id.id == journal_b_id and not move_line.reconciled:
|
||||
try:
|
||||
line_b.set_line_bank_statement_line(move_line.ids)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
reconciled.append({
|
||||
'line_a_id': pair['line_a_id'],
|
||||
'line_b_id': pair['line_b_id'],
|
||||
'amount': pair['amount'],
|
||||
'payment_id': payment.id,
|
||||
'status': 'reconciled',
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.error("Failed to reconcile transfer pair %s: %s", pair, e)
|
||||
reconciled.append({
|
||||
'line_a_id': pair['line_a_id'],
|
||||
'line_b_id': pair['line_b_id'],
|
||||
'amount': pair['amount'],
|
||||
'status': 'error',
|
||||
'error': str(e),
|
||||
})
|
||||
|
||||
return {
|
||||
'status': 'executed',
|
||||
'total_pairs': len(matched_pairs),
|
||||
'reconciled': len([r for r in reconciled if r['status'] == 'reconciled']),
|
||||
'errors': len([r for r in reconciled if r['status'] == 'error']),
|
||||
'details': reconciled,
|
||||
}
|
||||
|
||||
|
||||
def find_unreconciled_cheques(env, params):
|
||||
"""Find unreconciled cheque bank lines and classify as payroll vs non-payroll
|
||||
by checking if the amount matches an existing payroll liability entry."""
|
||||
PAYROLL_ACCT = 433 # 2201 Payroll Liabilities
|
||||
journal_id = int(params.get('journal_id', 50)) # Default Scotia Current
|
||||
limit = int(params.get('limit', 50))
|
||||
|
||||
AML = env['account.move.line'].sudo()
|
||||
BSL = env['account.bank.statement.line'].sudo()
|
||||
|
||||
# Build set of known payroll liability amounts
|
||||
payroll_amounts = set()
|
||||
for aml in AML.search([
|
||||
('account_id', '=', PAYROLL_ACCT),
|
||||
('parent_state', '=', 'posted'),
|
||||
('credit', '>', 0),
|
||||
]):
|
||||
payroll_amounts.add(round(aml.credit, 2))
|
||||
|
||||
cheque_lines = BSL.search([
|
||||
('journal_id', '=', journal_id),
|
||||
('is_reconciled', '=', False),
|
||||
('payment_ref', 'ilike', 'cheque'),
|
||||
('amount', '<', 0),
|
||||
('company_id', '=', env.company.id),
|
||||
], limit=limit, order='move_id desc')
|
||||
|
||||
payroll = []
|
||||
non_payroll = []
|
||||
for line in cheque_lines:
|
||||
amt = round(abs(line.amount), 2)
|
||||
entry = {
|
||||
'id': line.id,
|
||||
'date': str(line.move_id.date),
|
||||
'ref': line.payment_ref or '',
|
||||
'amount': amt,
|
||||
'journal': line.journal_id.name,
|
||||
}
|
||||
if amt in payroll_amounts:
|
||||
entry['type'] = 'payroll'
|
||||
payroll.append(entry)
|
||||
else:
|
||||
entry['type'] = 'non_payroll'
|
||||
non_payroll.append(entry)
|
||||
|
||||
return {
|
||||
'count': len(cheque_lines),
|
||||
'payroll_count': len(payroll),
|
||||
'non_payroll_count': len(non_payroll),
|
||||
'payroll': payroll,
|
||||
'non_payroll': non_payroll,
|
||||
}
|
||||
|
||||
|
||||
def reconcile_payroll_cheques(env, params):
|
||||
"""Reconcile payroll cheque bank lines by applying the Payroll Cheque Clearing
|
||||
reconcile model. Only reconciles cheques whose amount matches an existing
|
||||
payroll liability entry on account 2201. Non-payroll cheques are skipped.
|
||||
|
||||
Params:
|
||||
journal_id (int): Bank journal ID (default 50 = Scotia Current)
|
||||
line_ids (list): Optional list of specific bank line IDs to reconcile.
|
||||
If not provided, reconciles all matching payroll cheques.
|
||||
"""
|
||||
PAYROLL_ACCT = 433
|
||||
journal_id = int(params.get('journal_id', 50))
|
||||
|
||||
AML = env['account.move.line'].sudo()
|
||||
BSL = env['account.bank.statement.line'].sudo()
|
||||
RecModel = env['account.reconcile.model'].sudo()
|
||||
|
||||
model = RecModel.search([
|
||||
('name', 'ilike', 'Payroll Cheque'),
|
||||
('company_id', '=', env.company.id),
|
||||
], limit=1)
|
||||
if not model:
|
||||
return {'error': 'No "Payroll Cheque Clearing" reconcile model found. Create one first.'}
|
||||
|
||||
# Get lines to process
|
||||
if params.get('line_ids'):
|
||||
cheque_lines = BSL.browse([int(x) for x in params['line_ids']])
|
||||
cheque_lines = cheque_lines.filtered(lambda l: not l.is_reconciled)
|
||||
else:
|
||||
cheque_lines = BSL.search([
|
||||
('journal_id', '=', journal_id),
|
||||
('is_reconciled', '=', False),
|
||||
('payment_ref', 'ilike', 'cheque'),
|
||||
('amount', '<', 0),
|
||||
('company_id', '=', env.company.id),
|
||||
])
|
||||
|
||||
# Filter post-lock-date
|
||||
lock = env.company.fiscalyear_lock_date
|
||||
if lock:
|
||||
cheque_lines = cheque_lines.filtered(lambda l: l.move_id.date > lock)
|
||||
|
||||
# Filter to payroll-only amounts
|
||||
payroll_amounts = set()
|
||||
for aml in AML.search([
|
||||
('account_id', '=', PAYROLL_ACCT),
|
||||
('parent_state', '=', 'posted'),
|
||||
('credit', '>', 0),
|
||||
]):
|
||||
payroll_amounts.add(round(aml.credit, 2))
|
||||
|
||||
payroll_lines = cheque_lines.filtered(
|
||||
lambda l: round(abs(l.amount), 2) in payroll_amounts
|
||||
)
|
||||
skipped = len(cheque_lines) - len(payroll_lines)
|
||||
|
||||
if not payroll_lines:
|
||||
return {
|
||||
'status': 'nothing_to_do',
|
||||
'message': f'No payroll cheques to reconcile ({skipped} non-payroll cheques skipped)',
|
||||
}
|
||||
|
||||
try:
|
||||
model._apply_reconcile_models(payroll_lines)
|
||||
env.cr.commit()
|
||||
except Exception as e:
|
||||
return {'error': f'Reconciliation failed: {e}'}
|
||||
|
||||
still = payroll_lines.filtered(lambda l: not l.is_reconciled)
|
||||
reconciled = len(payroll_lines) - len(still)
|
||||
|
||||
return {
|
||||
'status': 'completed',
|
||||
'reconciled': reconciled,
|
||||
'still_unreconciled': len(still),
|
||||
'non_payroll_skipped': skipped,
|
||||
'message': f'Reconciled {reconciled} payroll cheques. {skipped} non-payroll cheques skipped.',
|
||||
}
|
||||
|
||||
|
||||
def _extract_partner_from_ref(env, payment_ref):
|
||||
"""Extract a partner from a bank line payment_ref using keyword matching."""
|
||||
if not payment_ref:
|
||||
return None
|
||||
skip_words = {
|
||||
'misc', 'payment', 'online', 'banking', 'pad', 'business', 'deposit',
|
||||
'cheque', 'transfer', 'e-transfer', 'sent', 'autodeposit', 'credit',
|
||||
'debit', 'memo', 'free', 'interac', 'from', 'the', 'and', 'for',
|
||||
'miscellaneous', 'bill', 'correction', 'adjustment', 'other',
|
||||
}
|
||||
# Strip common suffixes like colons and split
|
||||
clean_ref = payment_ref.replace(':', ' ').replace('-', ' ')
|
||||
words = [w for w in clean_ref.split() if len(w) > 2 and w.lower() not in skip_words]
|
||||
# Try progressively shorter phrases
|
||||
for n in range(min(len(words), 4), 0, -1):
|
||||
for i in range(len(words) - n + 1):
|
||||
phrase = ' '.join(words[i:i+n])
|
||||
partners = env['res.partner'].search([
|
||||
('name', 'ilike', phrase),
|
||||
('company_id', 'in', [env.company.id, False]),
|
||||
], limit=3)
|
||||
if partners:
|
||||
return partners[0]
|
||||
# Fallback: try each word individually with supplier/customer rank
|
||||
for word in words:
|
||||
if len(word) < 4:
|
||||
continue
|
||||
partners = env['res.partner'].search([
|
||||
('name', 'ilike', word),
|
||||
('company_id', 'in', [env.company.id, False]),
|
||||
'|', ('customer_rank', '>', 0), ('supplier_rank', '>', 0),
|
||||
], limit=3)
|
||||
if partners:
|
||||
return partners[0]
|
||||
return None
|
||||
|
||||
|
||||
def _find_best_subset(candidates, target, max_items=8):
|
||||
"""Find the subset of candidates whose amounts sum closest to target.
|
||||
Returns (aml_ids, total) for the best combination."""
|
||||
items = candidates[:max_items]
|
||||
if not items:
|
||||
return [], 0.0
|
||||
best_ids = []
|
||||
best_total = 0.0
|
||||
best_diff = abs(target)
|
||||
n = len(items)
|
||||
# Brute force all subsets (2^n, max 256)
|
||||
for mask in range(1, 1 << n):
|
||||
subset_ids = []
|
||||
subset_total = 0.0
|
||||
for j in range(n):
|
||||
if mask & (1 << j):
|
||||
subset_ids.append(items[j]['aml_id'])
|
||||
subset_total += items[j]['amount_residual']
|
||||
diff = abs(subset_total - target)
|
||||
if diff < best_diff:
|
||||
best_diff = diff
|
||||
best_ids = subset_ids
|
||||
best_total = subset_total
|
||||
if diff < 0.01:
|
||||
break # Exact match found
|
||||
return best_ids, round(best_total, 2)
|
||||
|
||||
|
||||
def suggest_bank_line_matches(env, params):
|
||||
"""Find candidate journal items (invoices/bills) that could match a bank statement line.
|
||||
Scores and ranks matches, finds best subset-sum combination.
|
||||
Returns data for a reconciliation-mode fusion-table."""
|
||||
line_id = int(params['statement_line_id'])
|
||||
line = env['account.bank.statement.line'].browse(line_id)
|
||||
if not line.exists():
|
||||
return {'error': 'Bank statement line not found'}
|
||||
if line.is_reconciled:
|
||||
return {'error': 'Bank statement line is already reconciled'}
|
||||
|
||||
AML = env['account.move.line'].sudo()
|
||||
bank_amount = abs(line.amount)
|
||||
line_date = line.move_id.date
|
||||
is_incoming = line.amount > 0 # positive = customer payment, negative = vendor payment
|
||||
from datetime import timedelta as td
|
||||
|
||||
# Determine partner
|
||||
partner = line.partner_id
|
||||
if not partner:
|
||||
partner = _extract_partner_from_ref(env, line.payment_ref)
|
||||
|
||||
# Base domain common to all searches
|
||||
base_domain = [
|
||||
('parent_state', '=', 'posted'),
|
||||
('reconciled', '=', False),
|
||||
('company_id', '=', env.company.id),
|
||||
('display_type', 'not in', ('line_section', 'line_subsection', 'line_note')),
|
||||
('statement_line_id', '=', False),
|
||||
]
|
||||
|
||||
# --- PRIORITY 1: Outstanding payments/receipts on bank journal accounts ---
|
||||
# These are registered payments waiting to be matched to bank lines.
|
||||
# For incoming bank lines → look for outstanding receipts (credit on outstanding account)
|
||||
# For outgoing bank lines → look for outstanding payments (debit on outstanding account)
|
||||
outstanding_acct_ids = env['account.account'].search([
|
||||
('name', 'ilike', 'outstanding'),
|
||||
('company_ids', 'in', env.company.id),
|
||||
]).ids
|
||||
outstanding_amls = AML
|
||||
if outstanding_acct_ids:
|
||||
os_domain = base_domain + [('account_id', 'in', outstanding_acct_ids)]
|
||||
if is_incoming:
|
||||
os_domain.append(('amount_residual', '>', 0)) # Debit residual on outstanding receipts
|
||||
else:
|
||||
os_domain.append(('amount_residual', '<', 0)) # Credit residual on outstanding payments
|
||||
if partner:
|
||||
outstanding_amls = AML.search(os_domain + [('partner_id', '=', partner.id)], limit=30)
|
||||
if not outstanding_amls:
|
||||
outstanding_amls = AML.search(os_domain, limit=30)
|
||||
else:
|
||||
outstanding_amls = AML.search(os_domain, limit=30)
|
||||
|
||||
# --- PRIORITY 2: Open invoices/bills (receivable/payable accounts) ---
|
||||
inv_domain = list(base_domain)
|
||||
if is_incoming:
|
||||
inv_domain.append(('account_id.account_type', '=', 'asset_receivable'))
|
||||
inv_domain.append(('amount_residual', '>', 0))
|
||||
else:
|
||||
inv_domain.append(('account_id.account_type', '=', 'liability_payable'))
|
||||
inv_domain.append(('amount_residual', '<', 0))
|
||||
inv_domain.append(('date', '>=', str(line_date - td(days=90))))
|
||||
inv_domain.append(('date', '<=', str(line_date + td(days=30))))
|
||||
|
||||
invoice_amls = AML
|
||||
if partner:
|
||||
invoice_amls = AML.search(inv_domain + [('partner_id', '=', partner.id)], limit=30)
|
||||
if not invoice_amls:
|
||||
invoice_amls = AML.search(inv_domain, limit=30)
|
||||
else:
|
||||
invoice_amls = AML.search(inv_domain, limit=30)
|
||||
|
||||
# Merge: outstanding payments first (priority), then invoices/bills
|
||||
combined = outstanding_amls | invoice_amls
|
||||
|
||||
# Score and format candidates
|
||||
outstanding_ids = set(outstanding_amls.ids) if outstanding_amls else set()
|
||||
candidates = []
|
||||
seen_ids = set()
|
||||
for aml in combined:
|
||||
if aml.id in seen_ids:
|
||||
continue
|
||||
seen_ids.add(aml.id)
|
||||
residual = abs(aml.amount_residual)
|
||||
score = 0
|
||||
reasons = []
|
||||
is_payment = aml.id in outstanding_ids
|
||||
|
||||
# Source type: payment entries get a boost (preferred match)
|
||||
if is_payment:
|
||||
score += 15
|
||||
reasons.append('payment')
|
||||
|
||||
# Amount scoring
|
||||
if abs(residual - bank_amount) < 0.01:
|
||||
score += 40
|
||||
reasons.append('exact amount')
|
||||
elif residual <= bank_amount * 1.05:
|
||||
score += 20
|
||||
reasons.append('close amount')
|
||||
|
||||
# Partner scoring
|
||||
if partner and aml.partner_id.id == partner.id:
|
||||
score += 25
|
||||
reasons.append('partner')
|
||||
elif partner and aml.partner_id and partner.name and aml.partner_id.name:
|
||||
p1_words = set(partner.name.upper().split())
|
||||
p2_words = set(aml.partner_id.name.upper().split())
|
||||
if p1_words & p2_words:
|
||||
score += 10
|
||||
reasons.append('partial partner')
|
||||
|
||||
# Date proximity scoring
|
||||
days_apart = abs((aml.date - line_date).days)
|
||||
if days_apart <= 3:
|
||||
score += 15
|
||||
reasons.append(f'{days_apart}d')
|
||||
elif days_apart <= 7:
|
||||
score += 10
|
||||
elif days_apart <= 14:
|
||||
score += 5
|
||||
|
||||
# Reference matching
|
||||
if line.payment_ref and aml.move_id.ref:
|
||||
if any(w.upper() in (aml.move_id.ref or '').upper()
|
||||
for w in line.payment_ref.split() if len(w) > 3):
|
||||
score += 10
|
||||
reasons.append('ref match')
|
||||
|
||||
# Determine entry type label
|
||||
entry_type = 'payment' if is_payment else 'invoice'
|
||||
if aml.move_id.move_type == 'in_invoice':
|
||||
entry_type = 'bill'
|
||||
elif aml.move_id.move_type == 'out_invoice':
|
||||
entry_type = 'invoice'
|
||||
elif aml.move_id.move_type in ('in_refund', 'out_refund'):
|
||||
entry_type = 'credit note'
|
||||
elif aml.payment_id:
|
||||
entry_type = 'payment'
|
||||
|
||||
candidates.append({
|
||||
'aml_id': aml.id,
|
||||
'move_id': aml.move_id.id,
|
||||
'name': aml.move_id.name or '',
|
||||
'ref': aml.move_id.ref or '',
|
||||
'partner': aml.partner_id.name if aml.partner_id else '',
|
||||
'partner_id': aml.partner_id.id if aml.partner_id else None,
|
||||
'date': str(aml.date),
|
||||
'amount_total': abs(aml.balance),
|
||||
'amount_residual': residual,
|
||||
'account': aml.account_id.code if hasattr(aml.account_id, 'code') else '',
|
||||
'type': entry_type,
|
||||
'score': score,
|
||||
'reasons': ', '.join(reasons) if reasons else '',
|
||||
})
|
||||
|
||||
# Sort by score descending
|
||||
candidates.sort(key=lambda c: -c['score'])
|
||||
|
||||
# Find best subset-sum combination
|
||||
best_combo_ids, best_combo_total = _find_best_subset(candidates, bank_amount)
|
||||
|
||||
# Mark which candidates are in the best combination
|
||||
for c in candidates:
|
||||
c['in_best_combo'] = c['aml_id'] in best_combo_ids
|
||||
|
||||
return {
|
||||
'bank_line': {
|
||||
'id': line.id,
|
||||
'date': str(line_date),
|
||||
'ref': line.payment_ref or '',
|
||||
'amount': line.amount,
|
||||
'abs_amount': bank_amount,
|
||||
'journal': line.journal_id.name,
|
||||
'partner': partner.name if partner else '',
|
||||
'partner_id': partner.id if partner else None,
|
||||
'direction': 'incoming' if is_incoming else 'outgoing',
|
||||
},
|
||||
'candidates': candidates[:20],
|
||||
'best_combination': best_combo_ids,
|
||||
'best_combination_total': best_combo_total,
|
||||
'is_exact_match': abs(best_combo_total - bank_amount) < 0.01,
|
||||
'count': len(candidates),
|
||||
}
|
||||
|
||||
|
||||
def search_matching_entries(env, params):
|
||||
"""Search open journal items by query (invoice/bill number, amount, or partner name).
|
||||
Used by the reconciliation table search bar via direct RPC."""
|
||||
query = (params.get('query') or '').strip()
|
||||
line_id = params.get('statement_line_id')
|
||||
if not query:
|
||||
return {'candidates': []}
|
||||
|
||||
AML = env['account.move.line'].sudo()
|
||||
|
||||
# Search across receivable, payable, AND outstanding accounts
|
||||
outstanding_acct_ids = env['account.account'].search([
|
||||
('name', 'ilike', 'outstanding'),
|
||||
('company_ids', 'in', env.company.id),
|
||||
]).ids
|
||||
domain = [
|
||||
('parent_state', '=', 'posted'),
|
||||
('reconciled', '=', False),
|
||||
('company_id', '=', env.company.id),
|
||||
('display_type', 'not in', ('line_section', 'line_subsection', 'line_note')),
|
||||
'|',
|
||||
('account_id.account_type', 'in', ('asset_receivable', 'liability_payable')),
|
||||
('account_id', 'in', outstanding_acct_ids),
|
||||
]
|
||||
|
||||
# Try as amount first
|
||||
try:
|
||||
amount = float(query.replace('$', '').replace(',', ''))
|
||||
amount_domain = domain + [
|
||||
'|',
|
||||
'&', ('amount_residual', '>=', amount - 0.50), ('amount_residual', '<=', amount + 0.50),
|
||||
'&', ('amount_residual', '>=', -amount - 0.50), ('amount_residual', '<=', -amount + 0.50),
|
||||
]
|
||||
amls = AML.search(amount_domain, limit=15)
|
||||
if amls:
|
||||
return {'candidates': _format_aml_candidates(amls)}
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Search by move name (invoice/bill number)
|
||||
name_amls = AML.search(domain + [('move_id.name', 'ilike', query)], limit=15)
|
||||
if name_amls:
|
||||
return {'candidates': _format_aml_candidates(name_amls)}
|
||||
|
||||
# Search by move ref
|
||||
ref_amls = AML.search(domain + [('move_id.ref', 'ilike', query)], limit=15)
|
||||
if ref_amls:
|
||||
return {'candidates': _format_aml_candidates(ref_amls)}
|
||||
|
||||
# Search by partner name
|
||||
partner_amls = AML.search(domain + [('partner_id.name', 'ilike', query)], limit=15)
|
||||
return {'candidates': _format_aml_candidates(partner_amls)}
|
||||
|
||||
|
||||
def _format_aml_candidates(amls):
|
||||
"""Format AMLs as candidate dicts for the reconciliation table."""
|
||||
return [{
|
||||
'aml_id': aml.id,
|
||||
'move_id': aml.move_id.id,
|
||||
'name': aml.move_id.name or '',
|
||||
'ref': aml.move_id.ref or '',
|
||||
'partner': aml.partner_id.name if aml.partner_id else '',
|
||||
'partner_id': aml.partner_id.id if aml.partner_id else None,
|
||||
'date': str(aml.date),
|
||||
'amount_total': abs(aml.balance),
|
||||
'amount_residual': abs(aml.amount_residual),
|
||||
'account': aml.account_id.code if hasattr(aml.account_id, 'code') else '',
|
||||
'score': 0,
|
||||
'reasons': 'manual search',
|
||||
'in_best_combo': False,
|
||||
} for aml in amls]
|
||||
|
||||
|
||||
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,
|
||||
'get_bank_line_details': get_bank_line_details,
|
||||
'check_recurring_pattern': check_recurring_pattern,
|
||||
'match_internal_transfers': match_internal_transfers,
|
||||
'find_unreconciled_cheques': find_unreconciled_cheques,
|
||||
'reconcile_payroll_cheques': reconcile_payroll_cheques,
|
||||
'suggest_bank_line_matches': suggest_bank_line_matches,
|
||||
'search_matching_entries': search_matching_entries,
|
||||
}
|
||||
@@ -1,299 +0,0 @@
|
||||
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))
|
||||
|
||||
# Odoo 19 Enterprise: account.account may not have company_id field
|
||||
# (shared chart of accounts). Use try/except to handle both cases.
|
||||
try:
|
||||
collected_accounts = env['account.account'].search([
|
||||
('code', '=like', '2005%'), ('company_ids', 'in', env.company.id),
|
||||
])
|
||||
itc_accounts = env['account.account'].search([
|
||||
('code', '=like', '2006%'), ('company_ids', 'in', env.company.id),
|
||||
])
|
||||
except Exception:
|
||||
collected_accounts = env['account.account'].search([
|
||||
('code', '=like', '2005%'),
|
||||
])
|
||||
itc_accounts = env['account.account'].search([
|
||||
('code', '=like', '2006%'),
|
||||
])
|
||||
|
||||
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):
|
||||
try:
|
||||
AccountReturn = env['account.return']
|
||||
except KeyError:
|
||||
return {'error': 'Tax return model (account.return) is not available. The account_tax_report or related Enterprise module may not be installed.'}
|
||||
returns = AccountReturn.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:
|
||||
AccountReturn = env['account.return']
|
||||
except KeyError:
|
||||
return {'error': 'Tax return model (account.return) is not available.'}
|
||||
try:
|
||||
AccountReturn._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):
|
||||
try:
|
||||
AccountReturn = env['account.return']
|
||||
except KeyError:
|
||||
return {'error': 'Tax return model (account.return) is not available.'}
|
||||
return_id = int(params['return_id'])
|
||||
tax_return = AccountReturn.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)}
|
||||
|
||||
|
||||
def create_expense_entry(env, params):
|
||||
"""[Tier 3] Create a direct GL expense entry in the Misc journal with optional HST split.
|
||||
This is the 'old school' way of recording expenses without a formal vendor bill.
|
||||
Requires user approval before execution."""
|
||||
date = params.get('date', str(env['account.move']._fields['date'].default(env['account.move'])))
|
||||
description = params.get('description', 'Expense')
|
||||
expense_account_id = int(params['expense_account_id'])
|
||||
amount = abs(float(params['amount']))
|
||||
has_hst = params.get('has_hst', False)
|
||||
bank_journal_id = int(params.get('bank_journal_id', 0))
|
||||
|
||||
# Find the MISC journal
|
||||
misc_journal = env['account.journal'].search([
|
||||
('code', '=', 'MISC'), ('company_id', '=', env.company.id),
|
||||
], limit=1)
|
||||
if not misc_journal:
|
||||
return {'error': 'Miscellaneous Operations journal (MISC) not found'}
|
||||
|
||||
expense_account = env['account.account'].browse(expense_account_id)
|
||||
if not expense_account.exists():
|
||||
return {'error': f'Expense account not found: {expense_account_id}'}
|
||||
|
||||
# Determine credit account (bank outstanding or AP)
|
||||
credit_account = None
|
||||
if bank_journal_id:
|
||||
bank_journal = env['account.journal'].browse(bank_journal_id)
|
||||
if bank_journal.exists():
|
||||
# Use the bank journal's default debit/credit account
|
||||
credit_account = (bank_journal.default_account_id
|
||||
or bank_journal.company_id.account_journal_payment_credit_account_id)
|
||||
if not credit_account:
|
||||
# Fallback to AP account
|
||||
credit_account = env['account.account'].search([
|
||||
('account_type', '=', 'liability_payable'),
|
||||
('company_ids', 'in', env.company.id),
|
||||
], limit=1)
|
||||
|
||||
if not credit_account:
|
||||
return {'error': 'Could not determine credit account for the expense entry'}
|
||||
|
||||
line_ids = []
|
||||
if has_hst:
|
||||
# Split: net expense + 13% HST ITC
|
||||
hst_rate = 0.13
|
||||
net_amount = round(amount / (1 + hst_rate), 2)
|
||||
hst_amount = round(amount - net_amount, 2)
|
||||
|
||||
# Find HST ITC account (2006%)
|
||||
itc_account = env['account.account'].search([
|
||||
('code', '=like', '2006%'),
|
||||
], limit=1)
|
||||
if not itc_account:
|
||||
# Fallback: use the HST purchase tax account
|
||||
hst_tax = env['account.tax'].search([
|
||||
('type_tax_use', '=', 'purchase'), ('amount', '=', 13.0),
|
||||
('company_id', '=', env.company.id),
|
||||
], limit=1)
|
||||
if hst_tax and hst_tax.invoice_repartition_line_ids:
|
||||
for rep in hst_tax.invoice_repartition_line_ids:
|
||||
if rep.repartition_type == 'tax' and rep.account_id:
|
||||
itc_account = rep.account_id
|
||||
break
|
||||
if not itc_account:
|
||||
return {'error': 'HST ITC account (2006) not found'}
|
||||
|
||||
line_ids = [
|
||||
(0, 0, {'name': description, 'account_id': expense_account_id,
|
||||
'debit': net_amount, 'credit': 0.0}),
|
||||
(0, 0, {'name': f'HST ITC - {description}', 'account_id': itc_account.id,
|
||||
'debit': hst_amount, 'credit': 0.0}),
|
||||
(0, 0, {'name': description, 'account_id': credit_account.id,
|
||||
'debit': 0.0, 'credit': amount}),
|
||||
]
|
||||
else:
|
||||
# Simple: debit expense / credit bank
|
||||
line_ids = [
|
||||
(0, 0, {'name': description, 'account_id': expense_account_id,
|
||||
'debit': amount, 'credit': 0.0}),
|
||||
(0, 0, {'name': description, 'account_id': credit_account.id,
|
||||
'debit': 0.0, 'credit': amount}),
|
||||
]
|
||||
|
||||
try:
|
||||
move = env['account.move'].create({
|
||||
'move_type': 'entry',
|
||||
'journal_id': misc_journal.id,
|
||||
'date': date,
|
||||
'ref': description,
|
||||
'line_ids': line_ids,
|
||||
'company_id': env.company.id,
|
||||
})
|
||||
move.action_post()
|
||||
return {
|
||||
'status': 'posted',
|
||||
'move_id': move.id,
|
||||
'move_name': move.name,
|
||||
'amount': amount,
|
||||
'has_hst': has_hst,
|
||||
'hst_amount': round(amount - amount / 1.13, 2) if has_hst else 0.0,
|
||||
}
|
||||
except Exception as e:
|
||||
_logger.error("Failed to create expense entry: %s", 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,
|
||||
'create_expense_entry': create_expense_entry,
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
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_ids', 'in', 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_ids', 'in', 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,
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
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_ids', 'in', 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_ids', 'in', 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,
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
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_ids', 'in', 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_ids', 'in', 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,
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
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_ids', 'in', 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 _resolve_account_id(env, val):
|
||||
"""Resolve an account code or ID to a valid account ID.
|
||||
Accepts: integer ID, string ID, or account code string like '2201'."""
|
||||
if not val:
|
||||
return False
|
||||
val_str = str(val).strip()
|
||||
# Try as a direct ID first
|
||||
try:
|
||||
acct = env['account.account'].browse(int(val_str))
|
||||
if acct.exists():
|
||||
return acct.id
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
# Try as an account code
|
||||
acct = env['account.account'].search([
|
||||
('code', '=', val_str),
|
||||
('company_ids', 'in', env.company.id),
|
||||
], limit=1)
|
||||
if acct:
|
||||
return acct.id
|
||||
return False
|
||||
|
||||
|
||||
def create_payroll_journal_entry(env, params):
|
||||
journal_id = int(params['journal_id'])
|
||||
date = params['date']
|
||||
ref = params.get('ref', 'Payroll Entry')
|
||||
lines_data = params['lines']
|
||||
|
||||
# Duplicate check: same journal + date + ref + similar amount
|
||||
total_debit = sum(float(l.get('debit', 0)) for l in lines_data)
|
||||
existing = env['account.move'].search([
|
||||
('journal_id', '=', journal_id),
|
||||
('date', '=', date),
|
||||
('ref', 'ilike', ref[:30]),
|
||||
('state', 'in', ('draft', 'posted')),
|
||||
], limit=1)
|
||||
if existing:
|
||||
return {
|
||||
'status': 'duplicate',
|
||||
'error': f'Entry already exists: {existing.name} (ref: {existing.ref}) on {existing.date} '
|
||||
f'for ${existing.amount_total:,.2f}. Skipping to avoid duplicate.',
|
||||
'existing_move_id': existing.id,
|
||||
'existing_name': existing.name,
|
||||
}
|
||||
|
||||
# Resolve account codes to IDs
|
||||
resolved_lines = []
|
||||
for line in lines_data:
|
||||
account_id = _resolve_account_id(env, line['account_id'])
|
||||
if not account_id:
|
||||
return {'error': f"Account not found: {line['account_id']}. "
|
||||
f"Provide a valid account code (e.g. '2201') or database ID."}
|
||||
resolved_lines.append((0, 0, {
|
||||
'account_id': 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,
|
||||
}))
|
||||
|
||||
move_vals = {
|
||||
'journal_id': journal_id,
|
||||
'date': date,
|
||||
'ref': ref,
|
||||
'line_ids': resolved_lines,
|
||||
}
|
||||
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,
|
||||
}
|
||||
@@ -1,285 +0,0 @@
|
||||
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)}'}
|
||||
|
||||
|
||||
def get_invoicing_summary(env, params):
|
||||
"""Get invoicing summary — total invoiced by month, by partner, or for a date range.
|
||||
Supports: monthly breakdown for a year, current month totals, or filtered by partner."""
|
||||
from datetime import date, timedelta
|
||||
import calendar
|
||||
|
||||
year = int(params.get('year', date.today().year))
|
||||
partner_name = params.get('partner_name')
|
||||
date_from = params.get('date_from')
|
||||
date_to = params.get('date_to')
|
||||
|
||||
domain = [
|
||||
('move_type', '=', 'out_invoice'),
|
||||
('state', '=', 'posted'),
|
||||
('company_id', '=', env.company.id),
|
||||
]
|
||||
|
||||
if partner_name:
|
||||
partner = env['res.partner'].search([('name', 'ilike', partner_name)], limit=1)
|
||||
if partner:
|
||||
domain.append(('partner_id', '=', partner.id))
|
||||
else:
|
||||
return {'error': f'Partner not found: {partner_name}'}
|
||||
|
||||
if date_from and date_to:
|
||||
domain += [('date', '>=', date_from), ('date', '<=', date_to)]
|
||||
invoices = env['account.move'].search(domain, order='date desc')
|
||||
total = sum(inv.amount_total for inv in invoices)
|
||||
return {
|
||||
'period': f'{date_from} to {date_to}',
|
||||
'count': len(invoices),
|
||||
'total': total,
|
||||
'invoices': [{
|
||||
'id': inv.id, 'name': inv.name, 'partner': inv.partner_id.name,
|
||||
'date': str(inv.date), 'amount': inv.amount_total,
|
||||
'payment_state': inv.payment_state,
|
||||
} for inv in invoices[:30]],
|
||||
}
|
||||
|
||||
# Monthly breakdown for the year
|
||||
months = []
|
||||
grand_total = 0
|
||||
for month in range(1, 13):
|
||||
m_start = f'{year}-{month:02d}-01'
|
||||
last_day = calendar.monthrange(year, month)[1]
|
||||
m_end = f'{year}-{month:02d}-{last_day}'
|
||||
m_domain = domain + [('date', '>=', m_start), ('date', '<=', m_end)]
|
||||
invoices = env['account.move'].search(m_domain)
|
||||
total = sum(inv.amount_total for inv in invoices)
|
||||
grand_total += total
|
||||
months.append({
|
||||
'month': f'{year}-{month:02d}',
|
||||
'month_name': calendar.month_name[month],
|
||||
'count': len(invoices),
|
||||
'total': round(total, 2),
|
||||
})
|
||||
|
||||
return {
|
||||
'year': year,
|
||||
'grand_total': round(grand_total, 2),
|
||||
'months': months,
|
||||
'partner': partner_name or 'All',
|
||||
}
|
||||
|
||||
|
||||
def get_billing_summary(env, params):
|
||||
"""Get billing (vendor bills) summary — total billed by month or date range."""
|
||||
from datetime import date
|
||||
import calendar
|
||||
|
||||
year = int(params.get('year', date.today().year))
|
||||
partner_name = params.get('partner_name')
|
||||
date_from = params.get('date_from')
|
||||
date_to = params.get('date_to')
|
||||
|
||||
domain = [
|
||||
('move_type', '=', 'in_invoice'),
|
||||
('state', '=', 'posted'),
|
||||
('company_id', '=', env.company.id),
|
||||
]
|
||||
|
||||
if partner_name:
|
||||
partner = env['res.partner'].search([('name', 'ilike', partner_name)], limit=1)
|
||||
if partner:
|
||||
domain.append(('partner_id', '=', partner.id))
|
||||
else:
|
||||
return {'error': f'Partner not found: {partner_name}'}
|
||||
|
||||
if date_from and date_to:
|
||||
domain += [('date', '>=', date_from), ('date', '<=', date_to)]
|
||||
bills = env['account.move'].search(domain, order='date desc')
|
||||
total = sum(b.amount_total for b in bills)
|
||||
return {
|
||||
'period': f'{date_from} to {date_to}',
|
||||
'count': len(bills),
|
||||
'total': total,
|
||||
'bills': [{
|
||||
'id': b.id, 'name': b.name, 'partner': b.partner_id.name,
|
||||
'date': str(b.date), 'amount': b.amount_total,
|
||||
'payment_state': b.payment_state,
|
||||
} for b in bills[:30]],
|
||||
}
|
||||
|
||||
# Monthly breakdown
|
||||
months = []
|
||||
grand_total = 0
|
||||
for month in range(1, 13):
|
||||
m_start = f'{year}-{month:02d}-01'
|
||||
last_day = calendar.monthrange(year, month)[1]
|
||||
m_end = f'{year}-{month:02d}-{last_day}'
|
||||
m_domain = domain + [('date', '>=', m_start), ('date', '<=', m_end)]
|
||||
bills = env['account.move'].search(m_domain)
|
||||
total = sum(b.amount_total for b in bills)
|
||||
grand_total += total
|
||||
months.append({
|
||||
'month': f'{year}-{month:02d}',
|
||||
'month_name': calendar.month_name[month],
|
||||
'count': len(bills),
|
||||
'total': round(total, 2),
|
||||
})
|
||||
|
||||
return {
|
||||
'year': year,
|
||||
'grand_total': round(grand_total, 2),
|
||||
'months': months,
|
||||
'partner': partner_name or 'All',
|
||||
}
|
||||
|
||||
|
||||
def get_collections_summary(env, params):
|
||||
"""Get payment collections summary — how much was collected (received) in a period."""
|
||||
date_from = params.get('date_from')
|
||||
date_to = params.get('date_to')
|
||||
if not date_from or not date_to:
|
||||
from datetime import date
|
||||
today = date.today()
|
||||
date_from = date_from or f'{today.year}-{today.month:02d}-01'
|
||||
date_to = date_to or str(today)
|
||||
|
||||
payments = env['account.payment'].search([
|
||||
('payment_type', '=', 'inbound'),
|
||||
('state', '=', 'posted'),
|
||||
('date', '>=', date_from),
|
||||
('date', '<=', date_to),
|
||||
('company_id', '=', env.company.id),
|
||||
], order='date desc')
|
||||
|
||||
total = sum(p.amount for p in payments)
|
||||
by_partner = {}
|
||||
for p in payments:
|
||||
pname = p.partner_id.name if p.partner_id else 'Unknown'
|
||||
by_partner.setdefault(pname, {'count': 0, 'total': 0})
|
||||
by_partner[pname]['count'] += 1
|
||||
by_partner[pname]['total'] += p.amount
|
||||
|
||||
top_partners = sorted(by_partner.items(), key=lambda x: -x[1]['total'])[:15]
|
||||
|
||||
return {
|
||||
'period': f'{date_from} to {date_to}',
|
||||
'total_collected': round(total, 2),
|
||||
'payment_count': len(payments),
|
||||
'by_partner': [{'partner': k, 'count': v['count'], 'total': round(v['total'], 2)} for k, v in top_partners],
|
||||
}
|
||||
|
||||
|
||||
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,
|
||||
'get_invoicing_summary': get_invoicing_summary,
|
||||
'get_billing_summary': get_billing_summary,
|
||||
'get_collections_summary': get_collections_summary,
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 46 KiB |
@@ -1,37 +0,0 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
|
||||
export class FusionApprovalCard extends Component {
|
||||
static template = "fusion_accounting.ApprovalCard";
|
||||
static props = ["approval", "onApprove", "onReject"];
|
||||
|
||||
get toolLabel() {
|
||||
const name = this.props.approval.tool_name || "";
|
||||
// Short labels for common tools
|
||||
const labels = {
|
||||
create_payroll_journal_entry: "Payroll JE",
|
||||
create_vendor_bill: "Vendor Bill",
|
||||
register_bill_payment: "Bill Payment",
|
||||
create_expense_entry: "Expense",
|
||||
register_hst_payment: "HST Payment",
|
||||
apply_payment: "Payment",
|
||||
send_followup: "Follow-up",
|
||||
flag_entry: "Flag",
|
||||
};
|
||||
return labels[name] || name.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase());
|
||||
}
|
||||
|
||||
formatAmount(val) {
|
||||
if (!val) return "";
|
||||
return Number(val).toLocaleString("en-CA", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
approve() {
|
||||
this.props.onApprove(this.props.approval.id);
|
||||
}
|
||||
|
||||
reject() {
|
||||
this.props.onReject(this.props.approval.id);
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="fusion_accounting.ApprovalCard">
|
||||
<!-- Single row in the approval table — rendered inside <tbody> by chat_panel -->
|
||||
<tr class="fusion_approval_row">
|
||||
<td class="px-2 py-1 small text-nowrap" t-esc="toolLabel"/>
|
||||
<td class="px-2 py-1 small" style="white-space: pre-line; max-width: 320px;">
|
||||
<t t-esc="props.approval.summary || ''"/>
|
||||
</td>
|
||||
<td class="px-2 py-1 small text-end text-nowrap fw-semibold">
|
||||
<t t-if="props.approval.amount">
|
||||
$<t t-esc="formatAmount(props.approval.amount)"/>
|
||||
</t>
|
||||
</td>
|
||||
<td class="px-1 py-1 text-end text-nowrap">
|
||||
<button class="btn btn-success btn-xs px-2 py-0 me-1" t-on-click="approve"
|
||||
style="font-size: 0.75rem; line-height: 1.5;"
|
||||
title="Approve">
|
||||
<i class="fa fa-check"/>
|
||||
</button>
|
||||
<button class="btn btn-outline-danger btn-xs px-2 py-0" t-on-click="reject"
|
||||
style="font-size: 0.75rem; line-height: 1.5;"
|
||||
title="Reject">
|
||||
<i class="fa fa-times"/>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</t>
|
||||
</templates>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,297 +0,0 @@
|
||||
<?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 class="d-flex align-items-center">
|
||||
<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>
|
||||
<div class="d-flex gap-1 align-items-center">
|
||||
<!-- Session history button -->
|
||||
<button class="btn btn-outline-secondary btn-sm"
|
||||
t-on-click="toggleSessionPicker"
|
||||
title="Load previous session">
|
||||
<i class="fa fa-history"/>
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Session Picker Dropdown -->
|
||||
<t t-if="state.showSessionPicker">
|
||||
<div class="fusion_session_picker border-bottom">
|
||||
<div class="p-2 bg-body-tertiary">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<small class="fw-semibold text-muted">Recent Sessions</small>
|
||||
<button class="btn-close btn-close-sm" t-on-click="toggleSessionPicker"/>
|
||||
</div>
|
||||
<t t-if="state.sessionList.length === 0">
|
||||
<p class="text-muted small mb-0">No previous sessions found.</p>
|
||||
</t>
|
||||
<div class="fusion_session_list overflow-auto" style="max-height: 200px;">
|
||||
<t t-foreach="state.sessionList" t-as="sess" t-key="sess.id">
|
||||
<div class="fusion_session_item d-flex justify-content-between align-items-center p-2 rounded cursor-pointer"
|
||||
t-att-class="sess.id === state.internalSessionId ? 'bg-primary-subtle' : ''"
|
||||
t-on-click="() => this.loadSession(sess.id)">
|
||||
<div>
|
||||
<div class="small fw-semibold" t-esc="sess.name"/>
|
||||
<div class="text-muted" style="font-size: 0.72rem;">
|
||||
<t t-esc="formatSessionDate(sess.date)"/>
|
||||
<span class="ms-2" t-if="sess.message_count">
|
||||
<t t-esc="sess.message_count"/> msgs
|
||||
</span>
|
||||
<span class="ms-1 badge"
|
||||
t-att-class="sess.state === 'active' ? 'bg-success-subtle text-success' : 'bg-secondary-subtle text-secondary'"
|
||||
t-esc="sess.state"/>
|
||||
</div>
|
||||
</div>
|
||||
<i class="fa fa-chevron-right text-muted" style="font-size: 0.7rem;"/>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- 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-3">
|
||||
<i class="fa fa-robot fa-3x mb-2 d-block"/>
|
||||
<p class="mb-3">What would you like to work on?</p>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2 justify-content-center px-3 pb-3">
|
||||
<button class="btn btn-outline-secondary btn-sm fusion_starter"
|
||||
t-on-click="() => this.sendStarter('Reconcile the latest ADP payment on Scotia Current')">
|
||||
<i class="fa fa-exchange me-1"/>Match ADP Payment
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm fusion_starter"
|
||||
t-on-click="() => this.sendStarter('Show me unreconciled bank lines on all journals')">
|
||||
<i class="fa fa-bank me-1"/>Unreconciled Lines
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm fusion_starter"
|
||||
t-on-click="() => this.sendStarter('How much do we owe to Pride Mobility?')">
|
||||
<i class="fa fa-credit-card me-1"/>Vendor Balance
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm fusion_starter"
|
||||
t-on-click="() => this.sendStarter('Show me invoicing by month for this year')">
|
||||
<i class="fa fa-bar-chart me-1"/>Invoicing by Month
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm fusion_starter"
|
||||
t-on-click="() => this.sendStarter('How much are we collecting this month?')">
|
||||
<i class="fa fa-money me-1"/>Collections
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm fusion_starter"
|
||||
t-on-click="() => this.sendStarter('What is our current HST balance?')">
|
||||
<i class="fa fa-percent me-1"/>HST Balance
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm fusion_starter"
|
||||
t-on-click="() => this.sendStarter('Show me overdue invoices')">
|
||||
<i class="fa fa-exclamation-circle me-1"/>Overdue Invoices
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm fusion_starter"
|
||||
t-on-click="() => this.sendStarter('Run month-end close checklist')">
|
||||
<i class="fa fa-check-square-o me-1"/>Month-End Close
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm fusion_starter"
|
||||
t-on-click="() => this.sendStarter('Show me the P&L for this quarter')">
|
||||
<i class="fa fa-line-chart me-1"/>Profit & Loss
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm fusion_starter"
|
||||
t-on-click="() => this.sendStarter('Find duplicate bills')">
|
||||
<i class="fa fa-copy me-1"/>Duplicate Bills
|
||||
</button>
|
||||
</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
|
||||
<t t-if="msg.hasImage">
|
||||
<i class="fa fa-image ms-1 text-info" title="Image attached"/>
|
||||
</t>
|
||||
</small>
|
||||
<t t-if="msg.imageUrl">
|
||||
<img t-att-src="msg.imageUrl" class="rounded mb-1 d-block" style="max-height: 120px; max-width: 200px; cursor: pointer; object-fit: cover;"
|
||||
t-on-click="() => window.open(msg.imageUrl, '_blank')"/>
|
||||
</t>
|
||||
<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>
|
||||
<!-- Collapsible tool calls log (like Claude Code) -->
|
||||
<t t-if="msg.toolCalls and msg.toolCalls.length">
|
||||
<details class="fusion_tool_calls mb-2">
|
||||
<summary class="small text-muted cursor-pointer d-inline-flex align-items-center gap-1 user-select-none">
|
||||
<i class="fa fa-wrench" style="font-size: 0.7rem;"/>
|
||||
<span><t t-esc="msg.toolCalls.length"/> tool call<t t-if="msg.toolCalls.length > 1">s</t></span>
|
||||
</summary>
|
||||
<div class="mt-1 ms-2 border-start ps-2" style="border-color: var(--o-border-color) !important;">
|
||||
<t t-foreach="msg.toolCalls" t-as="tc" t-key="tc_index">
|
||||
<div class="d-flex align-items-start gap-1 py-1 small"
|
||||
style="line-height: 1.3;">
|
||||
<i t-att-class="'fa fa-fw ' + (tc.status === 'error' ? 'fa-times-circle text-danger' : tc.status === 'pending_approval' ? 'fa-clock-o text-warning' : 'fa-check-circle text-success')"
|
||||
style="font-size: 0.7rem; margin-top: 3px;"/>
|
||||
<span>
|
||||
<code class="small" style="font-size: 0.78rem;" t-esc="tc.name"/>
|
||||
<t t-if="tc.summary">
|
||||
<span class="text-muted ms-1">— <t t-esc="tc.summary"/></span>
|
||||
</t>
|
||||
<t t-if="tc.duration_ms">
|
||||
<span class="text-muted ms-1" style="font-size: 0.7rem;">(<t t-esc="tc.duration_ms"/>ms)</span>
|
||||
</t>
|
||||
</span>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</details>
|
||||
</t>
|
||||
<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 fusion_live_status">
|
||||
<small class="text-muted d-block mb-1">
|
||||
<i class="fa fa-robot me-1"/>Fusion AI
|
||||
</small>
|
||||
<!-- Live thinking text -->
|
||||
<t t-if="state.liveThinking">
|
||||
<div class="fusion_thinking_block mb-2 p-2 rounded small fst-italic"
|
||||
style="background: rgba(var(--bs-body-color-rgb), 0.03); border-left: 3px solid var(--bs-purple, #6f42c1); max-height: 120px; overflow-y: auto;">
|
||||
<i class="fa fa-brain me-1 text-purple" style="color: var(--bs-purple, #6f42c1);"/>
|
||||
<span t-esc="state.liveThinking"/>
|
||||
</div>
|
||||
</t>
|
||||
<!-- Live tool calls -->
|
||||
<t t-if="state.liveToolCalls.length > 0">
|
||||
<div class="mb-1">
|
||||
<t t-foreach="state.liveToolCalls" t-as="tc" t-key="tc_index">
|
||||
<div class="d-flex align-items-center gap-1 small py-1" style="line-height: 1.3;">
|
||||
<t t-if="tc.status === 'running'">
|
||||
<i class="fa fa-spinner fa-spin text-primary" style="font-size: 0.7rem;"/>
|
||||
</t>
|
||||
<t t-elif="tc.status === 'ok'">
|
||||
<i class="fa fa-check-circle text-success" style="font-size: 0.7rem;"/>
|
||||
</t>
|
||||
<t t-elif="tc.status === 'error'">
|
||||
<i class="fa fa-times-circle text-danger" style="font-size: 0.7rem;"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<i class="fa fa-clock-o text-warning" style="font-size: 0.7rem;"/>
|
||||
</t>
|
||||
<code class="small" style="font-size: 0.75rem;" t-esc="tc.name"/>
|
||||
<t t-if="tc.summary">
|
||||
<span class="text-muted">— <t t-esc="tc.summary"/></span>
|
||||
</t>
|
||||
<t t-if="tc.duration_ms">
|
||||
<span class="text-muted" style="font-size: 0.68rem;">(<t t-esc="tc.duration_ms"/>ms)</span>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
<!-- Default thinking indicator if no live data yet -->
|
||||
<t t-if="!state.liveThinking and state.liveToolCalls.length === 0">
|
||||
<i class="fa fa-spinner fa-spin me-1"/> Thinking...
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- Pending Approvals — compact table -->
|
||||
<t t-if="state.pendingApprovals.length > 0">
|
||||
<div class="border-top">
|
||||
<div class="d-flex justify-content-between align-items-center px-2 py-1 bg-warning-subtle">
|
||||
<small class="fw-semibold">
|
||||
<i class="fa fa-exclamation-triangle me-1 text-warning"/>
|
||||
<t t-esc="state.pendingApprovals.length"/> Pending Approval<t t-if="state.pendingApprovals.length > 1">s</t>
|
||||
</small>
|
||||
<div class="d-flex gap-1" t-if="state.pendingApprovals.length > 1">
|
||||
<button class="btn btn-success px-2 py-0" style="font-size: 0.75rem;"
|
||||
t-on-click="onApproveAll" title="Approve all">
|
||||
<i class="fa fa-check me-1"/>All
|
||||
</button>
|
||||
<button class="btn btn-outline-danger px-2 py-0" style="font-size: 0.75rem;"
|
||||
t-on-click="onRejectAll" title="Reject all">
|
||||
<i class="fa fa-times me-1"/>All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-auto" style="max-height: 280px;">
|
||||
<table class="table table-sm table-hover align-middle mb-0">
|
||||
<thead>
|
||||
<tr class="small text-muted">
|
||||
<th class="px-2 py-1 fw-semibold">Type</th>
|
||||
<th class="px-2 py-1 fw-semibold">Details</th>
|
||||
<th class="px-2 py-1 fw-semibold text-end">Amount</th>
|
||||
<th class="px-1 py-1 fw-semibold text-end" style="width: 80px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="state.pendingApprovals" t-as="approval" t-key="approval.id">
|
||||
<FusionApprovalCard
|
||||
approval="approval"
|
||||
onApprove.bind="onApprove"
|
||||
onReject.bind="onReject"/>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Input -->
|
||||
<div class="fusion_chat_input border-top p-2">
|
||||
<!-- Image preview -->
|
||||
<t t-if="state.pendingImage">
|
||||
<div class="fusion_image_preview d-flex align-items-center gap-2 mb-1 p-1 rounded bg-body-tertiary">
|
||||
<img t-att-src="state.pendingImage.dataUrl" class="rounded" style="max-height: 48px; max-width: 80px; object-fit: cover;"/>
|
||||
<small class="text-muted flex-grow-1 text-truncate" t-esc="state.pendingImage.name"/>
|
||||
<button class="btn btn-sm p-0 text-danger" t-on-click="clearImage" title="Remove">
|
||||
<i class="fa fa-times"/>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
<div class="input-group">
|
||||
<button class="btn btn-outline-secondary btn-sm" t-on-click="triggerFileUpload"
|
||||
title="Attach image (screenshot, remittance advice, etc.)">
|
||||
<i class="fa fa-paperclip"/>
|
||||
</button>
|
||||
<textarea
|
||||
t-ref="chatInput"
|
||||
class="form-control form-control-sm"
|
||||
placeholder="Ask Fusion AI... (paste screenshot with Ctrl+V)"
|
||||
rows="1"
|
||||
t-model="state.inputText"
|
||||
t-on-keydown="onKeyDown"
|
||||
t-on-paste="onPaste"/>
|
||||
<button class="btn btn-primary btn-sm" t-on-click="sendMessage"
|
||||
t-att-disabled="state.sending">
|
||||
<i class="fa fa-paper-plane"/>
|
||||
</button>
|
||||
</div>
|
||||
<input type="file" t-ref="fileInput" class="d-none" accept="image/*"
|
||||
t-on-change="onFileSelected"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -1,164 +0,0 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component, useState } from "@odoo/owl";
|
||||
|
||||
|
||||
export class FusionInteractiveTable extends Component {
|
||||
static template = "fusion_accounting.InteractiveTable";
|
||||
static props = ["tableData", "onTableAction"];
|
||||
|
||||
setup() {
|
||||
const rows = (this.props.tableData.rows || []).map((row) => ({
|
||||
...row,
|
||||
selected: false,
|
||||
userNote: "",
|
||||
}));
|
||||
this.state = useState({
|
||||
rows,
|
||||
selectAll: false,
|
||||
});
|
||||
}
|
||||
|
||||
get isInteractive() {
|
||||
return this.props.tableData.mode === "interactive";
|
||||
}
|
||||
|
||||
get columns() {
|
||||
return this.props.tableData.columns || [];
|
||||
}
|
||||
|
||||
get title() {
|
||||
return this.props.tableData.title || "";
|
||||
}
|
||||
|
||||
get actions() {
|
||||
return this.props.tableData.actions || [];
|
||||
}
|
||||
|
||||
get selectedCount() {
|
||||
return this.state.rows.filter((r) => r.selected).length;
|
||||
}
|
||||
|
||||
get hasAction() {
|
||||
return (action) => this.actions.includes(action);
|
||||
}
|
||||
|
||||
actionAvailable(action) {
|
||||
return this.actions.includes(action);
|
||||
}
|
||||
|
||||
recommendationClass(action) {
|
||||
switch (action) {
|
||||
case "dismiss":
|
||||
return "bg-success-subtle text-success";
|
||||
case "flag":
|
||||
return "bg-warning-subtle text-warning";
|
||||
case "create_rule":
|
||||
return "bg-info-subtle text-info";
|
||||
default:
|
||||
return "bg-secondary-subtle text-secondary";
|
||||
}
|
||||
}
|
||||
|
||||
recommendationLabel(action) {
|
||||
switch (action) {
|
||||
case "dismiss":
|
||||
return "Dismiss";
|
||||
case "flag":
|
||||
return "Flag";
|
||||
case "create_rule":
|
||||
return "Create Rule";
|
||||
default:
|
||||
return action || "Review";
|
||||
}
|
||||
}
|
||||
|
||||
onToggleSelectAll() {
|
||||
const newVal = !this.state.selectAll;
|
||||
this.state.selectAll = newVal;
|
||||
for (const row of this.state.rows) {
|
||||
row.selected = newVal;
|
||||
}
|
||||
}
|
||||
|
||||
onToggleRow(rowIndex) {
|
||||
this.state.rows[rowIndex].selected = !this.state.rows[rowIndex].selected;
|
||||
this.state.selectAll = this.state.rows.every((r) => r.selected);
|
||||
}
|
||||
|
||||
onNoteInput(rowIndex, ev) {
|
||||
this.state.rows[rowIndex].userNote = ev.target.value;
|
||||
}
|
||||
|
||||
_collectSelected() {
|
||||
return this.state.rows
|
||||
.filter((r) => r.selected)
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
cells: r.cells,
|
||||
recommendation: r.recommendation,
|
||||
userNote: r.userNote,
|
||||
}));
|
||||
}
|
||||
|
||||
_collectAllNotes() {
|
||||
return this.state.rows
|
||||
.filter((r) => r.userNote.trim())
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
cells: r.cells,
|
||||
recommendation: r.recommendation,
|
||||
userNote: r.userNote,
|
||||
}));
|
||||
}
|
||||
|
||||
onApplyRecommendations() {
|
||||
const selected = this._collectSelected();
|
||||
if (!selected.length) return;
|
||||
this.props.onTableAction({
|
||||
action: "apply_recommendations",
|
||||
source_tool: this.props.tableData.source_tool,
|
||||
rows: selected,
|
||||
});
|
||||
}
|
||||
|
||||
onFlagSelected() {
|
||||
const selected = this._collectSelected();
|
||||
if (!selected.length) return;
|
||||
this.props.onTableAction({
|
||||
action: "flag",
|
||||
source_tool: this.props.tableData.source_tool,
|
||||
rows: selected,
|
||||
});
|
||||
}
|
||||
|
||||
onCreateRules() {
|
||||
const selected = this._collectSelected();
|
||||
if (!selected.length) return;
|
||||
this.props.onTableAction({
|
||||
action: "create_rule",
|
||||
source_tool: this.props.tableData.source_tool,
|
||||
rows: selected,
|
||||
});
|
||||
}
|
||||
|
||||
onDismissSelected() {
|
||||
const selected = this._collectSelected();
|
||||
if (!selected.length) return;
|
||||
this.props.onTableAction({
|
||||
action: "dismiss",
|
||||
source_tool: this.props.tableData.source_tool,
|
||||
rows: selected,
|
||||
});
|
||||
}
|
||||
|
||||
onSubmitNotes() {
|
||||
const noted = this._collectAllNotes();
|
||||
if (!noted.length) return;
|
||||
this.props.onTableAction({
|
||||
action: "submit_notes",
|
||||
source_tool: this.props.tableData.source_tool,
|
||||
rows: noted,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="fusion_accounting.InteractiveTable">
|
||||
<div class="fusion_interactive_table my-2">
|
||||
<!-- Title -->
|
||||
<t t-if="title">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<i class="fa fa-table me-2 text-muted"/>
|
||||
<strong t-esc="title"/>
|
||||
<span class="badge bg-secondary-subtle text-secondary ms-2"
|
||||
t-esc="state.rows.length + ' rows'"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<!-- Checkbox column (interactive only) -->
|
||||
<t t-if="isInteractive">
|
||||
<th class="fit-content px-2">
|
||||
<input type="checkbox"
|
||||
class="form-check-input"
|
||||
t-att-checked="state.selectAll"
|
||||
t-on-change="onToggleSelectAll"/>
|
||||
</th>
|
||||
</t>
|
||||
<!-- Data columns -->
|
||||
<t t-foreach="columns" t-as="col" t-key="col_index">
|
||||
<th class="px-2 py-1" t-esc="col"/>
|
||||
</t>
|
||||
<!-- AI Recommendation column (interactive only) -->
|
||||
<t t-if="isInteractive">
|
||||
<th class="px-2 py-1 text-info">AI Recommendation</th>
|
||||
<th class="px-2 py-1 text-warning" style="min-width: 180px;">Your Input</th>
|
||||
</t>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="state.rows" t-as="row" t-key="row_index">
|
||||
<tr t-att-class="row.selected ? 'table-active' : ''">
|
||||
<!-- Checkbox -->
|
||||
<t t-if="isInteractive">
|
||||
<td class="fit-content px-2">
|
||||
<input type="checkbox"
|
||||
class="form-check-input"
|
||||
t-att-checked="row.selected"
|
||||
t-on-change="() => this.onToggleRow(row_index)"/>
|
||||
</td>
|
||||
</t>
|
||||
<!-- Data cells -->
|
||||
<t t-foreach="row.cells" t-as="cell" t-key="cell_index">
|
||||
<td class="px-2 py-1" t-esc="cell"/>
|
||||
</t>
|
||||
<!-- AI Recommendation -->
|
||||
<t t-if="isInteractive">
|
||||
<td class="px-2 py-1">
|
||||
<t t-if="row.recommendation">
|
||||
<span t-att-class="'badge me-1 ' + recommendationClass(row.recommendation.action)"
|
||||
t-esc="recommendationLabel(row.recommendation.action)"/>
|
||||
<small class="text-muted" t-esc="row.recommendation.reason"/>
|
||||
</t>
|
||||
</td>
|
||||
<!-- User input -->
|
||||
<td class="px-2 py-1">
|
||||
<input type="text"
|
||||
class="form-control form-control-sm fusion_row_note"
|
||||
placeholder="Add your note..."
|
||||
t-att-value="row.userNote"
|
||||
t-on-input="(ev) => this.onNoteInput(row_index, ev)"/>
|
||||
</td>
|
||||
</t>
|
||||
</tr>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Action Bar (interactive only) -->
|
||||
<t t-if="isInteractive">
|
||||
<div class="fusion_table_action_bar d-flex flex-wrap align-items-center gap-2 p-2 border-top">
|
||||
<small class="text-muted me-1">
|
||||
<t t-esc="selectedCount"/> selected
|
||||
</small>
|
||||
<button class="btn btn-success btn-sm"
|
||||
t-att-disabled="selectedCount === 0"
|
||||
t-on-click="onApplyRecommendations">
|
||||
<i class="fa fa-check me-1"/>Apply Recommendations
|
||||
</button>
|
||||
<t t-if="actionAvailable('flag')">
|
||||
<button class="btn btn-outline-warning btn-sm"
|
||||
t-att-disabled="selectedCount === 0"
|
||||
t-on-click="onFlagSelected">
|
||||
<i class="fa fa-flag me-1"/>Flag Selected
|
||||
</button>
|
||||
</t>
|
||||
<t t-if="actionAvailable('create_rule')">
|
||||
<button class="btn btn-outline-info btn-sm"
|
||||
t-att-disabled="selectedCount === 0"
|
||||
t-on-click="onCreateRules">
|
||||
<i class="fa fa-plus me-1"/>Create Rules
|
||||
</button>
|
||||
</t>
|
||||
<t t-if="actionAvailable('dismiss')">
|
||||
<button class="btn btn-outline-secondary btn-sm"
|
||||
t-att-disabled="selectedCount === 0"
|
||||
t-on-click="onDismissSelected">
|
||||
Dismiss Selected
|
||||
</button>
|
||||
</t>
|
||||
<div class="flex-grow-1"/>
|
||||
<button class="btn btn-outline-primary btn-sm"
|
||||
t-on-click="onSubmitNotes">
|
||||
<i class="fa fa-pencil me-1"/>Submit All Notes to AI
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -1,108 +0,0 @@
|
||||
/** @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 onAttentionClick(domain, prompt) {
|
||||
// Type the prompt into the chat input and send
|
||||
if (!prompt) return;
|
||||
const textarea = this.el?.querySelector('.fusion_chat_input textarea');
|
||||
if (textarea) {
|
||||
// Set value and trigger OWL's model update via input event
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLTextAreaElement.prototype, 'value'
|
||||
).set;
|
||||
nativeInputValueSetter.call(textarea, prompt);
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
// Click send button
|
||||
setTimeout(() => {
|
||||
const sendBtn = this.el?.querySelector('.fusion_chat_input .btn-primary');
|
||||
if (sendBtn) sendBtn.click();
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
|
||||
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).toLocaleString('en-CA', {minimumFractionDigits: 0})} total`,
|
||||
domain: "bank_reconciliation",
|
||||
status: d.bank_recon.count === 0 ? "green" : d.bank_recon.count < 10 ? "yellow" : "red",
|
||||
},
|
||||
{
|
||||
title: "AR Outstanding",
|
||||
metric: `$${Math.abs(d.ar.total || 0).toLocaleString('en-CA', {minimumFractionDigits: 0})}`,
|
||||
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).toLocaleString('en-CA', {minimumFractionDigits: 0})}`,
|
||||
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).toLocaleString('en-CA', {minimumFractionDigits: 0})}`,
|
||||
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);
|
||||
@@ -1,78 +0,0 @@
|
||||
<?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 px-3 py-2">
|
||||
<h4 class="mb-0"><i class="fa fa-bolt me-2"/>Fusion AI</h4>
|
||||
<button class="btn btn-outline-secondary btn-sm" t-on-click="loadDashboard">
|
||||
<i class="fa fa-refresh me-1"/>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 text-muted">Loading dashboard...</p>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-else="">
|
||||
<div class="fusion_main_layout d-flex">
|
||||
|
||||
<!-- LEFT: Cards + Needs Attention -->
|
||||
<div class="fusion_left_panel d-flex flex-column p-3 gap-3">
|
||||
|
||||
<div class="fusion_health_cards">
|
||||
<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="onAttentionClick"/>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- Needs Attention -->
|
||||
<div class="fusion_attention_panel flex-grow-1 d-flex flex-column">
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
<i class="fa fa-bell text-warning"/>
|
||||
<span class="fw-semibold small">Needs Attention</span>
|
||||
<t t-if="state.data and state.data.needs_attention">
|
||||
<span class="badge bg-warning text-dark" t-esc="state.data.needs_attention.length"/>
|
||||
</t>
|
||||
</div>
|
||||
<div class="fusion_attention_list flex-grow-1 overflow-auto">
|
||||
<t t-if="state.data and state.data.needs_attention and state.data.needs_attention.length">
|
||||
<t t-foreach="state.data.needs_attention" t-as="item" t-key="item_index">
|
||||
<div class="fusion_attention_item d-flex align-items-start gap-2 p-2 rounded cursor-pointer"
|
||||
t-on-click="() => this.onAttentionClick(item.domain, item.prompt)">
|
||||
<div t-attf-class="fusion_attn_dot fusion_attn_{{item.severity || 'warning'}}"/>
|
||||
<div class="flex-grow-1 small">
|
||||
<div class="fw-semibold" t-esc="item.title"/>
|
||||
<div class="text-muted" style="font-size: 0.75rem;" t-esc="item.action"/>
|
||||
</div>
|
||||
<i class="fa fa-chevron-right text-muted mt-1" style="font-size: 0.6rem;"/>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="text-center text-muted small py-3">
|
||||
<i class="fa fa-check-circle fa-2x mb-2 d-block text-success"/>
|
||||
All clear! No items need attention.
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT: Chat -->
|
||||
<div class="fusion_right_panel">
|
||||
<FusionChatPanel sessionId="state.chatSessionId"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -1,24 +0,0 @@
|
||||
/** @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 icon() {
|
||||
const icons = {
|
||||
bank_reconciliation: "fa-bank",
|
||||
accounts_receivable: "fa-file-text-o",
|
||||
accounts_payable: "fa-credit-card",
|
||||
hst_management: "fa-percent",
|
||||
audit: "fa-shield",
|
||||
month_end: "fa-calendar-check-o",
|
||||
};
|
||||
return icons[this.props.domain] || "fa-bar-chart";
|
||||
}
|
||||
|
||||
onClick() {
|
||||
this.props.onCardClick(this.props.domain);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="fusion_accounting.HealthCard">
|
||||
<div class="fusion_health_card cursor-pointer"
|
||||
t-attf-class="fusion_card_{{props.status}}"
|
||||
t-on-click="onClick">
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
<div class="fusion_card_icon">
|
||||
<i t-attf-class="fa {{icon}}"/>
|
||||
</div>
|
||||
<span class="fusion_card_title" t-esc="props.title"/>
|
||||
</div>
|
||||
<div class="fusion_card_metric" t-esc="props.metric"/>
|
||||
<div class="fusion_card_sub" t-esc="props.subtext"/>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -1,338 +0,0 @@
|
||||
.fusion_chat_panel {
|
||||
.fusion_chat_messages {
|
||||
max-height: 500px;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.fusion_chat_msg {
|
||||
word-break: break-word;
|
||||
animation: fusionFadeIn 0.25s ease;
|
||||
}
|
||||
|
||||
.fusion_ai_msg {
|
||||
background: var(--o-view-background-color);
|
||||
border: 1px solid var(--o-border-color);
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
// Live thinking block
|
||||
.fusion_live_status {
|
||||
animation: fusionPulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.fusion_thinking_block {
|
||||
animation: fusionFadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
// Session picker dropdown
|
||||
.fusion_session_picker {
|
||||
flex-shrink: 0;
|
||||
|
||||
.fusion_session_item {
|
||||
transition: background 0.15s ease;
|
||||
&:hover {
|
||||
background: rgba(var(--bs-body-color-rgb), 0.06);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Conversation starters
|
||||
.fusion_starter.btn {
|
||||
font-size: 0.8rem;
|
||||
border-radius: 1rem;
|
||||
padding: 0.3rem 0.8rem;
|
||||
transition: all 0.15s ease;
|
||||
background: transparent;
|
||||
color: var(--bs-body-color);
|
||||
border-color: var(--o-border-color, var(--bs-border-color));
|
||||
&:hover, &:focus, &:active {
|
||||
background: var(--o-action-color, #714B67) !important;
|
||||
color: #fff !important;
|
||||
border-color: var(--o-action-color, #714B67) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.fusion_chat_input {
|
||||
flex-shrink: 0;
|
||||
|
||||
textarea {
|
||||
resize: none;
|
||||
font-size: 0.88rem;
|
||||
min-height: 42px;
|
||||
max-height: 120px;
|
||||
line-height: 1.5;
|
||||
// Vertically centre single-line placeholder
|
||||
padding: 0.5rem 0.75rem;
|
||||
&::placeholder { opacity: 0.5; }
|
||||
}
|
||||
|
||||
// Wider send button
|
||||
.btn-primary {
|
||||
min-width: 52px;
|
||||
padding-left: 0.85rem;
|
||||
padding-right: 0.85rem;
|
||||
}
|
||||
|
||||
// Paperclip button
|
||||
.btn-outline-secondary {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
|
||||
.fusion_image_preview {
|
||||
animation: fusionFadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
.fusion_approval_row {
|
||||
td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
&:hover {
|
||||
background: rgba(var(--bs-body-color-rgb), 0.04);
|
||||
}
|
||||
}
|
||||
|
||||
// Collapsible tool calls log
|
||||
.fusion_tool_calls {
|
||||
summary {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
&::-webkit-details-marker { display: none; }
|
||||
&::before {
|
||||
content: "\f105"; // fa-angle-right
|
||||
font-family: FontAwesome;
|
||||
display: inline-block;
|
||||
width: 0.8em;
|
||||
font-size: 0.75rem;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
}
|
||||
&[open] > summary::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
code {
|
||||
color: var(--o-action-color, var(--bs-primary));
|
||||
background: rgba(var(--bs-body-color-rgb), 0.04);
|
||||
padding: 0.1em 0.3em;
|
||||
border-radius: 0.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
// Reconciliation table styles
|
||||
.fusion_recon_table {
|
||||
border: 1px solid var(--o-border-color);
|
||||
border-radius: 0.375rem;
|
||||
overflow: hidden;
|
||||
background: var(--o-view-background-color);
|
||||
|
||||
.table {
|
||||
font-size: 0.82rem;
|
||||
margin-bottom: 0;
|
||||
|
||||
thead th {
|
||||
font-weight: 600;
|
||||
font-size: 0.78rem;
|
||||
white-space: nowrap;
|
||||
background: rgba(var(--bs-body-color-rgb), 0.03);
|
||||
border-bottom: 2px solid var(--o-border-color);
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
transition: background-color 0.15s ease;
|
||||
&:hover { background: rgba(var(--bs-body-color-rgb), 0.04); }
|
||||
}
|
||||
|
||||
.fit-content { width: 1%; white-space: nowrap; }
|
||||
}
|
||||
|
||||
.fusion_apply_amount {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.15rem 0.35rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--o-border-color);
|
||||
color: inherit;
|
||||
text-align: right;
|
||||
|
||||
&:focus {
|
||||
background: var(--o-view-background-color);
|
||||
border-color: var(--o-action-color, var(--bs-primary));
|
||||
box-shadow: 0 0 0 0.15rem rgba(var(--bs-primary-rgb), 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.fusion_recon_search {
|
||||
background: rgba(var(--bs-body-color-rgb), 0.02);
|
||||
}
|
||||
|
||||
.fusion_search_results {
|
||||
position: absolute;
|
||||
left: 0.5rem;
|
||||
right: 0.5rem;
|
||||
top: 100%;
|
||||
z-index: 10;
|
||||
background: var(--o-view-background-color);
|
||||
border: 1px solid var(--o-border-color);
|
||||
border-radius: 0.375rem;
|
||||
box-shadow: 0 4px 12px rgba(var(--bs-body-color-rgb), 0.15);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
|
||||
.list-group-item {
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--o-border-color);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--bs-primary-rgb), 0.06);
|
||||
}
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
}
|
||||
|
||||
.fusion_match_total {
|
||||
background: rgba(var(--bs-body-color-rgb), 0.02);
|
||||
}
|
||||
}
|
||||
|
||||
// Interactive table styles
|
||||
.fusion_interactive_table {
|
||||
border: 1px solid var(--o-border-color);
|
||||
border-radius: 0.375rem;
|
||||
overflow: hidden;
|
||||
background: var(--o-view-background-color);
|
||||
|
||||
.table {
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0;
|
||||
|
||||
thead th {
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
white-space: nowrap;
|
||||
background: rgba(var(--bs-body-color-rgb), 0.03);
|
||||
border-bottom: 2px solid var(--o-border-color);
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--bs-body-color-rgb), 0.04);
|
||||
}
|
||||
|
||||
&.table-active {
|
||||
background: rgba(var(--bs-primary-rgb), 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.fit-content {
|
||||
width: 1%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// Row note input
|
||||
.fusion_row_note {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.2rem 0.4rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--o-border-color);
|
||||
color: inherit;
|
||||
|
||||
&:focus {
|
||||
background: var(--o-view-background-color);
|
||||
border-color: var(--o-action-color, var(--bs-primary));
|
||||
box-shadow: 0 0 0 0.15rem rgba(var(--bs-primary-rgb), 0.15);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
// Recommendation badges
|
||||
.badge {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
padding: 0.2em 0.5em;
|
||||
}
|
||||
|
||||
// Action bar at bottom
|
||||
.fusion_table_action_bar {
|
||||
background: rgba(var(--bs-body-color-rgb), 0.02);
|
||||
border-top: 1px solid var(--o-border-color);
|
||||
|
||||
.btn-sm {
|
||||
font-size: 0.78rem;
|
||||
padding: 0.25rem 0.6rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Animations
|
||||
// ================================================================
|
||||
@keyframes fusionFadeIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes fusionPulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
.fusion_accounting_dashboard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
.fusion_dashboard_header {
|
||||
border-bottom: 1px solid var(--o-border-color);
|
||||
background: var(--o-view-background-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Main two-column layout
|
||||
// ================================================================
|
||||
.fusion_main_layout {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// Left panel — padding matches right panel so both columns align
|
||||
.fusion_left_panel {
|
||||
width: 50%;
|
||||
min-width: 400px;
|
||||
max-width: 600px;
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0;
|
||||
padding: 0.75rem !important;
|
||||
}
|
||||
|
||||
// Right panel
|
||||
.fusion_right_panel {
|
||||
flex: 1;
|
||||
min-width: 500px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 0.75rem 0.75rem 0.75rem 0;
|
||||
|
||||
.fusion_chat_panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
min-height: 0;
|
||||
height: auto !important;
|
||||
overflow: hidden;
|
||||
|
||||
html[data-color-scheme="dark"] &,
|
||||
body.o_dark & {
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
flex-shrink: 0;
|
||||
border-radius: 0.75rem 0.75rem 0 0;
|
||||
}
|
||||
|
||||
.fusion_chat_messages {
|
||||
max-height: none !important;
|
||||
min-height: 0 !important;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.fusion_chat_input {
|
||||
flex-shrink: 0;
|
||||
border-radius: 0 0 0.75rem 0.75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Health Cards — modern rounded design
|
||||
// ================================================================
|
||||
.fusion_health_cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fusion_health_card {
|
||||
border-radius: 0.75rem;
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
border: 1px solid #dee2e6;
|
||||
background: var(--o-view-background-color, #fff);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
// Dark mode adjustments
|
||||
html[data-color-scheme="dark"] &,
|
||||
body.o_dark & {
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
&:hover {
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.fusion_card_icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.85rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fusion_card_title {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--bs-secondary-color, #6c757d);
|
||||
}
|
||||
|
||||
.fusion_card_metric {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.fusion_card_sub {
|
||||
font-size: 0.75rem;
|
||||
color: var(--bs-secondary-color, #6c757d);
|
||||
}
|
||||
|
||||
// Status-based left border + icon colour
|
||||
&.fusion_card_green {
|
||||
border-left: 3px solid var(--bs-success);
|
||||
.fusion_card_icon { background: rgba(var(--bs-success-rgb), 0.12); color: var(--bs-success); }
|
||||
}
|
||||
&.fusion_card_yellow {
|
||||
border-left: 3px solid var(--bs-warning);
|
||||
.fusion_card_icon { background: rgba(var(--bs-warning-rgb), 0.15); color: var(--bs-warning); }
|
||||
}
|
||||
&.fusion_card_red {
|
||||
border-left: 3px solid var(--bs-danger);
|
||||
.fusion_card_icon { background: rgba(var(--bs-danger-rgb), 0.12); color: var(--bs-danger); }
|
||||
}
|
||||
&.fusion_card_blue {
|
||||
border-left: 3px solid var(--bs-info);
|
||||
.fusion_card_icon { background: rgba(var(--bs-info-rgb), 0.12); color: var(--bs-info); }
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Needs Attention panel
|
||||
// ================================================================
|
||||
.fusion_attention_panel {
|
||||
background: var(--o-view-background-color, #fff);
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
min-height: 150px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
|
||||
html[data-color-scheme="dark"] &,
|
||||
body.o_dark & {
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.fusion_attention_item {
|
||||
transition: background 0.15s ease;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
html[data-color-scheme="dark"] &,
|
||||
body.o_dark & {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.06);
|
||||
&:hover { background: rgba(255, 255, 255, 0.04); }
|
||||
}
|
||||
}
|
||||
|
||||
.fusion_attn_dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
margin-top: 5px;
|
||||
|
||||
&.fusion_attn_danger { background: var(--bs-danger); }
|
||||
&.fusion_attn_warning { background: var(--bs-warning); }
|
||||
&.fusion_attn_info { background: var(--bs-info); }
|
||||
&.fusion_attn_muted { background: var(--bs-secondary); }
|
||||
}
|
||||
}
|
||||
|
||||
// Full height in Odoo's action container
|
||||
.o_action_manager {
|
||||
.o_action.fusion_accounting_dashboard {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
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 ===')
|
||||
@@ -1,107 +0,0 @@
|
||||
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 ===')
|
||||
@@ -1,49 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,8 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,137 +0,0 @@
|
||||
<?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" default_order="proposed_at desc">
|
||||
<field name="proposed_at" string="Date"/>
|
||||
<field name="session_id" string="Session"/>
|
||||
<field name="tool_display_name" string="Tool"/>
|
||||
<field name="tool_name" string="Tool (Code)" optional="hide"/>
|
||||
<field name="decision" widget="badge"
|
||||
decoration-success="decision in ('approved', 'auto')"
|
||||
decoration-danger="decision == 'rejected'"
|
||||
decoration-warning="decision == 'pending'"/>
|
||||
<field name="ai_confidence" string="Confidence" widget="progressbar"/>
|
||||
<field name="amount" string="Amount"/>
|
||||
<field name="partner_id" string="Partner"/>
|
||||
<field name="ai_reasoning" string="Reasoning" optional="hide"/>
|
||||
<field name="decided_by" string="Decided By" optional="hide"/>
|
||||
<field name="decided_at" string="Decided At" optional="hide"/>
|
||||
</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"/>
|
||||
<field name="decision" widget="statusbar"
|
||||
statusbar_visible="pending,approved,rejected,auto"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_title mb-3">
|
||||
<h1>
|
||||
<field name="tool_display_name" readonly="1"/>
|
||||
</h1>
|
||||
<div class="text-muted small">
|
||||
Internal: <field name="tool_name" readonly="1" class="d-inline"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<group>
|
||||
<group string="Request Details">
|
||||
<field name="session_id"/>
|
||||
<field name="proposed_at" string="When"/>
|
||||
<field name="ai_confidence" widget="progressbar" string="Confidence"/>
|
||||
<field name="amount"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="rule_id" invisible="not rule_id"/>
|
||||
</group>
|
||||
<group string="Decision">
|
||||
<field name="decided_by"/>
|
||||
<field name="decided_at"/>
|
||||
<field name="rejection_reason"
|
||||
invisible="decision != 'rejected'"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<notebook>
|
||||
<page string="AI Reasoning" name="reasoning">
|
||||
<field name="ai_reasoning" widget="text"
|
||||
placeholder="No AI reasoning recorded for this tool call."
|
||||
nolabel="1"/>
|
||||
</page>
|
||||
<page string="Parameters" name="params">
|
||||
<field name="tool_params_pretty" widget="text"
|
||||
nolabel="1" readonly="1"/>
|
||||
</page>
|
||||
<page string="Result" name="result">
|
||||
<field name="tool_result_pretty" widget="text"
|
||||
nolabel="1" readonly="1"/>
|
||||
</page>
|
||||
<page string="Correction" name="correction"
|
||||
invisible="decision != 'rejected'">
|
||||
<group>
|
||||
<field name="rejection_reason" string="Why was this rejected?"/>
|
||||
<field name="correct_action" widget="text"
|
||||
string="What should have been done instead?"/>
|
||||
</group>
|
||||
</page>
|
||||
</notebook>
|
||||
|
||||
<group string="Linked Records" invisible="not bank_statement_line_id">
|
||||
<field name="bank_statement_line_id"/>
|
||||
<field name="move_line_ids" widget="many2many_tags"/>
|
||||
</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"/>
|
||||
<field name="session_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')]"/>
|
||||
<filter name="auto" string="Auto-Executed" domain="[('decision', '=', 'auto')]"/>
|
||||
<separator/>
|
||||
<filter name="today" string="Today" domain="[('proposed_at', '>=', (context_today()).strftime('%Y-%m-%d'))]"/>
|
||||
<filter name="this_week" string="This Week" domain="[('proposed_at', '>=', (context_today() - datetime.timedelta(days=7)).strftime('%Y-%m-%d'))]"/>
|
||||
<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'}"/>
|
||||
<filter name="group_session" string="Session" domain="[]" context="{'group_by': 'session_id'}"/>
|
||||
</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="context">{'search_default_today': 1}</field>
|
||||
<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>
|
||||
@@ -1,62 +0,0 @@
|
||||
<?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"/>
|
||||
|
||||
<!-- Vendor Tax Profiles -->
|
||||
<menuitem id="menu_fusion_vendor_profiles"
|
||||
name="Vendor Tax Profiles"
|
||||
parent="menu_fusion_accounting_root"
|
||||
action="action_vendor_tax_profiles"
|
||||
sequence="50"
|
||||
groups="group_fusion_accounting_manager"/>
|
||||
|
||||
<!-- Recurring Patterns -->
|
||||
<menuitem id="menu_fusion_recurring_patterns"
|
||||
name="Recurring Patterns"
|
||||
parent="menu_fusion_accounting_root"
|
||||
action="action_recurring_patterns"
|
||||
sequence="55"
|
||||
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>
|
||||
@@ -1,86 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<record id="view_recurring_pattern_list" model="ir.ui.view">
|
||||
<field name="name">fusion.recurring.pattern.list</field>
|
||||
<field name="model">fusion.recurring.pattern</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="name"/>
|
||||
<field name="ref_keyword"/>
|
||||
<field name="amount"/>
|
||||
<field name="expense_account_code" string="Account"/>
|
||||
<field name="has_hst"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="reconcile_model_id"/>
|
||||
<field name="occurrences"/>
|
||||
<field name="last_seen"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_recurring_pattern_form" model="ir.ui.view">
|
||||
<field name="name">fusion.recurring.pattern.form</field>
|
||||
<field name="model">fusion.recurring.pattern</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<group>
|
||||
<group string="Pattern">
|
||||
<field name="name"/>
|
||||
<field name="ref_keyword"/>
|
||||
<field name="amount"/>
|
||||
<field name="amount_is_fixed"/>
|
||||
<field name="journal_id"/>
|
||||
</group>
|
||||
<group string="Coding">
|
||||
<field name="expense_account_id"/>
|
||||
<field name="has_hst"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="reconcile_model_id"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Statistics">
|
||||
<group>
|
||||
<field name="occurrences"/>
|
||||
<field name="first_seen"/>
|
||||
<field name="last_seen"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
<field name="last_computed"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="AI Instructions">
|
||||
<field name="action_note" nolabel="1" colspan="2"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_recurring_pattern_search" model="ir.ui.view">
|
||||
<field name="name">fusion.recurring.pattern.search</field>
|
||||
<field name="model">fusion.recurring.pattern</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="name"/>
|
||||
<field name="ref_keyword"/>
|
||||
<separator/>
|
||||
<filter name="has_hst" string="Has HST" domain="[('has_hst', '=', True)]"/>
|
||||
<filter name="no_hst" string="No HST" domain="[('has_hst', '=', False)]"/>
|
||||
<filter name="has_reco_model" string="Has Reco Model" domain="[('reconcile_model_id', '!=', False)]"/>
|
||||
<separator/>
|
||||
<group>
|
||||
<filter name="group_account" string="Account" domain="[]" context="{'group_by': 'expense_account_id'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_recurring_patterns" model="ir.actions.act_window">
|
||||
<field name="name">Recurring Patterns</field>
|
||||
<field name="res_model">fusion.recurring.pattern</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="view_recurring_pattern_search"/>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -1,113 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,105 +0,0 @@
|
||||
<?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,95 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<!-- Tree View -->
|
||||
<record id="view_vendor_tax_profile_tree" model="ir.ui.view">
|
||||
<field name="name">fusion.vendor.tax.profile.tree</field>
|
||||
<field name="model">fusion.vendor.tax.profile</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="partner_id"/>
|
||||
<field name="tax_classification" widget="badge"
|
||||
decoration-success="tax_classification == 'always_hst'"
|
||||
decoration-warning="tax_classification in ('shipping_only', 'mixed')"
|
||||
decoration-danger="tax_classification == 'never_hst'"
|
||||
decoration-info="tax_classification == 'mostly_hst'"/>
|
||||
<field name="total_bills"/>
|
||||
<field name="bills_with_hst"/>
|
||||
<field name="bills_zero_rated"/>
|
||||
<field name="avg_tax_pct" string="Avg Tax %"/>
|
||||
<field name="primary_account_code" string="Primary Account"/>
|
||||
<field name="is_po_vendor"/>
|
||||
<field name="po_count" optional="hide"/>
|
||||
<field name="is_foreign"/>
|
||||
<field name="last_computed"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Form View -->
|
||||
<record id="view_vendor_tax_profile_form" model="ir.ui.view">
|
||||
<field name="name">fusion.vendor.tax.profile.form</field>
|
||||
<field name="model">fusion.vendor.tax.profile</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<group>
|
||||
<group string="Vendor">
|
||||
<field name="partner_id"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
<field name="is_foreign"/>
|
||||
<field name="vendor_country"/>
|
||||
</group>
|
||||
<group string="Tax Classification">
|
||||
<field name="tax_classification"/>
|
||||
<field name="avg_tax_pct"/>
|
||||
<field name="primary_account_id"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Bill Statistics">
|
||||
<group>
|
||||
<field name="total_bills"/>
|
||||
<field name="bills_with_hst"/>
|
||||
<field name="bills_zero_rated"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="last_computed"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="AI Tax Note">
|
||||
<field name="tax_note" nolabel="1" colspan="2"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Search View -->
|
||||
<record id="view_vendor_tax_profile_search" model="ir.ui.view">
|
||||
<field name="name">fusion.vendor.tax.profile.search</field>
|
||||
<field name="model">fusion.vendor.tax.profile</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="partner_id"/>
|
||||
<field name="tax_classification"/>
|
||||
<separator/>
|
||||
<filter name="always_hst" string="Always HST" domain="[('tax_classification', '=', 'always_hst')]"/>
|
||||
<filter name="never_hst" string="Never HST" domain="[('tax_classification', '=', 'never_hst')]"/>
|
||||
<filter name="shipping_only" string="Shipping Only" domain="[('tax_classification', '=', 'shipping_only')]"/>
|
||||
<filter name="mixed" string="Mixed" domain="[('tax_classification', 'in', ('mixed', 'mostly_hst'))]"/>
|
||||
<filter name="foreign" string="Foreign Vendors" domain="[('is_foreign', '=', True)]"/>
|
||||
<separator/>
|
||||
<group>
|
||||
<filter name="group_classification" string="Classification" domain="[]" context="{'group_by': 'tax_classification'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action -->
|
||||
<record id="action_vendor_tax_profiles" model="ir.actions.act_window">
|
||||
<field name="name">Vendor Tax Profiles</field>
|
||||
<field name="res_model">fusion.vendor.tax.profile</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="search_view_id" ref="view_vendor_tax_profile_search"/>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -1 +0,0 @@
|
||||
from . import rule_wizard
|
||||
@@ -1,42 +0,0 @@
|
||||
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',
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
<?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