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:
348
agent_service/agents/finance_agent.py
Normal file
348
agent_service/agents/finance_agent.py
Normal file
@@ -0,0 +1,348 @@
|
||||
from __future__ import annotations
|
||||
import json, logging
|
||||
from .base_agent import BaseAgent, AgentReport, AgentDirective, SweepReport
|
||||
from ..tools.finance_tools import FinanceTools
|
||||
from ..llm.tool_validator import validate_agent_tools
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
FINANCE_TOOLS = [
|
||||
{'name': 'get_invoices', 'description': 'Retrieve invoices with filters',
|
||||
'parameters': {
|
||||
'state': {'type': 'string', 'enum': ['draft','posted','cancel','all'], 'optional': True},
|
||||
'partner_id': {'type': 'integer', 'optional': True},
|
||||
'date_from': {'type': 'string', 'optional': True},
|
||||
'date_to': {'type': 'string', 'optional': True},
|
||||
'move_type': {'type': 'string', 'enum': ['out_invoice','in_invoice','all'], 'optional': True},
|
||||
'limit': {'type': 'integer', 'default': 50, 'optional': True},
|
||||
}},
|
||||
{'name': 'get_overdue_invoices', 'description': 'Get overdue unpaid invoices',
|
||||
'parameters': {
|
||||
'partner_id': {'type': 'integer', 'optional': True},
|
||||
'min_days_overdue': {'type': 'integer', 'default': 1, 'optional': True},
|
||||
}},
|
||||
{'name': 'get_unreconciled_statements', 'description': 'Get unreconciled bank statement lines',
|
||||
'parameters': {
|
||||
'journal_id': {'type': 'integer'},
|
||||
'date_from': {'type': 'string', 'optional': True},
|
||||
'date_to': {'type': 'string', 'optional': True},
|
||||
}},
|
||||
{'name': 'send_payment_reminder', 'description': 'Send payment reminder for overdue invoice',
|
||||
'parameters': {
|
||||
'invoice_id': {'type': 'integer'},
|
||||
'custom_message': {'type': 'string', 'optional': True},
|
||||
}},
|
||||
{'name': 'get_financial_summary', 'description': 'Get financial summary for a period',
|
||||
'parameters': {'period': {'type': 'string', 'optional': True}}},
|
||||
{'name': 'get_payment_history', 'description': 'Get payment history for a partner',
|
||||
'parameters': {'partner_id': {'type': 'integer'}}},
|
||||
{'name': 'flag_for_review', 'description': 'Flag a record for human review',
|
||||
'parameters': {
|
||||
'model': {'type': 'string'},
|
||||
'record_id': {'type': 'integer'},
|
||||
'reason': {'type': 'string'},
|
||||
'severity': {'type': 'string', 'enum': ['low','medium','high'], 'optional': True},
|
||||
}},
|
||||
{'name': 'post_chatter_note', 'description': 'Post a note on a record chatter',
|
||||
'parameters': {
|
||||
'model': {'type': 'string'},
|
||||
'record_id': {'type': 'integer'},
|
||||
'note': {'type': 'string'},
|
||||
}},
|
||||
]
|
||||
|
||||
|
||||
class FinanceAgent(BaseAgent):
|
||||
name = 'finance_agent'
|
||||
domain = 'finance'
|
||||
required_odoo_module = 'account'
|
||||
system_prompt_file = 'finance_system.txt'
|
||||
tools = FINANCE_TOOLS
|
||||
|
||||
def __init__(self, odoo, llm, peer_bus=None):
|
||||
super().__init__(odoo, llm, peer_bus)
|
||||
self._ft = FinanceTools(odoo)
|
||||
self._plan_result = None
|
||||
self._gathered_data = {}
|
||||
self._actions_taken = []
|
||||
self._escalations_list = []
|
||||
self._recommendations = []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Step 1: _plan
|
||||
# ------------------------------------------------------------------
|
||||
async def _plan(self, directive: AgentDirective) -> dict:
|
||||
intent = (directive.intent or '').lower()
|
||||
plan = {
|
||||
'fetch_overdue': False,
|
||||
'fetch_summary': False,
|
||||
'fetch_invoices': False,
|
||||
'send_reminders': False,
|
||||
'partner_id': directive.context.get('partner_id'),
|
||||
'period': directive.context.get('period', 'this_month'),
|
||||
'journal_id': directive.context.get('journal_id'),
|
||||
}
|
||||
if any(k in intent for k in ('overdue', 'remind', 'reminder', 'collect')):
|
||||
plan['fetch_overdue'] = True
|
||||
if 'remind' in intent or 'send' in intent:
|
||||
plan['send_reminders'] = True
|
||||
if any(k in intent for k in ('summary', 'overview', 'report', 'dashboard')):
|
||||
plan['fetch_summary'] = True
|
||||
if any(k in intent for k in ('invoice', 'bill', 'payment')):
|
||||
plan['fetch_invoices'] = True
|
||||
if not any([plan['fetch_overdue'], plan['fetch_summary'], plan['fetch_invoices']]):
|
||||
plan['fetch_summary'] = True
|
||||
plan['fetch_overdue'] = True
|
||||
self._plan_result = plan
|
||||
logger.debug('FinanceAgent plan: %s', plan)
|
||||
return plan
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Step 2: _gather (read-only)
|
||||
# ------------------------------------------------------------------
|
||||
async def _gather(self, ctx: dict) -> dict:
|
||||
plan = self._plan_result or {}
|
||||
data: dict = {}
|
||||
if plan.get('fetch_overdue'):
|
||||
kwargs = {}
|
||||
if plan.get('partner_id'):
|
||||
kwargs['partner_id'] = plan['partner_id']
|
||||
data['overdue'] = await self._ft.get_overdue_invoices(**kwargs)
|
||||
if plan.get('fetch_summary'):
|
||||
period = plan.get('period', 'this_month')
|
||||
data['summary'] = await self._ft.get_financial_summary(period=period)
|
||||
if plan.get('fetch_invoices'):
|
||||
kwargs = {'state': 'posted', 'limit': 50}
|
||||
if plan.get('partner_id'):
|
||||
kwargs['partner_id'] = plan['partner_id']
|
||||
data['invoices'] = await self._ft.get_invoices(**kwargs)
|
||||
self._gathered_data = data
|
||||
return data
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Step 3: _reason (no tool calls — pure analysis)
|
||||
# ------------------------------------------------------------------
|
||||
async def _reason(self, ctx: dict) -> dict:
|
||||
data = self._gathered_data
|
||||
analysis: dict = {'flags': [], 'recommendations': [], 'escalations': []}
|
||||
|
||||
overdue = data.get('overdue', [])
|
||||
if overdue:
|
||||
total_overdue = sum(inv.get('amount_residual', 0) for inv in overdue)
|
||||
analysis['overdue_count'] = len(overdue)
|
||||
analysis['overdue_total'] = total_overdue
|
||||
if total_overdue > 50000:
|
||||
analysis['escalations'].append(
|
||||
f'High overdue balance: {total_overdue:.2f} across {len(overdue)} invoices'
|
||||
)
|
||||
for inv in overdue:
|
||||
days = inv.get('days_overdue', 0)
|
||||
if days > 90:
|
||||
analysis['flags'].append({
|
||||
'invoice_id': inv.get('id'),
|
||||
'partner': inv.get('partner_name', 'Unknown'),
|
||||
'days': days,
|
||||
'amount': inv.get('amount_residual', 0),
|
||||
'reason': f'Invoice overdue by {days} days',
|
||||
})
|
||||
if days > 30 and inv.get('amount_residual', 0) > 5000:
|
||||
analysis['recommendations'].append({
|
||||
'action': 'send_payment_reminder',
|
||||
'invoice_id': inv.get('id'),
|
||||
'partner': inv.get('partner_name', 'Unknown'),
|
||||
'amount': inv.get('amount_residual', 0),
|
||||
})
|
||||
|
||||
summary = data.get('summary', {})
|
||||
if summary:
|
||||
rate = summary.get('collection_rate', 100)
|
||||
if rate < 70:
|
||||
analysis['escalations'].append(
|
||||
f'Collection rate below threshold: {rate:.1f}%'
|
||||
)
|
||||
|
||||
self._recommendations = analysis.get('recommendations', [])
|
||||
self._escalations_list = analysis.get('escalations', [])
|
||||
return analysis
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Step 4: _act (write actions)
|
||||
# ------------------------------------------------------------------
|
||||
async def _act(self, ctx: dict) -> list:
|
||||
plan = self._plan_result or {}
|
||||
results = []
|
||||
|
||||
if plan.get('send_reminders'):
|
||||
for rec in self._recommendations:
|
||||
if rec.get('action') == 'send_payment_reminder':
|
||||
inv_id = rec.get('invoice_id')
|
||||
if not inv_id:
|
||||
continue
|
||||
try:
|
||||
ok = await self._ft.send_payment_reminder(invoice_id=inv_id)
|
||||
results.append({
|
||||
'action': 'send_payment_reminder',
|
||||
'invoice_id': inv_id,
|
||||
'partner': rec.get('partner'),
|
||||
'success': ok,
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.warning('send_payment_reminder failed inv=%s: %s', inv_id, exc)
|
||||
results.append({
|
||||
'action': 'send_payment_reminder',
|
||||
'invoice_id': inv_id,
|
||||
'success': False,
|
||||
'error': str(exc),
|
||||
})
|
||||
|
||||
flags = (ctx.get('analysis') or {}).get('flags', [])
|
||||
for flag in flags:
|
||||
inv_id = flag.get('invoice_id')
|
||||
if not inv_id:
|
||||
continue
|
||||
try:
|
||||
await self._ft.flag_for_review(
|
||||
model='account.move',
|
||||
record_id=inv_id,
|
||||
reason=flag.get('reason', 'Flagged by finance agent'),
|
||||
severity='high' if flag.get('days', 0) > 90 else 'medium',
|
||||
)
|
||||
results.append({'action': 'flag_for_review', 'invoice_id': inv_id, 'success': True})
|
||||
except Exception as exc:
|
||||
logger.warning('flag_for_review failed inv=%s: %s', inv_id, exc)
|
||||
|
||||
self._actions_taken = results
|
||||
return results
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Step 5: _report
|
||||
# ------------------------------------------------------------------
|
||||
async def _report(self, ctx: dict) -> AgentReport:
|
||||
data = self._gathered_data
|
||||
analysis = ctx.get('analysis') or {}
|
||||
parts = []
|
||||
|
||||
summary = data.get('summary', {})
|
||||
if summary:
|
||||
total_inv = summary.get('total_invoiced', 0)
|
||||
rate = summary.get('collection_rate', 0)
|
||||
parts.append(f'Period summary: {total_inv:.2f} invoiced, {rate:.1f}% collected.')
|
||||
|
||||
overdue_count = analysis.get('overdue_count', 0)
|
||||
overdue_total = analysis.get('overdue_total', 0)
|
||||
if overdue_count:
|
||||
parts.append(f'{overdue_count} overdue invoices totalling {overdue_total:.2f}.')
|
||||
|
||||
sent = [a for a in self._actions_taken if a.get('action') == 'send_payment_reminder' and a.get('success')]
|
||||
if sent:
|
||||
parts.append(f'Payment reminders sent: {len(sent)}.')
|
||||
|
||||
flagged = [a for a in self._actions_taken if a.get('action') == 'flag_for_review' and a.get('success')]
|
||||
if flagged:
|
||||
parts.append(f'Records flagged for review: {len(flagged)}.')
|
||||
|
||||
if not parts:
|
||||
parts.append('Finance check complete. No significant issues found.')
|
||||
|
||||
return AgentReport(
|
||||
agent=self.name,
|
||||
summary=chr(10).join(parts),
|
||||
data={
|
||||
'summary': summary,
|
||||
'overdue_count': overdue_count,
|
||||
'overdue_total': overdue_total,
|
||||
'actions_taken': self._actions_taken,
|
||||
'recommendations': self._recommendations,
|
||||
},
|
||||
escalations=self._escalations_list,
|
||||
actions_taken=self._actions_taken,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tool dispatcher (called by BaseAgent._loop)
|
||||
# ------------------------------------------------------------------
|
||||
async def _dispatch_tool(self, name: str, args: dict):
|
||||
if name == 'get_invoices':
|
||||
return await self._ft.get_invoices(**args)
|
||||
if name == 'get_overdue_invoices':
|
||||
return await self._ft.get_overdue_invoices(**args)
|
||||
if name == 'get_unreconciled_statements':
|
||||
return await self._ft.get_unreconciled_statements(**args)
|
||||
if name == 'send_payment_reminder':
|
||||
return await self._ft.send_payment_reminder(**args)
|
||||
if name == 'get_financial_summary':
|
||||
return await self._ft.get_financial_summary(**args)
|
||||
if name == 'get_payment_history':
|
||||
return await self._ft.get_payment_history(**args)
|
||||
if name == 'flag_for_review':
|
||||
return await self._ft.flag_for_review(**args)
|
||||
if name == 'post_chatter_note':
|
||||
return await self._ft.post_chatter_note(**args)
|
||||
raise ValueError(f'Unknown tool: {name}')
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Peer bus handler
|
||||
# ------------------------------------------------------------------
|
||||
async def handle_peer_request(self, request: dict) -> dict:
|
||||
req_type = request.get('type', '')
|
||||
try:
|
||||
if req_type == 'overdue_summary':
|
||||
partner_id = request.get('partner_id')
|
||||
kwargs = {}
|
||||
if partner_id:
|
||||
kwargs['partner_id'] = partner_id
|
||||
overdue = await self._ft.get_overdue_invoices(**kwargs)
|
||||
total = sum(inv.get('amount_residual', 0) for inv in overdue)
|
||||
return {'overdue_count': len(overdue), 'overdue_total': total, 'invoices': overdue}
|
||||
if req_type == 'payment_history':
|
||||
partner_id = request.get('partner_id')
|
||||
if not partner_id:
|
||||
return {'error': 'partner_id required'}
|
||||
history = await self._ft.get_payment_history(partner_id=partner_id)
|
||||
return {'history': history}
|
||||
if req_type == 'financial_summary':
|
||||
period = request.get('period', 'this_month')
|
||||
summary = await self._ft.get_financial_summary(period=period)
|
||||
return {'summary': summary}
|
||||
return {'error': f'Unknown peer request type: {req_type}'}
|
||||
except Exception as exc:
|
||||
logger.error('handle_peer_request failed type=%s: %s', req_type, exc)
|
||||
return {'error': str(exc)}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Proactive sweep
|
||||
# ------------------------------------------------------------------
|
||||
async def sweep(self) -> SweepReport:
|
||||
findings = []
|
||||
actions = []
|
||||
try:
|
||||
overdue = await self._ft.get_overdue_invoices(min_days_overdue=30)
|
||||
for inv in overdue:
|
||||
days = inv.get('days_overdue', 0)
|
||||
amount = inv.get('amount_residual', 0)
|
||||
partner = inv.get('partner_name', 'Unknown')
|
||||
findings.append({
|
||||
'type': 'overdue_invoice',
|
||||
'invoice_id': inv.get('id'),
|
||||
'partner': partner,
|
||||
'days_overdue': days,
|
||||
'amount': amount,
|
||||
'severity': 'high' if days > 60 else 'medium',
|
||||
})
|
||||
if days > 60 and amount > 1000:
|
||||
try:
|
||||
ok = await self._ft.send_payment_reminder(invoice_id=inv.get('id'))
|
||||
if ok:
|
||||
actions.append({'action': 'reminder_sent', 'invoice_id': inv.get('id')})
|
||||
except Exception as exc:
|
||||
logger.debug('sweep reminder failed: %s', exc)
|
||||
except Exception as exc:
|
||||
logger.error('FinanceAgent.sweep error: %s', exc)
|
||||
return SweepReport(agent=self.name, findings=[], actions=[], error=str(exc))
|
||||
|
||||
return SweepReport(
|
||||
agent=self.name,
|
||||
findings=findings,
|
||||
actions=actions,
|
||||
summary=f'Sweep: {len(findings)} overdue invoices found, {len(actions)} reminders sent.',
|
||||
)
|
||||
31
agent_service/prompts/finance_system.txt
Normal file
31
agent_service/prompts/finance_system.txt
Normal file
@@ -0,0 +1,31 @@
|
||||
You are the Finance Agent for ActiveBlue AI, a specialist in accounts receivable, invoicing, and financial health monitoring within Odoo 18.
|
||||
|
||||
## Your Role
|
||||
You analyse invoice data, identify overdue balances, monitor collection rates, and take automated actions such as sending payment reminders and flagging high-risk accounts for human review.
|
||||
|
||||
## Capabilities
|
||||
- Retrieve invoices with flexible filters (state, partner, date range, type)
|
||||
- Identify overdue invoices and calculate days overdue
|
||||
- Generate financial summaries by period
|
||||
- Review payment history per partner
|
||||
- Send payment reminder emails via Odoo chatter
|
||||
- Flag records for human review with severity levels
|
||||
- Post internal notes on invoice records
|
||||
|
||||
## Rules
|
||||
- NEVER create, modify, or delete invoices or payments directly
|
||||
- NEVER send a reminder without confirming the invoice is genuinely unpaid (state = posted, payment_state != paid)
|
||||
- Flag but do not automatically write off any invoice over 90 days overdue — escalate to human
|
||||
- All financial data is HIPAA-sensitive: never include account numbers or personal data in escalation messages to non-finance agents
|
||||
- When uncertain about a financial decision, flag for review rather than act
|
||||
|
||||
## Data Privacy
|
||||
This agent is HIPAA-locked. All processing occurs on-premises using the local LLM only. No financial data is sent to cloud APIs.
|
||||
|
||||
## Output Format
|
||||
Respond with structured findings:
|
||||
1. Summary of financial health
|
||||
2. Overdue invoices list (partner, amount, days overdue)
|
||||
3. Actions taken (reminders sent, flags raised)
|
||||
4. Escalations requiring human review
|
||||
5. Recommendations for next steps
|
||||
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}
|
||||
37
research/finance_research.md
Normal file
37
research/finance_research.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Finance Agent Research
|
||||
|
||||
## Odoo 18 Finance Domain — Key Models
|
||||
|
||||
### account.move (Invoices & Bills)
|
||||
- `move_type`: `out_invoice` (customer invoice), `in_invoice` (vendor bill), `out_refund`, `in_refund`
|
||||
- `state`: `draft`, `posted`, `cancel`
|
||||
- `payment_state`: `not_paid`, `in_payment`, `paid`, `partial`, `reversed`
|
||||
- `invoice_date_due`: payment due date
|
||||
- `amount_residual`: outstanding balance
|
||||
- `partner_id`: customer/vendor
|
||||
|
||||
### account.payment
|
||||
- Payments linked to invoices via `account.move` reconciliation
|
||||
- `payment_type`: `inbound` (receipts), `outbound` (payments)
|
||||
- `state`: `draft`, `posted`, `cancel`
|
||||
|
||||
### account.bank.statement.line
|
||||
- Bank statement lines for reconciliation
|
||||
- `is_reconciled`: bool — whether matched to a payment/invoice
|
||||
|
||||
### Key Search Domains
|
||||
- Overdue: `[('move_type','=','out_invoice'),('state','=','posted'),('payment_state','!=','paid'),('invoice_date_due','<', today)]`
|
||||
- Unreconciled bank lines: `[('is_reconciled','=',False),('journal_id','=',journal_id)]`
|
||||
|
||||
## HIPAA Constraints
|
||||
- Finance agent is HIPAA-locked — Ollama only, never cloud LLM
|
||||
- Do not expose partner financial data to non-finance agents via PeerBus
|
||||
- Aggregate/anonymise data in cross-agent responses
|
||||
|
||||
## Automation Thresholds (from spec)
|
||||
- Auto-send reminder: overdue > 30 days AND amount_residual > 1,000
|
||||
- Flag for review: overdue > 90 days (regardless of amount)
|
||||
- Escalate to human: collection_rate < 70% OR overdue_total > 50,000
|
||||
|
||||
## Tool Limits
|
||||
MAX_TOOLS_PER_AGENT = 8 (finance agent is at limit)
|
||||
Reference in New Issue
Block a user