changes
This commit is contained in:
7
fusion_accounting/models/__init__.py
Normal file
7
fusion_accounting/models/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from . import accounting_config
|
||||
from . import accounting_tool
|
||||
from . import accounting_session
|
||||
from . import accounting_match_history
|
||||
from . import accounting_rule
|
||||
from . import accounting_dashboard
|
||||
from . import account_move_hook
|
||||
53
fusion_accounting/models/account_move_hook.py
Normal file
53
fusion_accounting/models/account_move_hook.py
Normal file
@@ -0,0 +1,53 @@
|
||||
import logging
|
||||
|
||||
from odoo import models, api
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AccountMoveAuditHook(models.Model):
|
||||
_inherit = 'account.move'
|
||||
|
||||
def action_post(self):
|
||||
res = super().action_post()
|
||||
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
if ICP.get_param('fusion_accounting.enable_post_audit', 'False') != 'True':
|
||||
return res
|
||||
|
||||
for move in self:
|
||||
try:
|
||||
self._fusion_audit_posted_entry(move)
|
||||
except Exception as e:
|
||||
_logger.warning("Fusion post-audit hook failed for %s: %s", move.name, e)
|
||||
|
||||
return res
|
||||
|
||||
def _fusion_audit_posted_entry(self, move):
|
||||
issues = []
|
||||
|
||||
total_debit = sum(l.debit for l in move.line_ids)
|
||||
total_credit = sum(l.credit for l in move.line_ids)
|
||||
if abs(total_debit - total_credit) > 0.01:
|
||||
issues.append(f'Unbalanced: debit={total_debit:.2f}, credit={total_credit:.2f}')
|
||||
|
||||
for line in move.line_ids:
|
||||
if not line.account_id:
|
||||
issues.append(f'Line missing account: {line.name}')
|
||||
if line.product_id and not line.tax_ids:
|
||||
if move.move_type in ('out_invoice', 'out_refund', 'in_invoice', 'in_refund'):
|
||||
issues.append(f'Missing tax on product line: {line.product_id.name}')
|
||||
|
||||
if not move.line_ids:
|
||||
issues.append('Entry has no lines')
|
||||
|
||||
if issues:
|
||||
body_parts = ['<strong>Fusion AI Auto-Audit</strong><ul>']
|
||||
for issue in issues:
|
||||
body_parts.append(f'<li>{issue}</li>')
|
||||
body_parts.append('</ul>')
|
||||
move.message_post(
|
||||
body=''.join(body_parts),
|
||||
message_type='comment',
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
84
fusion_accounting/models/accounting_config.py
Normal file
84
fusion_accounting/models/accounting_config.py
Normal file
@@ -0,0 +1,84 @@
|
||||
import logging
|
||||
from odoo import models, fields, api
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
_inherit = 'res.config.settings'
|
||||
|
||||
fusion_ai_provider = fields.Selection(
|
||||
selection=[('claude', 'Anthropic Claude'), ('openai', 'OpenAI GPT')],
|
||||
string='AI Provider',
|
||||
default='claude',
|
||||
config_parameter='fusion_accounting.ai_provider',
|
||||
)
|
||||
fusion_anthropic_api_key = fields.Char(
|
||||
string='Anthropic API Key (Fusion AI)',
|
||||
config_parameter='fusion_accounting.anthropic_api_key',
|
||||
)
|
||||
fusion_openai_api_key = fields.Char(
|
||||
string='OpenAI API Key (Fusion AI)',
|
||||
config_parameter='fusion_accounting.openai_api_key',
|
||||
)
|
||||
fusion_claude_model = fields.Selection(
|
||||
selection=[
|
||||
('claude-opus-4-6', 'Claude Opus 4.6 (Most Intelligent)'),
|
||||
('claude-sonnet-4-6', 'Claude Sonnet 4.6 (Best Balance)'),
|
||||
('claude-haiku-4-5', 'Claude Haiku 4.5 (Fastest)'),
|
||||
('claude-sonnet-4-5', 'Claude Sonnet 4.5'),
|
||||
('claude-opus-4-5', 'Claude Opus 4.5'),
|
||||
('claude-sonnet-4-0', 'Claude Sonnet 4'),
|
||||
('claude-opus-4-0', 'Claude Opus 4'),
|
||||
],
|
||||
string='Claude Model',
|
||||
default='claude-sonnet-4-6',
|
||||
config_parameter='fusion_accounting.claude_model',
|
||||
)
|
||||
fusion_openai_model = fields.Selection(
|
||||
selection=[
|
||||
('gpt-5.4', 'GPT-5.4 (Flagship)'),
|
||||
('gpt-5.4-mini', 'GPT-5.4 Mini (Fast)'),
|
||||
('gpt-5.4-nano', 'GPT-5.4 Nano (Cheapest)'),
|
||||
('o3', 'o3 (Best Reasoning)'),
|
||||
('o4-mini', 'o4-mini (Fast Reasoning)'),
|
||||
('gpt-4o', 'GPT-4o (Legacy)'),
|
||||
('gpt-4o-mini', 'GPT-4o Mini (Legacy)'),
|
||||
],
|
||||
string='OpenAI Model',
|
||||
default='gpt-5.4-mini',
|
||||
config_parameter='fusion_accounting.openai_model',
|
||||
)
|
||||
fusion_tier3_threshold = fields.Float(
|
||||
string='Tier 3 Promotion Threshold',
|
||||
default=0.95,
|
||||
config_parameter='fusion_accounting.tier3_threshold',
|
||||
help='Accuracy threshold for promoting Tier 3 tools to auto-approved.',
|
||||
)
|
||||
fusion_tier3_min_sample = fields.Integer(
|
||||
string='Tier 3 Minimum Sample Size',
|
||||
default=30,
|
||||
config_parameter='fusion_accounting.tier3_min_sample',
|
||||
)
|
||||
fusion_audit_cron_frequency = fields.Selection(
|
||||
selection=[('daily', 'Daily'), ('weekly', 'Weekly'), ('monthly', 'Monthly')],
|
||||
string='Audit Scan Frequency',
|
||||
default='daily',
|
||||
config_parameter='fusion_accounting.audit_cron_frequency',
|
||||
)
|
||||
fusion_history_in_prompt = fields.Integer(
|
||||
string='Match History in Prompt',
|
||||
default=50,
|
||||
config_parameter='fusion_accounting.history_in_prompt',
|
||||
help='Number of recent match history records to include in AI prompt.',
|
||||
)
|
||||
fusion_max_tool_calls = fields.Integer(
|
||||
string='Max Tool Calls Per Turn',
|
||||
default=20,
|
||||
config_parameter='fusion_accounting.max_tool_calls',
|
||||
)
|
||||
fusion_enable_post_audit = fields.Boolean(
|
||||
string='Enable Post-Action Audit Hook',
|
||||
default=False,
|
||||
config_parameter='fusion_accounting.enable_post_audit',
|
||||
)
|
||||
278
fusion_accounting/models/accounting_dashboard.py
Normal file
278
fusion_accounting/models/accounting_dashboard.py
Normal file
@@ -0,0 +1,278 @@
|
||||
import json
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo import models, fields, api
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionAccountingDashboard(models.TransientModel):
|
||||
_name = 'fusion.accounting.dashboard'
|
||||
_description = 'Fusion Accounting Dashboard'
|
||||
|
||||
company_id = fields.Many2one(
|
||||
'res.company', string='Company',
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
bank_recon_count = fields.Integer(compute='_compute_bank_recon')
|
||||
bank_recon_amount = fields.Monetary(
|
||||
compute='_compute_bank_recon', currency_field='currency_id',
|
||||
)
|
||||
ar_total = fields.Monetary(
|
||||
compute='_compute_ar', currency_field='currency_id',
|
||||
)
|
||||
ar_overdue_count = fields.Integer(compute='_compute_ar')
|
||||
ap_total = fields.Monetary(
|
||||
compute='_compute_ap', currency_field='currency_id',
|
||||
)
|
||||
ap_due_this_week = fields.Integer(compute='_compute_ap')
|
||||
hst_balance = fields.Monetary(
|
||||
compute='_compute_hst', currency_field='currency_id',
|
||||
)
|
||||
audit_score = fields.Integer(compute='_compute_audit')
|
||||
audit_flag_count = fields.Integer(compute='_compute_audit')
|
||||
month_end_status = fields.Char(compute='_compute_month_end')
|
||||
month_end_open_items = fields.Integer(compute='_compute_month_end')
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency', string='Currency',
|
||||
default=lambda self: self.env.company.currency_id,
|
||||
)
|
||||
needs_attention_json = fields.Text(compute='_compute_action_centre')
|
||||
recent_activity_json = fields.Text(compute='_compute_action_centre')
|
||||
|
||||
@api.depends('company_id')
|
||||
def _compute_bank_recon(self):
|
||||
for rec in self:
|
||||
data = self.env['account.bank.statement.line'].read_group(
|
||||
[('is_reconciled', '=', False), ('company_id', '=', rec.company_id.id)],
|
||||
['amount:sum'], [],
|
||||
)
|
||||
row = data[0] if data else {}
|
||||
rec.bank_recon_count = row.get('__count', 0)
|
||||
rec.bank_recon_amount = abs(row.get('amount', 0) or 0)
|
||||
|
||||
@api.depends('company_id')
|
||||
def _compute_ar(self):
|
||||
for rec in self:
|
||||
data = self.env['account.move.line'].read_group(
|
||||
[
|
||||
('account_id.account_type', '=', 'asset_receivable'),
|
||||
('parent_state', '=', 'posted'),
|
||||
('reconciled', '=', False),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
],
|
||||
['amount_residual:sum'], [],
|
||||
)
|
||||
row = data[0] if data else {}
|
||||
rec.ar_total = row.get('amount_residual', 0) or 0
|
||||
|
||||
rec.ar_overdue_count = self.env['account.move.line'].search_count([
|
||||
('account_id.account_type', '=', 'asset_receivable'),
|
||||
('parent_state', '=', 'posted'),
|
||||
('reconciled', '=', False),
|
||||
('date_maturity', '<', fields.Date.today()),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
])
|
||||
|
||||
@api.depends('company_id')
|
||||
def _compute_ap(self):
|
||||
for rec in self:
|
||||
data = self.env['account.move.line'].read_group(
|
||||
[
|
||||
('account_id.account_type', '=', 'liability_payable'),
|
||||
('parent_state', '=', 'posted'),
|
||||
('reconciled', '=', False),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
],
|
||||
['amount_residual:sum'], [],
|
||||
)
|
||||
row = data[0] if data else {}
|
||||
rec.ap_total = abs(row.get('amount_residual', 0) or 0)
|
||||
|
||||
week_end = fields.Date.today() + timedelta(days=7)
|
||||
rec.ap_due_this_week = self.env['account.move.line'].search_count([
|
||||
('account_id.account_type', '=', 'liability_payable'),
|
||||
('parent_state', '=', 'posted'),
|
||||
('reconciled', '=', False),
|
||||
('date_maturity', '<=', week_end),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
])
|
||||
|
||||
@api.depends('company_id')
|
||||
def _compute_hst(self):
|
||||
for rec in self:
|
||||
collected_data = self.env['account.move.line'].read_group(
|
||||
[
|
||||
('account_id.code', '=like', '2005%'),
|
||||
('parent_state', '=', 'posted'),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
],
|
||||
['balance:sum'], [],
|
||||
)
|
||||
itc_data = self.env['account.move.line'].read_group(
|
||||
[
|
||||
('account_id.code', '=like', '2006%'),
|
||||
('parent_state', '=', 'posted'),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
],
|
||||
['balance:sum'], [],
|
||||
)
|
||||
collected = abs((collected_data[0] if collected_data else {}).get('balance', 0) or 0)
|
||||
itcs = abs((itc_data[0] if itc_data else {}).get('balance', 0) or 0)
|
||||
rec.hst_balance = collected - itcs
|
||||
|
||||
@api.depends('company_id')
|
||||
def _compute_audit(self):
|
||||
for rec in self:
|
||||
issues = 0
|
||||
|
||||
# Wrong-direction balances via read_group
|
||||
balance_data = self.env['account.move.line'].read_group(
|
||||
[('parent_state', '=', 'posted'), ('company_id', '=', rec.company_id.id)],
|
||||
['balance:sum'], ['account_id'],
|
||||
)
|
||||
acct_cache = {}
|
||||
acct_ids = [row['account_id'][0] for row in balance_data if row.get('account_id')]
|
||||
if acct_ids:
|
||||
for acct in self.env['account.account'].browse(acct_ids):
|
||||
acct_cache[acct.id] = acct.account_type
|
||||
for row in balance_data:
|
||||
if not row.get('account_id'):
|
||||
continue
|
||||
acct_type = acct_cache.get(row['account_id'][0], '')
|
||||
balance = row.get('balance', 0) or 0
|
||||
if acct_type in ('asset_receivable', 'asset_cash', 'asset_current',
|
||||
'asset_non_current', 'asset_fixed', 'expense',
|
||||
'expense_depreciation', 'expense_direct_cost'):
|
||||
if balance < -0.01:
|
||||
issues += 1
|
||||
elif acct_type in ('liability_payable', 'liability_current',
|
||||
'liability_non_current', 'equity', 'income',
|
||||
'income_other'):
|
||||
if balance > 0.01:
|
||||
issues += 1
|
||||
|
||||
gaps = self.env['account.move'].search_count([
|
||||
('state', '=', 'posted'),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
('made_sequence_gap', '=', True),
|
||||
])
|
||||
issues += gaps
|
||||
|
||||
pending_approvals = self.env['fusion.accounting.match.history'].search_count([
|
||||
('decision', '=', 'pending'),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
])
|
||||
|
||||
rec.audit_score = max(0, min(100, 100 - issues * 3))
|
||||
rec.audit_flag_count = issues + pending_approvals
|
||||
|
||||
@api.depends('company_id')
|
||||
def _compute_month_end(self):
|
||||
for rec in self:
|
||||
open_items = 0
|
||||
open_items += self.env['account.bank.statement.line'].search_count([
|
||||
('is_reconciled', '=', False),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
])
|
||||
open_items += self.env['account.move'].search_count([
|
||||
('state', '=', 'draft'),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
])
|
||||
|
||||
suspense_data = self.env['account.move.line'].read_group(
|
||||
[
|
||||
('account_id.code', '=like', '999%'),
|
||||
('parent_state', '=', 'posted'),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
],
|
||||
['balance:sum'], ['account_id'],
|
||||
)
|
||||
for row in suspense_data:
|
||||
if abs(row.get('balance', 0) or 0) > 0.01:
|
||||
open_items += 1
|
||||
|
||||
rec.month_end_open_items = open_items
|
||||
if open_items == 0:
|
||||
rec.month_end_status = 'Ready to Close'
|
||||
elif open_items < 5:
|
||||
rec.month_end_status = 'Almost Ready'
|
||||
else:
|
||||
rec.month_end_status = 'Open'
|
||||
|
||||
@api.depends('company_id')
|
||||
def _compute_action_centre(self):
|
||||
for rec in self:
|
||||
attention = []
|
||||
|
||||
unrecon = self.env['account.bank.statement.line'].search_count([
|
||||
('is_reconciled', '=', False),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
])
|
||||
if unrecon > 0:
|
||||
attention.append({
|
||||
'priority': 1,
|
||||
'title': f'{unrecon} unreconciled bank lines',
|
||||
'domain': 'bank_reconciliation',
|
||||
'action': 'Review and reconcile bank statement lines',
|
||||
})
|
||||
|
||||
overdue = self.env['account.move'].search_count([
|
||||
('move_type', '=', 'out_invoice'),
|
||||
('state', '=', 'posted'),
|
||||
('payment_state', 'in', ('not_paid', 'partial')),
|
||||
('invoice_date_due', '<', fields.Date.today()),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
])
|
||||
if overdue > 0:
|
||||
attention.append({
|
||||
'priority': 2,
|
||||
'title': f'{overdue} overdue customer invoices',
|
||||
'domain': 'accounts_receivable',
|
||||
'action': 'Send follow-up reminders',
|
||||
})
|
||||
|
||||
pending = self.env['fusion.accounting.match.history'].search_count([
|
||||
('decision', '=', 'pending'),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
])
|
||||
if pending > 0:
|
||||
attention.append({
|
||||
'priority': 0,
|
||||
'title': f'{pending} AI actions awaiting approval',
|
||||
'domain': 'audit',
|
||||
'action': 'Review and approve/reject pending actions',
|
||||
})
|
||||
|
||||
drafts = self.env['account.move'].search_count([
|
||||
('state', '=', 'draft'),
|
||||
('date', '<=', fields.Date.today() - timedelta(days=30)),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
])
|
||||
if drafts > 0:
|
||||
attention.append({
|
||||
'priority': 3,
|
||||
'title': f'{drafts} stale draft entries (30+ days)',
|
||||
'domain': 'journal_review',
|
||||
'action': 'Post or delete stale draft entries',
|
||||
})
|
||||
|
||||
attention.sort(key=lambda x: x['priority'])
|
||||
rec.needs_attention_json = json.dumps(attention)
|
||||
|
||||
recent = self.env['fusion.accounting.match.history'].search([
|
||||
('company_id', '=', rec.company_id.id),
|
||||
], limit=10, order='proposed_at desc')
|
||||
rec.recent_activity_json = json.dumps([{
|
||||
'tool': r.tool_name,
|
||||
'decision': r.decision,
|
||||
'date': str(r.proposed_at),
|
||||
'amount': r.amount,
|
||||
} for r in recent])
|
||||
|
||||
def action_refresh(self):
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fusion_accounting.dashboard',
|
||||
}
|
||||
81
fusion_accounting/models/accounting_match_history.py
Normal file
81
fusion_accounting/models/accounting_match_history.py
Normal file
@@ -0,0 +1,81 @@
|
||||
import logging
|
||||
from odoo import models, fields, api
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionAccountingMatchHistory(models.Model):
|
||||
_name = 'fusion.accounting.match.history'
|
||||
_description = 'Fusion Accounting Match History'
|
||||
_order = 'proposed_at desc'
|
||||
|
||||
session_id = fields.Many2one(
|
||||
'fusion.accounting.session', string='Session',
|
||||
index=True, ondelete='cascade',
|
||||
)
|
||||
tool_name = fields.Char(string='Tool Name', required=True, index=True)
|
||||
tool_params = fields.Text(string='Tool Parameters (JSON)')
|
||||
tool_result = fields.Text(string='Tool Result (JSON)')
|
||||
ai_reasoning = fields.Text(string='AI Reasoning')
|
||||
ai_confidence = fields.Float(string='AI Confidence', digits=(3, 2))
|
||||
rule_id = fields.Many2one(
|
||||
'fusion.accounting.rule', string='Applied Rule',
|
||||
ondelete='set null',
|
||||
)
|
||||
proposed_at = fields.Datetime(
|
||||
string='Proposed At',
|
||||
default=fields.Datetime.now,
|
||||
required=True,
|
||||
)
|
||||
decision = fields.Selection(
|
||||
selection=[
|
||||
('approved', 'Approved'),
|
||||
('rejected', 'Rejected'),
|
||||
('pending', 'Pending'),
|
||||
('auto', 'Auto-Executed'),
|
||||
],
|
||||
string='Decision',
|
||||
default='pending',
|
||||
index=True,
|
||||
)
|
||||
decided_at = fields.Datetime(string='Decided At')
|
||||
decided_by = fields.Many2one('res.users', string='Decided By')
|
||||
rejection_reason = fields.Text(string='Rejection Reason')
|
||||
correct_action = fields.Text(string='Correct Action (JSON)')
|
||||
bank_statement_line_id = fields.Many2one(
|
||||
'account.bank.statement.line', string='Bank Statement Line',
|
||||
ondelete='set null',
|
||||
)
|
||||
move_line_ids = fields.Many2many(
|
||||
'account.move.line', string='Journal Items',
|
||||
)
|
||||
amount = fields.Monetary(string='Amount', currency_field='currency_id')
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency', string='Currency',
|
||||
default=lambda self: self.env.company.currency_id,
|
||||
)
|
||||
partner_id = fields.Many2one('res.partner', string='Partner')
|
||||
company_id = fields.Many2one(
|
||||
'res.company', string='Company',
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
|
||||
def action_approve(self):
|
||||
self.write({
|
||||
'decision': 'approved',
|
||||
'decided_at': fields.Datetime.now(),
|
||||
'decided_by': self.env.user.id,
|
||||
})
|
||||
for rec in self:
|
||||
if rec.rule_id:
|
||||
rec.rule_id._record_decision(approved=True)
|
||||
|
||||
def action_reject(self):
|
||||
self.write({
|
||||
'decision': 'rejected',
|
||||
'decided_at': fields.Datetime.now(),
|
||||
'decided_by': self.env.user.id,
|
||||
})
|
||||
for rec in self:
|
||||
if rec.rule_id:
|
||||
rec.rule_id._record_decision(approved=False)
|
||||
120
fusion_accounting/models/accounting_rule.py
Normal file
120
fusion_accounting/models/accounting_rule.py
Normal file
@@ -0,0 +1,120 @@
|
||||
import logging
|
||||
from odoo import models, fields, api
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionAccountingRule(models.Model):
|
||||
_name = 'fusion.accounting.rule'
|
||||
_description = 'Fusion Accounting Rule'
|
||||
_order = 'sequence, id'
|
||||
_inherit = ['mail.thread']
|
||||
|
||||
name = fields.Char(string='Name', required=True, tracking=True)
|
||||
rule_type = fields.Selection(
|
||||
selection=[
|
||||
('match', 'Match'),
|
||||
('classify', 'Classify'),
|
||||
('audit', 'Audit'),
|
||||
('fee', 'Fee'),
|
||||
('routing', 'Routing'),
|
||||
('followup', 'Follow-Up'),
|
||||
],
|
||||
string='Type',
|
||||
required=True,
|
||||
tracking=True,
|
||||
)
|
||||
description = fields.Text(
|
||||
string='Description',
|
||||
help='Natural language description read by the AI.',
|
||||
)
|
||||
trigger_domain = fields.Text(
|
||||
string='Trigger Domain (JSON)',
|
||||
help='Odoo domain filter for matching records.',
|
||||
)
|
||||
match_logic = fields.Text(
|
||||
string='Match Logic',
|
||||
help='Natural language matching instructions for the AI.',
|
||||
)
|
||||
match_code = fields.Text(
|
||||
string='Match Code (Python)',
|
||||
help='Optional deterministic Python matching code.',
|
||||
)
|
||||
fee_account_id = fields.Many2one(
|
||||
'account.account', string='Fee Account',
|
||||
)
|
||||
write_off_account_id = fields.Many2one(
|
||||
'account.account', string='Write-Off Account',
|
||||
)
|
||||
approval_tier = fields.Selection(
|
||||
selection=[('auto', 'Auto-Approved'), ('needs_approval', 'Needs Approval')],
|
||||
string='Approval Tier',
|
||||
default='needs_approval',
|
||||
tracking=True,
|
||||
)
|
||||
created_by = fields.Selection(
|
||||
selection=[('admin', 'Admin'), ('ai', 'AI')],
|
||||
string='Created By',
|
||||
default='admin',
|
||||
)
|
||||
confidence_score = fields.Float(
|
||||
string='Confidence Score', digits=(3, 2), default=0.0,
|
||||
)
|
||||
total_uses = fields.Integer(string='Total Uses', default=0)
|
||||
total_approved = fields.Integer(string='Total Approved', default=0)
|
||||
total_rejected = fields.Integer(string='Total Rejected', default=0)
|
||||
promotion_threshold = fields.Float(
|
||||
string='Promotion Threshold', default=0.95,
|
||||
)
|
||||
min_sample_size = fields.Integer(string='Min Sample Size', default=30)
|
||||
active = fields.Boolean(string='Active', default=True, tracking=True)
|
||||
version = fields.Integer(string='Version', default=1)
|
||||
parent_rule_id = fields.Many2one(
|
||||
'fusion.accounting.rule', string='Previous Version',
|
||||
ondelete='set null',
|
||||
)
|
||||
journal_ids = fields.Many2many(
|
||||
'account.journal', string='Journals',
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company', string='Company',
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
sequence = fields.Integer(string='Sequence', default=10)
|
||||
notes = fields.Text(string='Notes')
|
||||
|
||||
def _record_decision(self, approved=True):
|
||||
for rec in self:
|
||||
self.env.cr.execute("""
|
||||
UPDATE fusion_accounting_rule
|
||||
SET total_uses = total_uses + 1,
|
||||
total_approved = total_approved + %s,
|
||||
total_rejected = total_rejected + %s
|
||||
WHERE id = %s
|
||||
RETURNING total_uses, total_approved
|
||||
""", (int(approved), int(not approved), rec.id))
|
||||
row = self.env.cr.fetchone()
|
||||
rec.invalidate_recordset(['total_uses', 'total_approved', 'total_rejected'])
|
||||
if row and row[0] > 0:
|
||||
rec.confidence_score = row[1] / row[0]
|
||||
rec._check_promotion()
|
||||
|
||||
def _check_promotion(self):
|
||||
for rec in self:
|
||||
if (rec.approval_tier == 'needs_approval'
|
||||
and rec.total_uses >= rec.min_sample_size
|
||||
and rec.confidence_score >= rec.promotion_threshold):
|
||||
rec.approval_tier = 'auto'
|
||||
_logger.info(
|
||||
"Rule '%s' promoted to auto-approved (confidence=%.2f, uses=%d)",
|
||||
rec.name, rec.confidence_score, rec.total_uses,
|
||||
)
|
||||
|
||||
def action_demote(self):
|
||||
self.write({'approval_tier': 'needs_approval'})
|
||||
|
||||
def action_rollback(self):
|
||||
for rec in self:
|
||||
if rec.parent_rule_id:
|
||||
rec.active = False
|
||||
rec.parent_rule_id.active = True
|
||||
60
fusion_accounting/models/accounting_session.py
Normal file
60
fusion_accounting/models/accounting_session.py
Normal file
@@ -0,0 +1,60 @@
|
||||
import logging
|
||||
from odoo import models, fields, api
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionAccountingSession(models.Model):
|
||||
_name = 'fusion.accounting.session'
|
||||
_description = 'Fusion Accounting AI Session'
|
||||
_order = 'create_date desc'
|
||||
_inherit = ['mail.thread']
|
||||
|
||||
name = fields.Char(
|
||||
string='Session',
|
||||
required=True,
|
||||
default=lambda self: self.env['ir.sequence'].next_by_code('fusion.accounting.session') or 'New',
|
||||
)
|
||||
user_id = fields.Many2one(
|
||||
'res.users', string='User',
|
||||
required=True, default=lambda self: self.env.user,
|
||||
index=True,
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company', string='Company',
|
||||
required=True, default=lambda self: self.env.company,
|
||||
)
|
||||
state = fields.Selection(
|
||||
selection=[
|
||||
('active', 'Active'),
|
||||
('closed', 'Closed'),
|
||||
],
|
||||
string='Status',
|
||||
default='active',
|
||||
index=True,
|
||||
)
|
||||
message_ids_json = fields.Text(
|
||||
string='Messages (JSON)',
|
||||
default='[]',
|
||||
help='Stored conversation messages as JSON array.',
|
||||
)
|
||||
context_domain = fields.Char(
|
||||
string='Context Domain',
|
||||
help='Active accounting domain when session started.',
|
||||
)
|
||||
context_data = fields.Text(
|
||||
string='Context Data (JSON)',
|
||||
help='Additional Odoo context captured at session start.',
|
||||
)
|
||||
match_history_ids = fields.One2many(
|
||||
'fusion.accounting.match.history', 'session_id',
|
||||
string='Match History',
|
||||
)
|
||||
token_count_in = fields.Integer(string='Tokens In', default=0)
|
||||
token_count_out = fields.Integer(string='Tokens Out', default=0)
|
||||
tool_call_count = fields.Integer(string='Tool Calls', default=0)
|
||||
ai_provider = fields.Char(string='AI Provider')
|
||||
ai_model = fields.Char(string='AI Model')
|
||||
|
||||
def action_close_session(self):
|
||||
self.write({'state': 'closed'})
|
||||
60
fusion_accounting/models/accounting_tool.py
Normal file
60
fusion_accounting/models/accounting_tool.py
Normal file
@@ -0,0 +1,60 @@
|
||||
import logging
|
||||
from odoo import models, fields, api
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionAccountingTool(models.Model):
|
||||
_name = 'fusion.accounting.tool'
|
||||
_description = 'Fusion Accounting AI Tool'
|
||||
_order = 'domain, sequence, name'
|
||||
|
||||
name = fields.Char(string='Technical Name', required=True, index=True)
|
||||
display_name_field = fields.Char(string='Tool Label', required=True)
|
||||
description = fields.Text(string='Description', required=True)
|
||||
domain = fields.Selection(
|
||||
selection=[
|
||||
('bank_reconciliation', 'Bank Reconciliation'),
|
||||
('hst_management', 'HST/GST Management'),
|
||||
('accounts_receivable', 'Accounts Receivable'),
|
||||
('accounts_payable', 'Accounts Payable'),
|
||||
('journal_review', 'Journal Review'),
|
||||
('month_end', 'Month-End / Year-End'),
|
||||
('payroll_verification', 'Payroll Verification'),
|
||||
('inventory', 'Inventory & COGS'),
|
||||
('adp', 'ADP Reconciliation'),
|
||||
('reporting', 'Financial Reporting'),
|
||||
('audit', 'Audit & Integrity'),
|
||||
('payroll_management', 'Payroll Management'),
|
||||
],
|
||||
string='Domain',
|
||||
required=True,
|
||||
index=True,
|
||||
)
|
||||
tier = fields.Selection(
|
||||
selection=[
|
||||
('1', 'Tier 1 - Free (Read-Only)'),
|
||||
('2', 'Tier 2 - Auto-Approved'),
|
||||
('3', 'Tier 3 - Requires Approval'),
|
||||
],
|
||||
string='Tier',
|
||||
required=True,
|
||||
default='1',
|
||||
)
|
||||
parameters_schema = fields.Text(string='Parameters (JSON Schema)')
|
||||
required_groups = fields.Char(
|
||||
string='Required Groups',
|
||||
help='Comma-separated XML IDs of required groups.',
|
||||
)
|
||||
odoo_method = fields.Char(string='Odoo Method Reference')
|
||||
sequence = fields.Integer(string='Sequence', default=10)
|
||||
active = fields.Boolean(string='Active', default=True)
|
||||
company_id = fields.Many2one(
|
||||
'res.company', string='Company',
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
('name_company_uniq', 'UNIQUE(name, company_id)',
|
||||
'Tool name must be unique per company.'),
|
||||
]
|
||||
Reference in New Issue
Block a user