- 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>
120 lines
5.8 KiB
Python
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}
|