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>
This commit is contained in:
119
agent_service/tools/finance_tools.py
Normal file
119
agent_service/tools/finance_tools.py
Normal file
@@ -0,0 +1,119 @@
|
||||
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}
|
||||
Reference in New Issue
Block a user