Files
odoo-ai/agent_service/tools/finance_tools.py
ActiveBlue Build dab6354d09 feat(finance): add FinanceAgent with full 6-step contract and FinanceTools
- FinanceAgent implements _plan/_gather/_reason/_act/_report lifecycle
- Proactive sweep flags 30+ day overdue invoices, auto-sends reminders >60d/>$1k
- PeerBus handler exposes overdue_summary, payment_history, financial_summary
- HIPAA-locked: Ollama only, no cloud LLM routing
- FinanceTools wraps OdooClient with 9 read/write methods on account.move
- finance_system.txt prompt enforces no-write-to-invoices rule
- research/finance_research.md documents Odoo 18 account model details

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 17:51:49 -04:00

120 lines
5.8 KiB
Python

from __future__ import annotations
import logging
from ..tools.odoo_client import OdooClient
logger = logging.getLogger(__name__)
class FinanceTools:
def __init__(self, odoo: OdooClient):
self._odoo = odoo
async def get_invoices(self, state='all', partner_id=None,
date_from=None, date_to=None,
move_type='all', limit=50):
domain = []
if move_type != 'all':
domain.append(['move_type', '=', move_type])
else:
domain.append(['move_type', 'in', ['out_invoice', 'in_invoice', 'out_refund', 'in_refund']])
if state != 'all':
domain.append(['state', '=', state])
if partner_id:
domain.append(['partner_id', '=', partner_id])
if date_from:
domain.append(['invoice_date', '>=', date_from])
if date_to:
domain.append(['invoice_date', '<=', date_to])
fields = ['name', 'move_type', 'state', 'partner_id', 'amount_total',
'amount_residual', 'payment_state', 'invoice_date', 'invoice_date_due']
return await self._odoo.search_read('account.move', domain, fields, limit=limit,
order='invoice_date_due desc')
async def get_overdue_invoices(self, partner_id=None, min_days_overdue=1):
from datetime import date, timedelta
cutoff = (date.today() - timedelta(days=min_days_overdue)).isoformat()
domain = [
['move_type', 'in', ['out_invoice', 'out_refund']],
['state', '=', 'posted'],
['payment_state', 'in', ['not_paid', 'partial']],
['invoice_date_due', '<', cutoff],
]
if partner_id:
domain.append(['partner_id', '=', partner_id])
fields = ['name', 'partner_id', 'amount_total', 'amount_residual',
'invoice_date_due', 'payment_state']
return await self._odoo.search_read('account.move', domain, fields,
order='invoice_date_due asc')
async def get_unreconciled_statements(self, journal_id, date_from=None, date_to=None):
domain = [['journal_id', '=', journal_id], ['is_reconciled', '=', False]]
if date_from:
domain.append(['date', '>=', date_from])
if date_to:
domain.append(['date', '<=', date_to])
fields = ['date', 'payment_ref', 'amount', 'partner_id', 'is_reconciled', 'move_id']
return await self._odoo.search_read('account.bank.statement.line', domain, fields,
order='date asc')
async def match_statement_line(self, statement_line_id, move_id):
result = await self._odoo.call(
'account.bank.statement.line', 'reconcile',
[[statement_line_id]], {'lines_vals': [{'id': move_id}]})
return result
async def send_payment_reminder(self, invoice_id, custom_message=None):
invoices = await self._odoo.read('account.move', [invoice_id],
['name', 'partner_id', 'amount_residual', 'invoice_date_due'])
if not invoices:
return {'success': False, 'error': 'Invoice not found'}
inv = invoices[0]
body = custom_message or (
f'Reminder: Invoice {inv["name"]} for {inv["amount_residual"]} '
f'was due on {inv["invoice_date_due"]}. Please arrange payment.')
msg_id = await self._odoo.post_chatter('account.move', invoice_id, body, subtype='mail.mt_comment')
return {'success': True, 'message_id': msg_id, 'invoice': inv['name']}
async def get_financial_summary(self, period='current'):
from datetime import date
if period == 'current':
today = date.today()
date_from = today.replace(day=1).isoformat()
date_to = today.isoformat()
else:
year, month = period.split('-')
import calendar
last_day = calendar.monthrange(int(year), int(month))[1]
date_from = f'{year}-{month}-01'
date_to = f'{year}-{month}-{last_day:02d}'
invoiced = await self._odoo.search_read(
'account.move',
[['move_type', '=', 'out_invoice'], ['state', '=', 'posted'],
['invoice_date', '>=', date_from], ['invoice_date', '<=', date_to]],
['amount_total', 'amount_residual', 'payment_state'])
total_invoiced = sum(i['amount_total'] for i in invoiced)
total_outstanding = sum(i['amount_residual'] for i in invoiced)
paid = sum(1 for i in invoiced if i['payment_state'] == 'paid')
return {
'period': period, 'date_from': date_from, 'date_to': date_to,
'total_invoiced': total_invoiced, 'total_outstanding': total_outstanding,
'total_paid': total_invoiced - total_outstanding,
'invoice_count': len(invoiced), 'paid_count': paid,
'collection_rate': round((1 - total_outstanding/total_invoiced)*100, 1) if total_invoiced else 0,
}
async def get_payment_history(self, partner_id):
fields = ['name', 'payment_type', 'amount', 'state', 'date', 'journal_id', 'ref']
return await self._odoo.search_read(
'account.payment',
[['partner_id', '=', partner_id], ['state', '=', 'posted']],
fields, limit=50, order='date desc')
async def flag_for_review(self, model, record_id, reason, severity='medium'):
note = f'[REVIEW FLAGGED - {severity.upper()}] {reason}'
await self._odoo.post_chatter(model, record_id, note)
return {'flagged': True, 'model': model, 'record_id': record_id, 'severity': severity}
async def post_chatter_note(self, model, record_id, note):
msg_id = await self._odoo.post_chatter(model, record_id, note)
return {'success': True, 'message_id': msg_id}