From 3cc93b87833bc8b2e6c3fc4f4a8135904126a00e Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sat, 4 Apr 2026 15:37:16 -0400 Subject: [PATCH] changes --- .../controllers/chat_controller.py | 42 +- fusion_accounting/data/cron.xml | 11 + fusion_accounting/data/tool_definitions.xml | 67 +- .../models/accounting_dashboard.py | 116 +++- .../models/accounting_match_history.py | 88 +++ fusion_accounting/services/adapters/claude.py | 4 +- .../services/adapters/openai_adapter.py | 4 +- fusion_accounting/services/agent.py | 473 +++++++++++++- .../services/prompts/domain_prompts.py | 53 +- .../services/prompts/system_prompt.py | 42 ++ .../services/tools/accounts_receivable.py | 57 +- fusion_accounting/services/tools/adp.py | 130 +++- .../services/tools/bank_reconciliation.py | 464 +++++++++++++- .../services/tools/hst_management.py | 6 +- fusion_accounting/services/tools/inventory.py | 4 +- .../services/tools/journal_review.py | 4 +- fusion_accounting/services/tools/month_end.py | 4 +- fusion_accounting/services/tools/payroll.py | 67 +- fusion_accounting/services/tools/reporting.py | 168 +++++ .../src/components/chat/approval_card.js | 21 +- .../src/components/chat/approval_card.xml | 44 +- .../static/src/components/chat/chat_panel.js | 577 ++++++++++++++++-- .../static/src/components/chat/chat_panel.xml | 196 +++++- .../components/dashboard/fusion_dashboard.js | 30 +- .../components/dashboard/fusion_dashboard.xml | 54 +- .../src/components/dashboard/health_card.js | 16 +- .../src/components/dashboard/health_card.xml | 16 +- fusion_accounting/static/src/scss/chat.scss | 186 +++++- .../static/src/scss/dashboard.scss | 202 ++++-- .../views/match_history_views.xml | 96 ++- fusion_poynt/__manifest__.py | 1 + fusion_poynt/models/__init__.py | 1 + fusion_poynt/models/account_payment.py | 42 ++ fusion_poynt/models/poynt_settlement.py | 473 +++++++------- fusion_poynt/views/account_payment_views.xml | 19 + fusion_poynt/views/poynt_settlement_views.xml | 48 +- 36 files changed, 3278 insertions(+), 548 deletions(-) create mode 100644 fusion_poynt/models/account_payment.py create mode 100644 fusion_poynt/views/account_payment_views.xml diff --git a/fusion_accounting/controllers/chat_controller.py b/fusion_accounting/controllers/chat_controller.py index db91ef75..a3c6214c 100644 --- a/fusion_accounting/controllers/chat_controller.py +++ b/fusion_accounting/controllers/chat_controller.py @@ -40,9 +40,9 @@ class FusionAccountingChatController(http.Controller): return {'status': 'closed'} @http.route('/fusion_accounting/chat', type='jsonrpc', auth='user') - def chat(self, session_id, message, context=None, **kwargs): - if not message: - return {'error': 'Message is required'} + def chat(self, session_id, message, context=None, image=None, **kwargs): + if not message and not image: + return {'error': 'Message or image is required'} # S3: Ownership check session = request.env['fusion.accounting.session'].browse(int(session_id)) if session.exists(): @@ -50,7 +50,7 @@ class FusionAccountingChatController(http.Controller): if error: return error agent = request.env['fusion.accounting.agent'] - result = agent.chat(int(session_id), message, context=context) + result = agent.chat(int(session_id), message or '', context=context, image=image) return result @http.route('/fusion_accounting/approve', type='jsonrpc', auth='user') @@ -134,6 +134,30 @@ class FusionAccountingChatController(http.Controller): results.append({'id': mid, 'status': 'error', 'error': 'Action could not be rejected. Check server logs for details.'}) return {'results': results} + @http.route('/fusion_accounting/chat/status', type='jsonrpc', auth='user') + def chat_status(self, session_id, **kwargs): + """Poll the live execution state of a running chat — returns thinking text, + tool calls in progress, and current status. Called every 500ms by the frontend + while a chat request is in flight.""" + from ..services.agent import get_execution_state + state = get_execution_state(int(session_id)) + return state + + @http.route('/fusion_accounting/search_matches', type='jsonrpc', auth='user') + def search_matches(self, statement_line_id, query='', **kwargs): + """Live search for matching journal items — called directly by the + reconciliation table search bar (no AI round-trip).""" + from ..services.tools.bank_reconciliation import search_matching_entries + try: + result = search_matching_entries(request.env, { + 'statement_line_id': int(statement_line_id), + 'query': query, + }) + return result + except Exception as e: + _logger.exception("Search matches failed") + return {'candidates': [], 'error': str(e)} + @http.route('/fusion_accounting/session/list', type='jsonrpc', auth='user') def session_list(self, limit=20, **kwargs): """List recent sessions for the session picker dropdown.""" @@ -187,10 +211,20 @@ class FusionAccountingChatController(http.Controller): for block in msg['content']: if isinstance(block, dict) and block.get('type') == 'text' and block['text'].strip(): display_messages.append({'role': msg['role'], 'content': block['text']}) + + # Include any pending approvals so they show on page load + agent = request.env['fusion.accounting.agent'] + pending = request.env['fusion.accounting.match.history'].search([ + ('session_id', '=', session.id), + ('decision', '=', 'pending'), + ]) + pending_approvals = [agent._format_pending_approval(p) for p in pending] + return { 'session_id': session.id, 'messages': display_messages, 'name': session.name, + 'pending_approvals': pending_approvals, } @http.route('/fusion_accounting/session/history', type='jsonrpc', auth='user') diff --git a/fusion_accounting/data/cron.xml b/fusion_accounting/data/cron.xml index fbcdb7b9..293cc72b 100644 --- a/fusion_accounting/data/cron.xml +++ b/fusion_accounting/data/cron.xml @@ -59,6 +59,17 @@ for rule in model.search([('active', '=', True), ('approval_tier', '=', 'needs_a True + + + Fusion AI: Reconcile Payroll Cheques + + code + model._reconcile_payroll_cheques() + 1 + days + True + + Fusion AI: Rebuild Vendor Tax Profiles diff --git a/fusion_accounting/data/tool_definitions.xml b/fusion_accounting/data/tool_definitions.xml index 7df20180..55a5cad9 100644 --- a/fusion_accounting/data/tool_definitions.xml +++ b/fusion_accounting/data/tool_definitions.xml @@ -151,10 +151,10 @@ get_partner_balance Get Partner Balance - Get a single partner's AR balance and open items. + [Tier 1: Read-only] Get a partner's AR and AP balance with open items. Shows: how much they owe us (receivable), how much we owe them (payable), and net balance. Use for "how much do we owe Pride Mobility?", "what's the balance for ADP?". accounts_receivable 1 - {"type": "object", "properties": {"partner_id": {"type": "integer"}}, "required": ["partner_id"]} + {"type": "object", "properties": {"partner_id": {"type": "integer", "description": "Partner ID (optional if partner_name provided)"}, "partner_name": {"type": "string", "description": "Partner name to search for (e.g. 'Pride Mobility')"}}} send_followup @@ -476,6 +476,16 @@ {"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}} + + register_adp_batch_payment + Register ADP Batch Payment + [Tier 3: Requires user approval] Register payments for a batch of ADP invoices from a remittance advice. Takes a list of invoice numbers with payment amounts and a payment date. Registers each payment via Odoo's payment wizard, creating outstanding receipt entries (PBNK2) on account 1050. After this, use suggest_bank_line_matches on the bank deposit to match the outstanding receipts. Use this when the user uploads an ADP remittance advice screenshot and says "mark these paid". + adp + 3 + {"type": "object", "properties": {"invoices": {"type": "array", "items": {"type": "object", "properties": {"invoice_number": {"type": "string"}, "amount": {"type": "number"}}, "required": ["invoice_number", "amount"]}, "description": "List of invoices with number and payment amount"}, "payment_date": {"type": "string", "description": "Payment date from remittance (YYYY-MM-DD)"}, "journal_id": {"type": "integer", "description": "Bank journal ID (default 50 = Scotia Current)"}}, "required": ["invoices", "payment_date"]} + fusion_accounting.group_fusion_accounting_manager + + get_profit_loss @@ -535,6 +545,31 @@ fusion_accounting.group_fusion_accounting_manager + + get_invoicing_summary + Get Invoicing Summary + [Tier 1: Read-only] Get customer invoicing summary — monthly breakdown for a year, date range totals, or filtered by partner. Use this for questions like "how much did we invoice this year?", "show me invoicing by month", "how much did we bill ADP this quarter?". + reporting + 1 + {"type": "object", "properties": {"year": {"type": "integer", "description": "Year for monthly breakdown (default: current year)"}, "partner_name": {"type": "string", "description": "Filter by partner name (optional)"}, "date_from": {"type": "string", "description": "Start date for date range (YYYY-MM-DD)"}, "date_to": {"type": "string", "description": "End date for date range (YYYY-MM-DD)"}}} + + + get_billing_summary + Get Billing Summary + [Tier 1: Read-only] Get vendor billing (purchases) summary — monthly breakdown for a year or date range. Use for "how much are our bills this month?", "show me vendor bills by month". + reporting + 1 + {"type": "object", "properties": {"year": {"type": "integer"}, "partner_name": {"type": "string"}, "date_from": {"type": "string"}, "date_to": {"type": "string"}}} + + + get_collections_summary + Get Collections Summary + [Tier 1: Read-only] Get payment collections summary — how much was collected (customer payments received) in a period, broken down by partner. Use for "how much are we collecting this month?", "show me collections for March". + reporting + 1 + {"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}} + + audit_posted_entry @@ -763,6 +798,34 @@ {"type": "object", "properties": {"journal_a_id": {"type": "integer", "description": "First bank journal ID"}, "journal_b_id": {"type": "integer", "description": "Second bank journal ID"}, "date_from": {"type": "string"}, "date_to": {"type": "string"}, "max_days_apart": {"type": "integer", "description": "Max days between matching lines (default 2)"}, "execute": {"type": "boolean", "description": "false=preview pairs only, true=actually reconcile"}}, "required": ["journal_a_id", "journal_b_id"]} + + suggest_bank_line_matches + Suggest Bank Line Matches + [Tier 1: Read-only] Find candidate invoices/bills that could match a bank statement line. Extracts partner from the bank line reference, searches open receivables (for incoming payments) or payables (for outgoing payments), scores candidates by amount/partner/date proximity, and finds the best combination of entries that sum to the bank amount. Returns data for a reconciliation-mode fusion-table with editable amounts and search. The user reviews matches, adjusts amounts for partial payments, searches and adds more entries, then clicks Apply Match. + bank_reconciliation + 1 + {"type": "object", "properties": {"statement_line_id": {"type": "integer", "description": "Bank statement line ID to find matches for"}}, "required": ["statement_line_id"]} + + + + find_unreconciled_cheques + Find Unreconciled Cheques + [Tier 1: Read-only] Find unreconciled cheque bank lines and classify them as payroll or non-payroll. Payroll cheques have a matching credit amount on 2201 Payroll Liabilities. Non-payroll cheques (vendor payments, rent, etc.) don't. Default journal: Scotia Current (50). + bank_reconciliation + 1 + {"type": "object", "properties": {"journal_id": {"type": "integer", "description": "Bank journal ID (default 50 = Scotia Current)"}, "limit": {"type": "integer", "description": "Max results (default 50)"}}} + + + + reconcile_payroll_cheques + Reconcile Payroll Cheques + [Tier 3: Requires user approval] Reconcile payroll cheque bank lines by applying the Payroll Cheque Clearing model. ONLY processes cheques whose amount matches an existing payroll liability entry on 2201. Non-payroll cheques (vendor/rent) are skipped automatically. Uses the pre-configured "Payroll Cheque Clearing" reconcile model (writeoff to Dr 2201). + bank_reconciliation + 3 + {"type": "object", "properties": {"journal_id": {"type": "integer", "description": "Bank journal ID (default 50)"}, "line_ids": {"type": "array", "items": {"type": "integer"}, "description": "Optional: specific bank line IDs to reconcile. If empty, reconciles all matching payroll cheques."}}} + fusion_accounting.group_fusion_accounting_manager + + create_expense_entry Create Direct GL Expense diff --git a/fusion_accounting/models/accounting_dashboard.py b/fusion_accounting/models/accounting_dashboard.py index 190cc743..c816f8f0 100644 --- a/fusion_accounting/models/accounting_dashboard.py +++ b/fusion_accounting/models/accounting_dashboard.py @@ -209,59 +209,111 @@ class FusionAccountingDashboard(models.TransientModel): def _compute_action_centre(self): for rec in self: attention = [] + today = fields.Date.today() - 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 AI approvals (highest priority) 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', + 'priority': 0, 'severity': 'danger', + 'title': f'{pending} AI actions awaiting your approval', 'domain': 'audit', - 'action': 'Review and approve/reject pending actions', + 'action': 'Review and approve or reject pending actions', + 'prompt': 'Show me all pending approval actions', }) + # Unreconciled bank lines + 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, 'severity': 'warning', + 'title': f'{unrecon} unreconciled bank lines', + 'domain': 'bank_reconciliation', + 'action': 'Review and reconcile bank statement lines', + 'prompt': 'Show me unreconciled bank lines across all journals with a breakdown by journal', + }) + + # Overdue customer invoices + overdue = self.env['account.move'].search_count([ + ('move_type', '=', 'out_invoice'), + ('state', '=', 'posted'), + ('payment_state', 'in', ('not_paid', 'partial')), + ('invoice_date_due', '<', today), + ('company_id', '=', rec.company_id.id), + ]) + if overdue > 0: + attention.append({ + 'priority': 2, 'severity': 'warning', + 'title': f'{overdue} overdue customer invoices', + 'domain': 'accounts_receivable', + 'action': 'Send follow-up reminders', + 'prompt': 'Show me overdue invoices sorted by amount', + }) + + # Unpaid vendor bills due this week + week_end = today + timedelta(days=7) + due_bills = self.env['account.move'].search_count([ + ('move_type', '=', 'in_invoice'), + ('state', '=', 'posted'), + ('payment_state', 'in', ('not_paid', 'partial')), + ('invoice_date_due', '<=', week_end), + ('invoice_date_due', '>=', today), + ('company_id', '=', rec.company_id.id), + ]) + if due_bills > 0: + attention.append({ + 'priority': 3, 'severity': 'info', + 'title': f'{due_bills} vendor bills due this week', + 'domain': 'accounts_payable', + 'action': 'Review upcoming payments', + 'prompt': f'Show me vendor bills due between {today} and {week_end}', + }) + + # Stale draft entries drafts = self.env['account.move'].search_count([ ('state', '=', 'draft'), - ('date', '<=', fields.Date.today() - timedelta(days=30)), + ('date', '<=', today - timedelta(days=30)), ('company_id', '=', rec.company_id.id), ]) if drafts > 0: attention.append({ - 'priority': 3, + 'priority': 4, 'severity': 'muted', 'title': f'{drafts} stale draft entries (30+ days)', 'domain': 'journal_review', 'action': 'Post or delete stale draft entries', + 'prompt': 'Find all stale draft entries older than 30 days', }) + # Unmatched customer payments (on outstanding receipts accounts) + try: + outstanding_accts = self.env['account.account'].search([ + ('name', 'ilike', 'outstanding receipt'), + ('company_ids', 'in', rec.company_id.id), + ]) + if outstanding_accts: + unmatched_payments = self.env['account.move.line'].search_count([ + ('account_id', 'in', outstanding_accts.ids), + ('parent_state', '=', 'posted'), + ('reconciled', '=', False), + ('company_id', '=', rec.company_id.id), + ]) + if unmatched_payments > 0: + attention.append({ + 'priority': 5, 'severity': 'info', + 'title': f'{unmatched_payments} unmatched customer payments', + 'domain': 'accounts_receivable', + 'action': 'Match payments to invoices', + 'prompt': 'Show me unmatched customer payments that need to be applied to invoices', + }) + except Exception: + pass + attention.sort(key=lambda x: x['priority']) rec.needs_attention_json = json.dumps(attention) diff --git a/fusion_accounting/models/accounting_match_history.py b/fusion_accounting/models/accounting_match_history.py index dbbd4304..9c511838 100644 --- a/fusion_accounting/models/accounting_match_history.py +++ b/fusion_accounting/models/accounting_match_history.py @@ -1,19 +1,83 @@ +import json import logging from odoo import models, fields, api _logger = logging.getLogger(__name__) +TOOL_LABELS = { + 'get_unreconciled_bank_lines': 'Get Unreconciled Bank Lines', + 'get_unreconciled_receipts': 'Get Unreconciled Receipts', + 'match_bank_line_to_payments': 'Match Bank Line to Payments', + 'auto_reconcile_bank_lines': 'Auto-Reconcile Bank Lines', + 'apply_reconcile_model': 'Apply Reconcile Model', + 'unmatch_bank_line': 'Unmatch Bank Line', + 'get_reconcile_suggestions': 'Get Reconcile Suggestions', + 'sum_payments_by_date': 'Sum Payments by Date', + 'get_bank_line_details': 'Get Bank Line Details', + 'check_recurring_pattern': 'Check Recurring Pattern', + 'match_internal_transfers': 'Match Internal Transfers', + 'find_unreconciled_cheques': 'Find Unreconciled Cheques', + 'reconcile_payroll_cheques': 'Reconcile Payroll Cheques', + 'suggest_bank_line_matches': 'Suggest Bank Line Matches', + 'search_matching_entries': 'Search Matching Entries', + 'calculate_hst_balance': 'Calculate HST Balance', + 'create_expense_entry': 'Create Expense Entry', + 'find_missing_itc_bills': 'Find Missing ITC Bills', + 'find_missing_tax_invoices': 'Find Missing Tax Invoices', + 'get_tax_report': 'Get Tax Report', + 'get_ar_aging': 'Get AR Aging', + 'get_overdue_invoices': 'Get Overdue Invoices', + 'get_partner_balance': 'Get Partner Balance', + 'get_ap_aging': 'Get AP Aging', + 'get_unpaid_bills': 'Get Unpaid Bills', + 'find_duplicate_bills': 'Find Duplicate Bills', + 'create_vendor_bill': 'Create Vendor Bill', + 'register_bill_payment': 'Register Bill Payment', + 'get_profit_loss': 'Get Profit & Loss', + 'get_balance_sheet': 'Get Balance Sheet', + 'get_trial_balance': 'Get Trial Balance', + 'get_cash_flow': 'Get Cash Flow', + 'compare_periods': 'Compare Periods', + 'get_invoicing_summary': 'Get Invoicing Summary', + 'get_billing_summary': 'Get Billing Summary', + 'get_collections_summary': 'Get Collections Summary', + 'create_payroll_journal_entry': 'Create Payroll Journal Entry', + 'find_adp_without_payment': 'Find ADP Without Payment', + 'get_adp_receivable_aging': 'Get ADP Receivable Aging', + 'register_adp_batch_payment': 'Register ADP Batch Payment', + 'get_close_checklist': 'Get Month-End Checklist', + 'find_draft_entries': 'Find Draft Entries', + 'find_wrong_direction_balances': 'Find Wrong Direction Balances', + 'find_duplicate_entries': 'Find Duplicate Entries', + 'get_payroll_entries': 'Get Payroll Entries', + 'get_cra_remittance_status': 'Get CRA Remittance Status', +} + + class FusionAccountingMatchHistory(models.Model): _name = 'fusion.accounting.match.history' _description = 'Fusion Accounting Match History' _order = 'proposed_at desc' + _rec_name = 'display_label' + display_label = fields.Char( + string='Label', compute='_compute_display_label', store=True, + ) 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_display_name = fields.Char( + string='Tool', compute='_compute_tool_display_name', store=True, + ) + tool_params_pretty = fields.Text( + string='Parameters', compute='_compute_pretty_json', + ) + tool_result_pretty = fields.Text( + string='Result', compute='_compute_pretty_json', + ) tool_params = fields.Text(string='Tool Parameters (JSON)') tool_result = fields.Text(string='Tool Result (JSON)') ai_reasoning = fields.Text(string='AI Reasoning') @@ -60,6 +124,30 @@ class FusionAccountingMatchHistory(models.Model): default=lambda self: self.env.company, ) + @api.depends('tool_name') + def _compute_tool_display_name(self): + for rec in self: + rec.tool_display_name = TOOL_LABELS.get(rec.tool_name, (rec.tool_name or '').replace('_', ' ').title()) + + @api.depends('tool_params', 'tool_result') + def _compute_pretty_json(self): + for rec in self: + for src, dst in [('tool_params', 'tool_params_pretty'), ('tool_result', 'tool_result_pretty')]: + raw = getattr(rec, src) or '{}' + try: + parsed = json.loads(raw) + setattr(rec, dst, json.dumps(parsed, indent=2, default=str, ensure_ascii=False)) + except (json.JSONDecodeError, TypeError): + setattr(rec, dst, raw) + + @api.depends('tool_name', 'proposed_at', 'decision') + def _compute_display_label(self): + for rec in self: + label = TOOL_LABELS.get(rec.tool_name, (rec.tool_name or '').replace('_', ' ').title()) + date_str = rec.proposed_at.strftime('%b %d %H:%M') if rec.proposed_at else '' + decision_str = dict(rec._fields['decision'].selection).get(rec.decision, '') + rec.display_label = f"{label} — {decision_str} ({date_str})" if date_str else label + def action_approve(self): self.write({ 'decision': 'approved', diff --git a/fusion_accounting/services/adapters/claude.py b/fusion_accounting/services/adapters/claude.py index 668b4d7d..70a76511 100644 --- a/fusion_accounting/services/adapters/claude.py +++ b/fusion_accounting/services/adapters/claude.py @@ -50,9 +50,9 @@ class FusionAccountingAdapterClaude(models.AbstractModel): def _supports_extended_thinking(self, model): return '4-6' in model or '4-5' in model or '4-1' in model or '4-0' in model - def call_with_tools(self, system_prompt, messages, tools=None): + def call_with_tools(self, system_prompt, messages, tools=None, model_override=None): client = self._get_client() - model = self._get_model_name() + model = model_override or self._get_model_name() api_messages = [] for msg in messages: diff --git a/fusion_accounting/services/adapters/openai_adapter.py b/fusion_accounting/services/adapters/openai_adapter.py index 0f0d0b1d..8e791f6f 100644 --- a/fusion_accounting/services/adapters/openai_adapter.py +++ b/fusion_accounting/services/adapters/openai_adapter.py @@ -52,9 +52,9 @@ class FusionAccountingAdapterOpenAI(models.AbstractModel): def _is_reasoning_model(self, model): return model.startswith('o1') or model.startswith('o3') or model.startswith('o4') - def call_with_tools(self, system_prompt, messages, tools=None): + def call_with_tools(self, system_prompt, messages, tools=None, model_override=None): client = self._get_client() - model = self._get_model_name() + model = model_override or self._get_model_name() is_reasoning = self._is_reasoning_model(model) if is_reasoning: diff --git a/fusion_accounting/services/agent.py b/fusion_accounting/services/agent.py index 64c1331e..58442c13 100644 --- a/fusion_accounting/services/agent.py +++ b/fusion_accounting/services/agent.py @@ -8,6 +8,17 @@ from odoo.exceptions import UserError _logger = logging.getLogger(__name__) +# In-memory execution state for live status polling. +# Key: session_id, Value: {thinking, tool_calls, status} +# Cleared after each chat() call completes. +_execution_state = {} + + +def get_execution_state(session_id): + """Get the current execution state for a session (called by polling endpoint).""" + return _execution_state.get(session_id, {'status': 'idle', 'thinking': '', 'tool_calls': []}) + + # Inter-account transfer pairs: (source_journal, cc_journal, cc_account_pattern) # Source sends "MB-CREDIT CARD" (outgoing), CC receives "PAYMENT FROM" (incoming) TRANSFER_PAIRS = [ @@ -25,12 +36,75 @@ class FusionAccountingAgent(models.AbstractModel): ICP = self.env['ir.config_parameter'].sudo() return ICP.get_param(f'fusion_accounting.{key}', default) + # Domains that need deeper reasoning → use Sonnet + COMPLEX_DOMAINS = {'audit', 'month_end', 'hst_management', 'payroll_management'} + # Keywords in user messages that suggest complex analysis → use Sonnet + COMPLEX_KEYWORDS = { + 'audit', 'analyze', 'analyse', 'review all', 'full report', 'investigate', + 'month-end', 'month end', 'close the books', 'hst filing', 'tax return', + 'what went wrong', 'why is', 'explain the difference', 'compare', + } + def _get_adapter(self): provider = self._get_config('ai_provider', 'claude') if provider == 'claude': return self.env['fusion.accounting.adapter.claude'] return self.env['fusion.accounting.adapter.openai'] + def _route_model(self, session, user_message, has_image=False): + """Smart model routing: Haiku for routine tool calling, Sonnet for complex analysis. + Returns (model_name, can_escalate) — can_escalate=True means Haiku is trying first + and we should check if it needs help.""" + provider = session.ai_provider or self._get_config('ai_provider', 'claude') + if provider != 'claude': + return None, False + + # Always use Sonnet for images (vision quality matters for OCR) + if has_image: + return 'claude-sonnet-4-6', False + + # Use Sonnet for complex domains + if session.context_domain in self.COMPLEX_DOMAINS: + return 'claude-sonnet-4-6', False + + # Use Sonnet if the message contains complex analysis keywords + msg_lower = (user_message or '').lower() + if any(kw in msg_lower for kw in self.COMPLEX_KEYWORDS): + return 'claude-sonnet-4-6', False + + # Default: Haiku with escalation enabled + return 'claude-haiku-4-5', True + + def _should_escalate(self, response, tool_calls_log, turn): + """Check if Haiku's response suggests it needs Sonnet's help.""" + text = (response.get('text') or '').lower() + + # Haiku said it can't do something + uncertainty_phrases = [ + "i'm not sure", "i cannot determine", "i don't have enough", + "unable to", "i'm unable", "this is complex", "beyond my", + "i need more context", "difficult to assess", "i apologize", + "i'm having trouble", "let me think about this differently", + ] + if any(phrase in text for phrase in uncertainty_phrases): + return True + + # Haiku made no tool calls on first turn when it probably should have + # (user asked a question but Haiku just gave text without using tools) + if turn == 0 and not response.get('tool_calls') and not text: + return True + + # Haiku had multiple tool errors + error_count = sum(1 for tc in tool_calls_log if tc.get('status') == 'error') + if error_count >= 2: + return True + + # Response is very short for a data question (Haiku might be confused) + if turn == 0 and not response.get('tool_calls') and len(text) < 50: + return True + + return False + def _get_tool_registry(self): return self.env['fusion.accounting.tool'].search([('active', '=', True)]) @@ -114,8 +188,8 @@ class FusionAccountingAgent(models.AbstractModel): vals = { 'session_id': session.id if session else False, 'tool_name': tool_name, - 'tool_params': json.dumps(params) if params else '{}', - 'tool_result': json.dumps(result) if result else '{}', + 'tool_params': json.dumps(params, indent=2, default=str) if params else '{}', + 'tool_result': json.dumps(result, indent=2, default=str) if result else '{}', 'ai_reasoning': reasoning, 'ai_confidence': confidence, 'rule_id': rule.id if rule else False, @@ -125,7 +199,7 @@ class FusionAccountingAgent(models.AbstractModel): } return self.env['fusion.accounting.match.history'].sudo().create(vals) - def chat(self, session_id, user_message, context=None): + def chat(self, session_id, user_message, context=None, image=None): session = self.env['fusion.accounting.session'].browse(session_id) if not session.exists(): raise UserError(_("Session not found.")) @@ -155,36 +229,106 @@ class FusionAccountingAgent(models.AbstractModel): ) messages_json = json.loads(session.message_ids_json or '[]') - messages_json.append({'role': 'user', 'content': user_message}) + + # Build user message — may include image for vision + if image and isinstance(image, dict) and image.get('base64'): + user_content = [] + if user_message: + user_content.append({'type': 'text', 'text': user_message}) + user_content.append({ + 'type': 'image', + 'source': { + 'type': 'base64', + 'media_type': image.get('media_type', 'image/png'), + 'data': image['base64'], + }, + }) + messages_json.append({'role': 'user', 'content': user_content}) + else: + messages_json.append({'role': 'user', 'content': user_message}) + + # Smart model routing: Haiku for routine, Sonnet for complex + has_image = bool(image and isinstance(image, dict) and image.get('base64')) + model_override, can_escalate = self._route_model(session, user_message, has_image=has_image) + escalated = False + if model_override: + _logger.info("Model routing: %s → %s (escalation=%s)", session.name, model_override, can_escalate) max_turns = max(int(self._get_config('max_tool_calls', '20')), 1) total_tokens_in = 0 total_tokens_out = 0 response = {'text': '', 'tool_calls': None} has_pending_tier3 = False + tool_calls_log = [] # Track tool calls for frontend display + reconciliation_data = None # Raw data from suggest_bank_line_matches + + # Initialize live execution state for polling + _execution_state[session.id] = { + 'status': 'thinking', + 'thinking': '', + 'tool_calls': [], + 'turn': 0, + } for turn in range(max_turns): + _execution_state[session.id]['status'] = 'calling_ai' + _execution_state[session.id]['turn'] = turn + 1 + response = adapter.call_with_tools( system_prompt=system_prompt, messages=messages_json, tools=tool_definitions, + model_override=model_override, ) total_tokens_in += response.get('tokens_in', 0) total_tokens_out += response.get('tokens_out', 0) + # Check if Haiku needs to escalate to Sonnet + if can_escalate and not escalated and self._should_escalate(response, tool_calls_log, turn): + _logger.info("Escalating %s from Haiku → Sonnet (turn %d)", session.name, turn) + model_override = 'claude-sonnet-4-6' + escalated = True + can_escalate = False + _execution_state[session.id]['status'] = 'escalating' + # Re-call with Sonnet + response = adapter.call_with_tools( + system_prompt=system_prompt, + messages=messages_json, + tools=tool_definitions, + model_override=model_override, + ) + total_tokens_in += response.get('tokens_in', 0) + total_tokens_out += response.get('tokens_out', 0) + + # Capture thinking text for live display + thinking = '' + for block in (response.get('raw_content') or []): + if hasattr(block, 'type') and block.type == 'thinking': + thinking = block.thinking + break + if thinking: + _execution_state[session.id]['thinking'] = thinking[:500] # Truncated for live display + if response.get('tool_calls'): tool_results = [] + _execution_state[session.id]['status'] = 'calling_tools' + for tc in response['tool_calls']: tool_name = tc['name'] tool_params = tc.get('arguments', {}) tool_rec = tools.filtered(lambda t: t.name == tool_name) tier = tool_rec.tier if tool_rec else '1' + # Update live state: show which tool is running + _execution_state[session.id]['tool_calls'].append({ + 'name': tool_name, 'status': 'running', + }) + if tier == '3': has_pending_tier3 = True history_rec = self._log_match_history( session, tool_name, tool_params, None, - reasoning=tc.get('reasoning', ''), + reasoning=thinking or '', confidence=tc.get('confidence', 0.0), tier='3', ) @@ -196,17 +340,43 @@ class FusionAccountingAgent(models.AbstractModel): 'message': f'Action requires user approval. Match history ID: {history_rec.id}', }), }) + tool_calls_log.append({ + 'name': tool_name, + 'tier': tier, + 'status': 'pending_approval', + 'summary': self._build_tool_call_summary(tool_name, tool_params, None), + }) + _execution_state[session.id]['tool_calls'][-1]['status'] = 'pending' else: + t0 = time.time() result = self._execute_tool(tool_name, tool_params, session.id) + elapsed = round((time.time() - t0) * 1000) self._log_match_history( session, tool_name, tool_params, result, - reasoning=tc.get('reasoning', ''), + reasoning=thinking or '', tier=tier, ) tool_results.append({ 'tool_call_id': tc.get('id', ''), 'result': json.dumps(result) if not isinstance(result, str) else result, }) + tc_status = 'error' if isinstance(result, dict) and result.get('error') else 'ok' + tc_summary = self._build_tool_call_summary(tool_name, tool_params, result) + + # Capture reconciliation data for direct frontend rendering + if tool_name == 'suggest_bank_line_matches' and tc_status == 'ok': + reconciliation_data = result + tool_calls_log.append({ + 'name': tool_name, + 'tier': tier, + 'status': tc_status, + 'summary': tc_summary, + 'duration_ms': elapsed, + }) + # Update live state + _execution_state[session.id]['tool_calls'][-1].update({ + 'status': tc_status, 'summary': tc_summary, 'duration_ms': elapsed, + }) try: self._check_rule_proposal(tool_name, tool_params, session) except Exception: @@ -225,6 +395,7 @@ class FusionAccountingAgent(models.AbstractModel): system_prompt=system_prompt, messages=messages_json, tools=[], + model_override=model_override, ) total_tokens_in += response.get('tokens_in', 0) total_tokens_out += response.get('tokens_out', 0) @@ -249,6 +420,7 @@ class FusionAccountingAgent(models.AbstractModel): system_prompt=system_prompt, messages=messages_json, tools=[], + model_override=model_override, ) total_tokens_in += response.get('tokens_in', 0) total_tokens_out += response.get('tokens_out', 0) @@ -264,7 +436,7 @@ class FusionAccountingAgent(models.AbstractModel): 'token_count_in': session.token_count_in + total_tokens_in, 'token_count_out': session.token_count_out + total_tokens_out, 'ai_provider': provider, - 'ai_model': adapter._get_model_name(), + 'ai_model': model_override or adapter._get_model_name(), }) pending = self.env['fusion.accounting.match.history'].search([ @@ -272,19 +444,190 @@ class FusionAccountingAgent(models.AbstractModel): ('decision', '=', 'pending'), ]) - return { + # Clear live execution state + _execution_state.pop(session.id, None) + + # Add escalation marker to tool calls log if it happened + if escalated: + tool_calls_log.insert(0, { + 'name': 'model_escalation', + 'tier': '-', + 'status': 'ok', + 'summary': 'Escalated from Haiku to Sonnet for deeper analysis', + 'duration_ms': 0, + }) + + result_payload = { 'text': response.get('text', ''), - 'pending_approvals': [{ - 'id': p.id, - 'tool_name': p.tool_name, - 'params': p.tool_params, - 'reasoning': p.ai_reasoning, - 'confidence': p.ai_confidence, - 'amount': p.amount, - } for p in pending], + 'tool_calls_log': tool_calls_log, + 'pending_approvals': [self._format_pending_approval(p) for p in pending], 'session_id': session.id, + 'model_used': model_override or adapter._get_model_name(), } + # Attach raw reconciliation data so frontend renders it directly + # (instead of relying on AI to format fusion-table JSON correctly) + if reconciliation_data: + result_payload['reconciliation_table'] = reconciliation_data + + return result_payload + + def _build_tool_call_summary(self, tool_name, params, result): + """Build a one-line summary of what a tool call did, for the collapsed tool log.""" + try: + # Result-based summaries (when we have output) + if result and isinstance(result, dict) and not result.get('error'): + count = result.get('count') + status = result.get('status') + if status == 'created': + name = result.get('name', '') + return f"Created {name}" if name else "Created successfully" + if status == 'matched': + return "Matched successfully" + if count is not None: + return f"Found {count} result{'s' if count != 1 else ''}" + if 'balance' in result: + return f"Balance: ${result['balance']:,.2f}" + if 'total' in result: + return f"Total: ${result['total']:,.2f}" + if 'entries' in result: + return f"Found {len(result['entries'])} entries" + if 'accounts' in result: + return f"Found {len(result['accounts'])} accounts" + if status: + return str(status) + + if result and isinstance(result, dict) and result.get('error'): + err = str(result['error']) + return f"Error: {err[:80]}" + + # Params-based summaries (for pending approvals, no result yet) + if params: + ref = params.get('ref', params.get('reference', params.get('name', ''))) + amount = params.get('amount') + lines = params.get('lines', []) + if lines: + total = sum(l.get('debit', 0) for l in lines) + return f"{ref} — ${total:,.2f}" if ref else f"${total:,.2f} journal entry" + if ref and amount: + return f"{ref} — ${abs(amount):,.2f}" + if ref: + return str(ref) + + return "Completed" + except Exception: + return "Completed" + + def _format_pending_approval(self, history): + """Build a rich approval payload so the UI can show exactly what's being approved.""" + params = {} + try: + params = json.loads(history.tool_params or '{}') + except json.JSONDecodeError: + pass + + # Extract amount from params — look in common locations + amount = history.amount or 0.0 + if not amount: + # Try to compute from journal entry lines + lines = params.get('lines', []) + if lines: + amount = sum(l.get('debit', 0) for l in lines) + # Or from direct amount field + if not amount: + amount = abs(params.get('amount', 0)) + + # Build a human-readable summary of what this action will do + summary = self._build_approval_summary(history.tool_name, params) + + return { + 'id': history.id, + 'tool_name': history.tool_name, + 'params': history.tool_params, + 'reasoning': history.ai_reasoning, + 'confidence': history.ai_confidence, + 'amount': amount, + 'summary': summary, + } + + def _resolve_account_label(self, account_id): + """Resolve an account ID to 'code - name' for display.""" + if not account_id: + return '?' + try: + acct = self.env['account.account'].browse(int(account_id)) + if acct.exists(): + return f"{acct.code} {acct.name}" + except Exception: + pass + return str(account_id) + + def _build_approval_summary(self, tool_name, params): + """Generate a short human-readable description of what a Tier 3 action will do.""" + try: + if tool_name == 'create_payroll_journal_entry': + ref = params.get('ref', 'Payroll Entry') + date = params.get('date', '?') + lines = params.get('lines', []) + total = sum(l.get('debit', 0) for l in lines) + acct_names = [] + for l in lines: + aid = l.get('account_id', '') + acct_label = self._resolve_account_label(aid) + if l.get('debit'): + acct_names.append(f"Dr {acct_label}: ${l['debit']:,.2f}") + elif l.get('credit'): + acct_names.append(f"Cr {acct_label}: ${l['credit']:,.2f}") + detail = ' / '.join(acct_names) if acct_names else '' + return f"{ref} on {date} — ${total:,.2f}\n{detail}" + + elif tool_name == 'create_vendor_bill': + partner = params.get('partner_name', params.get('partner_id', '?')) + amount = params.get('amount', 0) + ref = params.get('ref', params.get('reference', '')) + date = params.get('date', '?') + return f"Vendor bill for {partner} — ${abs(amount):,.2f} on {date}" + (f" ({ref})" if ref else "") + + elif tool_name == 'register_bill_payment': + bill_id = params.get('bill_id', '?') + amount = params.get('amount', 0) + journal = params.get('journal_id', '?') + return f"Pay bill #{bill_id} — ${abs(amount):,.2f} from journal {journal}" + + elif tool_name == 'create_expense_entry': + ref = params.get('ref', params.get('memo', 'Expense')) + amount = params.get('amount', 0) + account = params.get('expense_account_id', '?') + return f"{ref} — ${abs(amount):,.2f} to account {account}" + + elif tool_name == 'register_hst_payment': + amount = params.get('amount', 0) + date = params.get('date', '?') + return f"HST remittance — ${abs(amount):,.2f} on {date}" + + elif tool_name in ('apply_payment', 'send_followup', 'create_payment_reminder'): + partner = params.get('partner_name', params.get('partner_id', '?')) + amount = params.get('amount', 0) + return f"{tool_name.replace('_', ' ').title()} for {partner}" + (f" — ${abs(amount):,.2f}" if amount else "") + + elif tool_name == 'flag_entry': + move_id = params.get('move_id', '?') + reason = params.get('reason', '') + return f"Flag entry #{move_id}" + (f": {reason}" if reason else "") + + else: + # Generic fallback: show key params + parts = [] + for key in ('ref', 'reference', 'name', 'partner_name', 'date', 'move_id'): + if key in params: + parts.append(f"{key}: {params[key]}") + if 'amount' in params: + parts.append(f"${abs(params['amount']):,.2f}") + return ' | '.join(parts) if parts else json.dumps(params)[:120] + + except Exception: + return str(params)[:120] + def approve_action(self, match_history_id): history = self.env['fusion.accounting.match.history'].browse(match_history_id) if not history.exists() or history.decision != 'pending': @@ -504,3 +847,101 @@ class FusionAccountingAgent(models.AbstractModel): ) _logger.info("Transfer reconcile complete: %d total reconciled", total_reconciled) + + # ---------------------------------------------------------------- + # One-time: Match payroll cheque bank lines against open payroll liability entries + # ---------------------------------------------------------------- + @api.model + def _reconcile_payroll_cheques(self): + """Reconcile payroll cheque bank lines using writeoff to Payroll Liabilities (2201). + + Your payroll JEs post: + Dr Salaries / Dr ER CPP-EI / Dr CRA Taxes + Cr 2201 Payroll Liabilities (net pay = cheque amount) + + When the cheque clears the bank, the bank line shows: + "Cheque 1773 : Cheque" = -$1,477.95 + + This method finds cheque bank lines that have a matching payroll liability + entry (same amount) and applies a reconcile model that writes off to account + 433 (Payroll Liabilities). This debits 433 to clear the liability. + + Non-payroll cheques (no matching entry on 433) are skipped. + """ + PAYROLL_LIABILITY_ACCT_ID = 433 # code 2201 + SCOTIA_CURRENT_JOURNAL_ID = 50 + + AML = self.env['account.move.line'].sudo() + BSL = self.env['account.bank.statement.line'].sudo() + RecModel = self.env['account.reconcile.model'].sudo() + + # Find the payroll cheque reconcile model (must be pre-created via XML or manually) + model = RecModel.search([ + ('name', 'ilike', 'Payroll Cheque'), + ('company_id', '=', self.env.company.id), + ], limit=1) + + if not model: + _logger.warning("Payroll cheque reconcile: no 'Payroll Cheque' model found — create one manually") + return + + # Find all unreconciled cheque lines on Scotia Current (negative = outgoing) + # Only process lines after lock date to avoid lock date errors + cheque_lines = BSL.search([ + ('journal_id', '=', SCOTIA_CURRENT_JOURNAL_ID), + ('is_reconciled', '=', False), + ('amount', '<', 0), + ('payment_ref', 'ilike', 'cheque'), + ('company_id', '=', self.env.company.id), + ], order='move_id asc') + + # Filter to post-lock-date lines only + lock_date = self.env.company.fiscalyear_lock_date + if lock_date: + cheque_lines = cheque_lines.filtered(lambda l: l.move_id.date > lock_date) + + _logger.info("Payroll cheque reconcile: found %d unreconciled cheque lines (post lock date)", len(cheque_lines)) + + # Build set of all known payroll liability credit amounts + payroll_credit_amounts = set() + for aml in AML.search([ + ('account_id', '=', PAYROLL_LIABILITY_ACCT_ID), + ('parent_state', '=', 'posted'), + ('credit', '>', 0), + ]): + payroll_credit_amounts.add(round(aml.credit, 2)) + + # Filter: only reconcile cheques that have a matching payroll liability entry + payroll_lines = cheque_lines.filtered( + lambda l: round(abs(l.amount), 2) in payroll_credit_amounts + ) + + _logger.info( + "Payroll cheque reconcile: %d payroll, %d non-payroll (skipped)", + len(payroll_lines), len(cheque_lines) - len(payroll_lines), + ) + + if not payroll_lines: + _logger.info("Payroll cheque reconcile: nothing to reconcile") + return + + # Apply the reconcile model to payroll cheque lines + try: + model._apply_reconcile_models(payroll_lines) + self.env.cr.commit() + except Exception as e: + _logger.exception("Payroll cheque reconcile batch failed: %s", e) + self.env.cr.rollback() + return + + # Count results + still_unreconciled = payroll_lines.filtered(lambda l: not l.is_reconciled) + reconciled = len(payroll_lines) - len(still_unreconciled) + + for line in still_unreconciled[:10]: + _logger.info("Payroll cheque still unreconciled: %s $%.2f", line.payment_ref, abs(line.amount)) + + _logger.info( + "Payroll cheque reconcile complete: %d reconciled, %d still unreconciled", + reconciled, len(still_unreconciled), + ) diff --git a/fusion_accounting/services/prompts/domain_prompts.py b/fusion_accounting/services/prompts/domain_prompts.py index ba5ca7d5..eb04224a 100644 --- a/fusion_accounting/services/prompts/domain_prompts.py +++ b/fusion_accounting/services/prompts/domain_prompts.py @@ -8,6 +8,34 @@ You are helping with bank statement reconciliation. Key concepts: - Fee differences (e.g., Elavon card processing fees) should be allocated to the fee account. - Weekend batches may combine multiple days of card payments. - Always verify amounts before proposing a match. + +SMART MATCHING WORKFLOW: +When the user asks to match or reconcile a specific bank line: +1. Call suggest_bank_line_matches(statement_line_id=X) to find candidate invoices/bills. +2. Present the results as a reconciliation-mode fusion-table. IMPORTANT: pass the tool + result fields DIRECTLY into the fusion-table — do NOT reformat into cells arrays: + ```fusion-table + { + "mode": "reconciliation", + "title": "Match: [ref] $[amount]", + "source_tool": "suggest_bank_line_matches", + "bank_line": , + "candidates": , + "best_combination": + } + ``` + Each candidate must have: aml_id, name, ref, partner, date, amount_residual, type, score, reasons. + Do NOT convert candidates into {"id":..., "cells":[...]} format — use the raw tool output. +3. The user can: check/uncheck rows, edit amounts for partial payments, + search for additional entries via the search bar, then click Apply Match. +4. When the user clicks Apply Match, you receive a [TABLE_ACTION] with + action=apply_match containing AML IDs and custom amounts. +5. Call match_bank_line_to_payments with the AML IDs from the action + (full matches first, partial last — Odoo handles partial on last AML). +6. Partial payment: if apply_amount < amount_residual, it's partial. + Only ONE AML can be partial (the last one). Odoo leaves the residual open. + +Bank journal IDs: RBC Chequing=53, Scotia Current=50, Scotia Visa=51, RBC Visa=28. """, 'hst_management': """ @@ -119,10 +147,31 @@ INVENTORY & COGS CONTEXT: """, 'adp': """ -ADP RECONCILIATION CONTEXT: +ADP (ASSISTIVE DEVICE PROGRAM) RECONCILIATION CONTEXT: - ADP Receivable tracked on account 1101. - ADP invoices have customer portion + ADP portion = total. -- Government deposits should match ADP invoices. +- Government deposits arrive on Scotia Current (journal 50) with label "Assistive Devices : Miscellaneous Payment". +- ADP partner in Odoo: "ADP (Assistive Device Program)" (id 3421). + +ADP PAYMENT MATCHING WORKFLOW: +1. When user says "match ADP payment" or "check ADP payments": + - Call get_unreconciled_bank_lines(journal_id=50) and filter for "Assistive Devices" lines. + - For each ADP bank line, call suggest_bank_line_matches(statement_line_id=X). + - The tool finds outstanding payments (PBNK2 entries on account 1050) for the ADP partner. + - Present as reconciliation fusion-table. + +2. When user uploads an ADP remittance advice image: + - Read the image. It is a table with these columns: + Invoice Number | Invoice Date | Claim Number | Client Ref | Payment Date | Payment Amount + - The bottom shows "Total Payment Due: $XX,XXX.XX" — this is the bank deposit amount. + - Extract every row: invoice number and payment amount. + - Find the bank line on Scotia Current matching the total amount. + - Call suggest_bank_line_matches for that bank line. + - The outstanding payments on 1050 should sum to the total. + +3. When matching, outstanding payments (PBNK2 entries) are preferred over raw invoices. + Each PBNK2 entry represents a registered payment batch. Two or more PBNK2 entries + may combine to equal the bank deposit total. """, 'reporting': """ diff --git a/fusion_accounting/services/prompts/system_prompt.py b/fusion_accounting/services/prompts/system_prompt.py index 45a01397..79481d65 100644 --- a/fusion_accounting/services/prompts/system_prompt.py +++ b/fusion_accounting/services/prompts/system_prompt.py @@ -89,6 +89,48 @@ LINKING TO ODOO RECORDS: - Bank statement lines: mention the date, reference, and amount clearly. - When tool results include record IDs, always link them. +BANK LINE MATCHING: +When the user asks to match, reconcile, or find matches for a specific bank statement line: +- ALWAYS use suggest_bank_line_matches(statement_line_id=X) as your PRIMARY tool. +- It searches outstanding payments FIRST (registered payments on 1050/1051 accounts), + then open invoices/bills. Outstanding payments are the correct match — not raw invoices. +- Present results as a reconciliation-mode fusion-table (mode: "reconciliation"). +- Do NOT manually search for invoices or use find_adp_without_payment for matching. +- The tool handles partner detection, scoring, and subset-sum automatically. +- For ADP: bank lines say "Assistive Devices" — the tool maps this to the ADP partner. + +ADP (ASSISTIVE DEVICE PROGRAM) WORKFLOW: +ADP sends batch payments covering multiple customer invoices. The bank deposit label is +"Assistive Devices : Miscellaneous Payment". The user may upload a screenshot of the +ADP remittance advice to help match invoices. + +When handling ADP payments: +1. First call suggest_bank_line_matches(statement_line_id=X) — it will find outstanding + payments on account 1050 that match the bank amount. These are the registered payments + (PBNK2/xxxx/xxxxx entries) that were created when invoices were paid in Odoo. +2. Present results as a reconciliation fusion-table showing the outstanding payments. +3. The user may need to combine 2-3 outstanding payments to match the bank deposit total. + +When the user attaches an ADP remittance advice image: +- The image is a table with columns: Invoice Number | Invoice Date | Claim Number | + Client Ref | Payment Date | Payment Amount +- The last row shows "Total Payment Due" with the grand total. +- Extract ALL invoice numbers and their payment amounts from the image. +- Present a summary table of what you extracted for confirmation. +- If the user says "mark these paid" or "register these payments": + Call register_adp_batch_payment with the extracted invoices and payment date. + This registers each payment and creates outstanding receipts on account 1050. + Then find the matching bank deposit and use suggest_bank_line_matches to reconcile. +- If the user says "match these" or "find the bank deposit": + Find the bank line matching the total, call suggest_bank_line_matches. + +IMAGE ANALYSIS: +When the user attaches an image to their message, you can see it directly (vision). +- Read all text, numbers, and tables from the image. +- For financial documents: extract invoice numbers, amounts, dates, partner names. +- For remittance advices: extract the line items and grand total. +- Always confirm what you extracted before taking action. + TOOL CALLING: - Call tools by name with the required parameters. - You may call multiple tools in sequence to gather data before proposing an action. diff --git a/fusion_accounting/services/tools/accounts_receivable.py b/fusion_accounting/services/tools/accounts_receivable.py index a7601c57..1f7dc518 100644 --- a/fusion_accounting/services/tools/accounts_receivable.py +++ b/fusion_accounting/services/tools/accounts_receivable.py @@ -65,27 +65,56 @@ def get_overdue_invoices(env, params): def get_partner_balance(env, params): - partner_id = int(params['partner_id']) - partner = env['res.partner'].browse(partner_id) - if not partner.exists(): - return {'error': 'Partner not found'} - amls = env['account.move.line'].search([ - ('partner_id', '=', partner_id), + """Get AR and AP balance for a partner. Accepts partner_id or partner_name.""" + partner = None + if params.get('partner_id'): + partner = env['res.partner'].browse(int(params['partner_id'])) + elif params.get('partner_name'): + partner = env['res.partner'].search([ + ('name', 'ilike', params['partner_name']), + ], limit=1) + if not partner or not partner.exists(): + return {'error': f"Partner not found: {params.get('partner_name', params.get('partner_id', '?'))}"} + + # AR balance (receivable) + ar_amls = env['account.move.line'].search([ + ('partner_id', '=', partner.id), ('account_id.account_type', '=', 'asset_receivable'), ('parent_state', '=', 'posted'), ('reconciled', '=', False), ('company_id', '=', env.company.id), ]) + ar_balance = sum(aml.amount_residual for aml in ar_amls) + + # AP balance (payable) + ap_amls = env['account.move.line'].search([ + ('partner_id', '=', partner.id), + ('account_id.account_type', '=', 'liability_payable'), + ('parent_state', '=', 'posted'), + ('reconciled', '=', False), + ('company_id', '=', env.company.id), + ]) + ap_balance = sum(aml.amount_residual for aml in ap_amls) + + open_items = [{ + 'id': aml.id, + 'move_name': aml.move_id.name, + 'ref': aml.ref or '', + 'date': str(aml.date), + 'amount_residual': aml.amount_residual, + 'type': 'receivable' if aml.account_id.account_type == 'asset_receivable' else 'payable', + 'date_maturity': str(aml.date_maturity) if aml.date_maturity else '', + } for aml in (ar_amls | ap_amls)[:30]] + return { 'partner': partner.name, - 'balance': sum(aml.amount_residual for aml in amls), - 'open_items': [{ - 'id': aml.id, - 'ref': aml.ref or aml.move_id.name, - 'date': str(aml.date), - 'amount_residual': aml.amount_residual, - 'date_maturity': str(aml.date_maturity) if aml.date_maturity else '', - } for aml in amls], + 'partner_id': partner.id, + 'ar_balance': ar_balance, + 'ap_balance': ap_balance, + 'net_balance': ar_balance + ap_balance, + 'they_owe_us': ar_balance if ar_balance > 0 else 0, + 'we_owe_them': abs(ap_balance) if ap_balance < 0 else 0, + 'open_items': open_items, } diff --git a/fusion_accounting/services/tools/adp.py b/fusion_accounting/services/tools/adp.py index ccca45cf..32123195 100644 --- a/fusion_accounting/services/tools/adp.py +++ b/fusion_accounting/services/tools/adp.py @@ -7,7 +7,7 @@ _logger = logging.getLogger(__name__) def get_adp_receivable_aging(env, params): accounts = env['account.account'].search([ ('code', '=like', '1101%'), - ('company_id', '=', env.company.id), + ('company_ids', 'in', env.company.id), ]) today = fields.Date.today() amls = env['account.move.line'].search([ @@ -81,7 +81,7 @@ def get_adp_summary(env, params): date_from = params.get('date_from') date_to = params.get('date_to') accounts = env['account.account'].search([ - ('code', '=like', '1101%'), ('company_id', '=', env.company.id), + ('code', '=like', '1101%'), ('company_ids', 'in', env.company.id), ]) domain = [ ('account_id', 'in', accounts.ids), @@ -102,10 +102,136 @@ def get_adp_summary(env, params): } +def register_adp_batch_payment(env, params): + """Register payments for a batch of ADP invoices from a remittance advice. + + Takes a list of invoice numbers with payment amounts and a payment date. + Registers a payment for each invoice via Odoo's payment wizard, which + creates outstanding receipt entries (PBNK2) on account 1050. + + After calling this, use suggest_bank_line_matches on the bank deposit line + to match the outstanding receipts against the bank line. + """ + invoices_data = params.get('invoices', []) + payment_date = params.get('payment_date') + journal_id = int(params.get('journal_id', 50)) # Default Scotia Current + + if not invoices_data: + return {'error': 'No invoices provided'} + if not payment_date: + return {'error': 'payment_date is required (YYYY-MM-DD)'} + + ADP_PARTNER_ID = 3421 # ADP (Assistive Device Program) + results = [] + total_paid = 0.0 + errors = [] + + for inv_data in invoices_data: + inv_number = str(inv_data.get('invoice_number', '')).strip() + amount = float(inv_data.get('amount', 0)) + + if not inv_number or not amount: + errors.append(f"Skipped: missing invoice_number or amount in {inv_data}") + continue + + # Find the invoice by name/number + invoice = env['account.move'].search([ + ('name', 'ilike', inv_number), + ('move_type', '=', 'out_invoice'), + ('state', '=', 'posted'), + ('company_id', '=', env.company.id), + ], limit=1) + + if not invoice: + # Try without leading zeros or with different format + invoice = env['account.move'].search([ + ('name', '=like', f'%{inv_number}'), + ('move_type', '=', 'out_invoice'), + ('state', '=', 'posted'), + ], limit=1) + + if not invoice: + errors.append(f"Invoice {inv_number} not found") + continue + + if invoice.payment_state == 'paid': + results.append({ + 'invoice': inv_number, + 'status': 'already_paid', + 'move_id': invoice.id, + }) + continue + + # Check if amount matches residual (allow partial) + if amount > invoice.amount_residual + 0.01: + errors.append( + f"Invoice {inv_number}: payment ${amount:.2f} exceeds " + f"residual ${invoice.amount_residual:.2f}" + ) + continue + + # Register payment via the payment wizard + try: + payment_vals = { + 'payment_type': 'inbound', + 'partner_type': 'customer', + 'partner_id': invoice.partner_id.id or ADP_PARTNER_ID, + 'amount': amount, + 'date': payment_date, + 'journal_id': journal_id, + 'ref': f'ADP Remittance - {inv_number}', + } + # Use the payment register wizard + ctx = { + 'active_model': 'account.move', + 'active_ids': [invoice.id], + } + wizard = env['account.payment.register'].with_context(**ctx).create({ + 'payment_date': payment_date, + 'amount': amount, + 'journal_id': journal_id, + 'payment_method_line_id': env['account.payment.method.line'].search([ + ('journal_id', '=', journal_id), + ('payment_type', '=', 'inbound'), + ], limit=1).id, + }) + wizard.action_create_payments() + + results.append({ + 'invoice': inv_number, + 'status': 'paid', + 'amount': amount, + 'move_id': invoice.id, + 'move_name': invoice.name, + }) + total_paid += amount + except Exception as e: + _logger.warning("ADP payment failed for %s: %s", inv_number, e) + errors.append(f"Invoice {inv_number}: payment failed — {e}") + + env.cr.commit() + + return { + 'status': 'completed', + 'paid_count': len([r for r in results if r.get('status') == 'paid']), + 'already_paid_count': len([r for r in results if r.get('status') == 'already_paid']), + 'total_paid': total_paid, + 'results': results, + 'errors': errors, + 'message': ( + f"Registered payments for {len([r for r in results if r.get('status') == 'paid'])} invoices " + f"totalling ${total_paid:,.2f}. " + + (f"{len(errors)} errors." if errors else "No errors.") + + " Now use suggest_bank_line_matches to match the bank deposit." + ), + } + + TOOLS = { 'get_adp_receivable_aging': get_adp_receivable_aging, 'match_adp_payment_to_invoice': match_adp_payment_to_invoice, 'verify_adp_split': verify_adp_split, 'find_adp_without_payment': find_adp_without_payment, 'get_adp_summary': get_adp_summary, + 'register_adp_batch_payment': register_adp_batch_payment, } diff --git a/fusion_accounting/services/tools/bank_reconciliation.py b/fusion_accounting/services/tools/bank_reconciliation.py index e5eb0085..82cc2e8d 100644 --- a/fusion_accounting/services/tools/bank_reconciliation.py +++ b/fusion_accounting/services/tools/bank_reconciliation.py @@ -35,7 +35,7 @@ def get_unreconciled_receipts(env, params): account_code = params.get('account_code', '1122') accounts = env['account.account'].search([ ('code', '=like', f'{account_code}%'), - ('company_id', '=', env.company.id), + ('company_ids', 'in', env.company.id), ]) domain = [ ('account_id', 'in', accounts.ids), @@ -484,6 +484,464 @@ def match_internal_transfers(env, params): } +def find_unreconciled_cheques(env, params): + """Find unreconciled cheque bank lines and classify as payroll vs non-payroll + by checking if the amount matches an existing payroll liability entry.""" + PAYROLL_ACCT = 433 # 2201 Payroll Liabilities + journal_id = int(params.get('journal_id', 50)) # Default Scotia Current + limit = int(params.get('limit', 50)) + + AML = env['account.move.line'].sudo() + BSL = env['account.bank.statement.line'].sudo() + + # Build set of known payroll liability amounts + payroll_amounts = set() + for aml in AML.search([ + ('account_id', '=', PAYROLL_ACCT), + ('parent_state', '=', 'posted'), + ('credit', '>', 0), + ]): + payroll_amounts.add(round(aml.credit, 2)) + + cheque_lines = BSL.search([ + ('journal_id', '=', journal_id), + ('is_reconciled', '=', False), + ('payment_ref', 'ilike', 'cheque'), + ('amount', '<', 0), + ('company_id', '=', env.company.id), + ], limit=limit, order='move_id desc') + + payroll = [] + non_payroll = [] + for line in cheque_lines: + amt = round(abs(line.amount), 2) + entry = { + 'id': line.id, + 'date': str(line.move_id.date), + 'ref': line.payment_ref or '', + 'amount': amt, + 'journal': line.journal_id.name, + } + if amt in payroll_amounts: + entry['type'] = 'payroll' + payroll.append(entry) + else: + entry['type'] = 'non_payroll' + non_payroll.append(entry) + + return { + 'count': len(cheque_lines), + 'payroll_count': len(payroll), + 'non_payroll_count': len(non_payroll), + 'payroll': payroll, + 'non_payroll': non_payroll, + } + + +def reconcile_payroll_cheques(env, params): + """Reconcile payroll cheque bank lines by applying the Payroll Cheque Clearing + reconcile model. Only reconciles cheques whose amount matches an existing + payroll liability entry on account 2201. Non-payroll cheques are skipped. + + Params: + journal_id (int): Bank journal ID (default 50 = Scotia Current) + line_ids (list): Optional list of specific bank line IDs to reconcile. + If not provided, reconciles all matching payroll cheques. + """ + PAYROLL_ACCT = 433 + journal_id = int(params.get('journal_id', 50)) + + AML = env['account.move.line'].sudo() + BSL = env['account.bank.statement.line'].sudo() + RecModel = env['account.reconcile.model'].sudo() + + model = RecModel.search([ + ('name', 'ilike', 'Payroll Cheque'), + ('company_id', '=', env.company.id), + ], limit=1) + if not model: + return {'error': 'No "Payroll Cheque Clearing" reconcile model found. Create one first.'} + + # Get lines to process + if params.get('line_ids'): + cheque_lines = BSL.browse([int(x) for x in params['line_ids']]) + cheque_lines = cheque_lines.filtered(lambda l: not l.is_reconciled) + else: + cheque_lines = BSL.search([ + ('journal_id', '=', journal_id), + ('is_reconciled', '=', False), + ('payment_ref', 'ilike', 'cheque'), + ('amount', '<', 0), + ('company_id', '=', env.company.id), + ]) + + # Filter post-lock-date + lock = env.company.fiscalyear_lock_date + if lock: + cheque_lines = cheque_lines.filtered(lambda l: l.move_id.date > lock) + + # Filter to payroll-only amounts + payroll_amounts = set() + for aml in AML.search([ + ('account_id', '=', PAYROLL_ACCT), + ('parent_state', '=', 'posted'), + ('credit', '>', 0), + ]): + payroll_amounts.add(round(aml.credit, 2)) + + payroll_lines = cheque_lines.filtered( + lambda l: round(abs(l.amount), 2) in payroll_amounts + ) + skipped = len(cheque_lines) - len(payroll_lines) + + if not payroll_lines: + return { + 'status': 'nothing_to_do', + 'message': f'No payroll cheques to reconcile ({skipped} non-payroll cheques skipped)', + } + + try: + model._apply_reconcile_models(payroll_lines) + env.cr.commit() + except Exception as e: + return {'error': f'Reconciliation failed: {e}'} + + still = payroll_lines.filtered(lambda l: not l.is_reconciled) + reconciled = len(payroll_lines) - len(still) + + return { + 'status': 'completed', + 'reconciled': reconciled, + 'still_unreconciled': len(still), + 'non_payroll_skipped': skipped, + 'message': f'Reconciled {reconciled} payroll cheques. {skipped} non-payroll cheques skipped.', + } + + +def _extract_partner_from_ref(env, payment_ref): + """Extract a partner from a bank line payment_ref using keyword matching.""" + if not payment_ref: + return None + skip_words = { + 'misc', 'payment', 'online', 'banking', 'pad', 'business', 'deposit', + 'cheque', 'transfer', 'e-transfer', 'sent', 'autodeposit', 'credit', + 'debit', 'memo', 'free', 'interac', 'from', 'the', 'and', 'for', + 'miscellaneous', 'bill', 'correction', 'adjustment', 'other', + } + # Strip common suffixes like colons and split + clean_ref = payment_ref.replace(':', ' ').replace('-', ' ') + words = [w for w in clean_ref.split() if len(w) > 2 and w.lower() not in skip_words] + # Try progressively shorter phrases + for n in range(min(len(words), 4), 0, -1): + for i in range(len(words) - n + 1): + phrase = ' '.join(words[i:i+n]) + partners = env['res.partner'].search([ + ('name', 'ilike', phrase), + ('company_id', 'in', [env.company.id, False]), + ], limit=3) + if partners: + return partners[0] + # Fallback: try each word individually with supplier/customer rank + for word in words: + if len(word) < 4: + continue + partners = env['res.partner'].search([ + ('name', 'ilike', word), + ('company_id', 'in', [env.company.id, False]), + '|', ('customer_rank', '>', 0), ('supplier_rank', '>', 0), + ], limit=3) + if partners: + return partners[0] + return None + + +def _find_best_subset(candidates, target, max_items=8): + """Find the subset of candidates whose amounts sum closest to target. + Returns (aml_ids, total) for the best combination.""" + items = candidates[:max_items] + if not items: + return [], 0.0 + best_ids = [] + best_total = 0.0 + best_diff = abs(target) + n = len(items) + # Brute force all subsets (2^n, max 256) + for mask in range(1, 1 << n): + subset_ids = [] + subset_total = 0.0 + for j in range(n): + if mask & (1 << j): + subset_ids.append(items[j]['aml_id']) + subset_total += items[j]['amount_residual'] + diff = abs(subset_total - target) + if diff < best_diff: + best_diff = diff + best_ids = subset_ids + best_total = subset_total + if diff < 0.01: + break # Exact match found + return best_ids, round(best_total, 2) + + +def suggest_bank_line_matches(env, params): + """Find candidate journal items (invoices/bills) that could match a bank statement line. + Scores and ranks matches, finds best subset-sum combination. + Returns data for a reconciliation-mode fusion-table.""" + line_id = int(params['statement_line_id']) + line = env['account.bank.statement.line'].browse(line_id) + if not line.exists(): + return {'error': 'Bank statement line not found'} + if line.is_reconciled: + return {'error': 'Bank statement line is already reconciled'} + + AML = env['account.move.line'].sudo() + bank_amount = abs(line.amount) + line_date = line.move_id.date + is_incoming = line.amount > 0 # positive = customer payment, negative = vendor payment + from datetime import timedelta as td + + # Determine partner + partner = line.partner_id + if not partner: + partner = _extract_partner_from_ref(env, line.payment_ref) + + # Base domain common to all searches + base_domain = [ + ('parent_state', '=', 'posted'), + ('reconciled', '=', False), + ('company_id', '=', env.company.id), + ('display_type', 'not in', ('line_section', 'line_subsection', 'line_note')), + ('statement_line_id', '=', False), + ] + + # --- PRIORITY 1: Outstanding payments/receipts on bank journal accounts --- + # These are registered payments waiting to be matched to bank lines. + # For incoming bank lines → look for outstanding receipts (credit on outstanding account) + # For outgoing bank lines → look for outstanding payments (debit on outstanding account) + outstanding_acct_ids = env['account.account'].search([ + ('name', 'ilike', 'outstanding'), + ('company_ids', 'in', env.company.id), + ]).ids + outstanding_amls = AML + if outstanding_acct_ids: + os_domain = base_domain + [('account_id', 'in', outstanding_acct_ids)] + if is_incoming: + os_domain.append(('amount_residual', '>', 0)) # Debit residual on outstanding receipts + else: + os_domain.append(('amount_residual', '<', 0)) # Credit residual on outstanding payments + if partner: + outstanding_amls = AML.search(os_domain + [('partner_id', '=', partner.id)], limit=30) + if not outstanding_amls: + outstanding_amls = AML.search(os_domain, limit=30) + else: + outstanding_amls = AML.search(os_domain, limit=30) + + # --- PRIORITY 2: Open invoices/bills (receivable/payable accounts) --- + inv_domain = list(base_domain) + if is_incoming: + inv_domain.append(('account_id.account_type', '=', 'asset_receivable')) + inv_domain.append(('amount_residual', '>', 0)) + else: + inv_domain.append(('account_id.account_type', '=', 'liability_payable')) + inv_domain.append(('amount_residual', '<', 0)) + inv_domain.append(('date', '>=', str(line_date - td(days=90)))) + inv_domain.append(('date', '<=', str(line_date + td(days=30)))) + + invoice_amls = AML + if partner: + invoice_amls = AML.search(inv_domain + [('partner_id', '=', partner.id)], limit=30) + if not invoice_amls: + invoice_amls = AML.search(inv_domain, limit=30) + else: + invoice_amls = AML.search(inv_domain, limit=30) + + # Merge: outstanding payments first (priority), then invoices/bills + combined = outstanding_amls | invoice_amls + + # Score and format candidates + outstanding_ids = set(outstanding_amls.ids) if outstanding_amls else set() + candidates = [] + seen_ids = set() + for aml in combined: + if aml.id in seen_ids: + continue + seen_ids.add(aml.id) + residual = abs(aml.amount_residual) + score = 0 + reasons = [] + is_payment = aml.id in outstanding_ids + + # Source type: payment entries get a boost (preferred match) + if is_payment: + score += 15 + reasons.append('payment') + + # Amount scoring + if abs(residual - bank_amount) < 0.01: + score += 40 + reasons.append('exact amount') + elif residual <= bank_amount * 1.05: + score += 20 + reasons.append('close amount') + + # Partner scoring + if partner and aml.partner_id.id == partner.id: + score += 25 + reasons.append('partner') + elif partner and aml.partner_id and partner.name and aml.partner_id.name: + p1_words = set(partner.name.upper().split()) + p2_words = set(aml.partner_id.name.upper().split()) + if p1_words & p2_words: + score += 10 + reasons.append('partial partner') + + # Date proximity scoring + days_apart = abs((aml.date - line_date).days) + if days_apart <= 3: + score += 15 + reasons.append(f'{days_apart}d') + elif days_apart <= 7: + score += 10 + elif days_apart <= 14: + score += 5 + + # Reference matching + if line.payment_ref and aml.move_id.ref: + if any(w.upper() in (aml.move_id.ref or '').upper() + for w in line.payment_ref.split() if len(w) > 3): + score += 10 + reasons.append('ref match') + + # Determine entry type label + entry_type = 'payment' if is_payment else 'invoice' + if aml.move_id.move_type == 'in_invoice': + entry_type = 'bill' + elif aml.move_id.move_type == 'out_invoice': + entry_type = 'invoice' + elif aml.move_id.move_type in ('in_refund', 'out_refund'): + entry_type = 'credit note' + elif aml.payment_id: + entry_type = 'payment' + + candidates.append({ + 'aml_id': aml.id, + 'move_id': aml.move_id.id, + 'name': aml.move_id.name or '', + 'ref': aml.move_id.ref or '', + 'partner': aml.partner_id.name if aml.partner_id else '', + 'partner_id': aml.partner_id.id if aml.partner_id else None, + 'date': str(aml.date), + 'amount_total': abs(aml.balance), + 'amount_residual': residual, + 'account': aml.account_id.code if hasattr(aml.account_id, 'code') else '', + 'type': entry_type, + 'score': score, + 'reasons': ', '.join(reasons) if reasons else '', + }) + + # Sort by score descending + candidates.sort(key=lambda c: -c['score']) + + # Find best subset-sum combination + best_combo_ids, best_combo_total = _find_best_subset(candidates, bank_amount) + + # Mark which candidates are in the best combination + for c in candidates: + c['in_best_combo'] = c['aml_id'] in best_combo_ids + + return { + 'bank_line': { + 'id': line.id, + 'date': str(line_date), + 'ref': line.payment_ref or '', + 'amount': line.amount, + 'abs_amount': bank_amount, + 'journal': line.journal_id.name, + 'partner': partner.name if partner else '', + 'partner_id': partner.id if partner else None, + 'direction': 'incoming' if is_incoming else 'outgoing', + }, + 'candidates': candidates[:20], + 'best_combination': best_combo_ids, + 'best_combination_total': best_combo_total, + 'is_exact_match': abs(best_combo_total - bank_amount) < 0.01, + 'count': len(candidates), + } + + +def search_matching_entries(env, params): + """Search open journal items by query (invoice/bill number, amount, or partner name). + Used by the reconciliation table search bar via direct RPC.""" + query = (params.get('query') or '').strip() + line_id = params.get('statement_line_id') + if not query: + return {'candidates': []} + + AML = env['account.move.line'].sudo() + + # Search across receivable, payable, AND outstanding accounts + outstanding_acct_ids = env['account.account'].search([ + ('name', 'ilike', 'outstanding'), + ('company_ids', 'in', env.company.id), + ]).ids + domain = [ + ('parent_state', '=', 'posted'), + ('reconciled', '=', False), + ('company_id', '=', env.company.id), + ('display_type', 'not in', ('line_section', 'line_subsection', 'line_note')), + '|', + ('account_id.account_type', 'in', ('asset_receivable', 'liability_payable')), + ('account_id', 'in', outstanding_acct_ids), + ] + + # Try as amount first + try: + amount = float(query.replace('$', '').replace(',', '')) + amount_domain = domain + [ + '|', + '&', ('amount_residual', '>=', amount - 0.50), ('amount_residual', '<=', amount + 0.50), + '&', ('amount_residual', '>=', -amount - 0.50), ('amount_residual', '<=', -amount + 0.50), + ] + amls = AML.search(amount_domain, limit=15) + if amls: + return {'candidates': _format_aml_candidates(amls)} + except ValueError: + pass + + # Search by move name (invoice/bill number) + name_amls = AML.search(domain + [('move_id.name', 'ilike', query)], limit=15) + if name_amls: + return {'candidates': _format_aml_candidates(name_amls)} + + # Search by move ref + ref_amls = AML.search(domain + [('move_id.ref', 'ilike', query)], limit=15) + if ref_amls: + return {'candidates': _format_aml_candidates(ref_amls)} + + # Search by partner name + partner_amls = AML.search(domain + [('partner_id.name', 'ilike', query)], limit=15) + return {'candidates': _format_aml_candidates(partner_amls)} + + +def _format_aml_candidates(amls): + """Format AMLs as candidate dicts for the reconciliation table.""" + return [{ + 'aml_id': aml.id, + 'move_id': aml.move_id.id, + 'name': aml.move_id.name or '', + 'ref': aml.move_id.ref or '', + 'partner': aml.partner_id.name if aml.partner_id else '', + 'partner_id': aml.partner_id.id if aml.partner_id else None, + 'date': str(aml.date), + 'amount_total': abs(aml.balance), + 'amount_residual': abs(aml.amount_residual), + 'account': aml.account_id.code if hasattr(aml.account_id, 'code') else '', + 'score': 0, + 'reasons': 'manual search', + 'in_best_combo': False, + } for aml in amls] + + TOOLS = { 'get_unreconciled_bank_lines': get_unreconciled_bank_lines, 'get_unreconciled_receipts': get_unreconciled_receipts, @@ -496,4 +954,8 @@ TOOLS = { 'get_bank_line_details': get_bank_line_details, 'check_recurring_pattern': check_recurring_pattern, 'match_internal_transfers': match_internal_transfers, + 'find_unreconciled_cheques': find_unreconciled_cheques, + 'reconcile_payroll_cheques': reconcile_payroll_cheques, + 'suggest_bank_line_matches': suggest_bank_line_matches, + 'search_matching_entries': search_matching_entries, } diff --git a/fusion_accounting/services/tools/hst_management.py b/fusion_accounting/services/tools/hst_management.py index 27b946d0..63f4e71c 100644 --- a/fusion_accounting/services/tools/hst_management.py +++ b/fusion_accounting/services/tools/hst_management.py @@ -19,10 +19,10 @@ def calculate_hst_balance(env, params): # (shared chart of accounts). Use try/except to handle both cases. try: collected_accounts = env['account.account'].search([ - ('code', '=like', '2005%'), ('company_id', '=', env.company.id), + ('code', '=like', '2005%'), ('company_ids', 'in', env.company.id), ]) itc_accounts = env['account.account'].search([ - ('code', '=like', '2006%'), ('company_id', '=', env.company.id), + ('code', '=like', '2006%'), ('company_ids', 'in', env.company.id), ]) except Exception: collected_accounts = env['account.account'].search([ @@ -216,7 +216,7 @@ def create_expense_entry(env, params): # Fallback to AP account credit_account = env['account.account'].search([ ('account_type', '=', 'liability_payable'), - ('company_id', '=', env.company.id), + ('company_ids', 'in', env.company.id), ], limit=1) if not credit_account: diff --git a/fusion_accounting/services/tools/inventory.py b/fusion_accounting/services/tools/inventory.py index 34d04d5a..a04fed99 100644 --- a/fusion_accounting/services/tools/inventory.py +++ b/fusion_accounting/services/tools/inventory.py @@ -7,7 +7,7 @@ _logger = logging.getLogger(__name__) def get_stock_valuation(env, params): accounts = env['account.account'].search([ ('code', '=like', '1069%'), - ('company_id', '=', env.company.id), + ('company_ids', 'in', env.company.id), ]) result = [] for acct in accounts: @@ -22,7 +22,7 @@ def get_stock_valuation(env, params): def get_price_differences(env, params): accounts = env['account.account'].search([ ('code', '=like', '5010%'), - ('company_id', '=', env.company.id), + ('company_ids', 'in', env.company.id), ]) domain = [ ('account_id', 'in', accounts.ids), diff --git a/fusion_accounting/services/tools/journal_review.py b/fusion_accounting/services/tools/journal_review.py index 99120b42..c2bbe2c3 100644 --- a/fusion_accounting/services/tools/journal_review.py +++ b/fusion_accounting/services/tools/journal_review.py @@ -108,7 +108,7 @@ def find_wrong_account_entries(env, params): tax_accounts = env['account.account'].search([ ('account_type', 'in', ('liability_current', 'asset_current')), ('code', '=like', '2005%'), - ('company_id', '=', env.company.id), + ('company_ids', 'in', env.company.id), ]) if tax_accounts: revenue_on_tax = env['account.move.line'].search( @@ -171,7 +171,7 @@ def find_draft_entries(env, params): def find_unreconciled_suspense(env, params): suspense_accounts = env['account.account'].search([ ('code', '=like', '999%'), - ('company_id', '=', env.company.id), + ('company_ids', 'in', env.company.id), ]) issues = [] for acct in suspense_accounts: diff --git a/fusion_accounting/services/tools/month_end.py b/fusion_accounting/services/tools/month_end.py index 93e64cd1..e35fc7cc 100644 --- a/fusion_accounting/services/tools/month_end.py +++ b/fusion_accounting/services/tools/month_end.py @@ -35,7 +35,7 @@ def get_close_checklist(env, params): def get_unreconciled_counts(env, params): accounts = env['account.account'].search([ ('reconcile', '=', True), - ('company_id', '=', env.company.id), + ('company_ids', 'in', env.company.id), ]) result = [] for acct in accounts: @@ -77,7 +77,7 @@ def get_accrual_status(env, params): for code in accrual_codes: accounts = env['account.account'].search([ ('code', '=like', f'{code}%'), - ('company_id', '=', env.company.id), + ('company_ids', 'in', env.company.id), ]) for acct in accounts: balance = sum(env['account.move.line'].search([ diff --git a/fusion_accounting/services/tools/payroll.py b/fusion_accounting/services/tools/payroll.py index 1603dade..79cfcf2a 100644 --- a/fusion_accounting/services/tools/payroll.py +++ b/fusion_accounting/services/tools/payroll.py @@ -66,7 +66,7 @@ def verify_source_deductions(env, params): def get_cra_remittance_status(env, params): cra_accounts = env['account.account'].search([ ('name', 'ilike', 'CRA'), - ('company_id', '=', env.company.id), + ('company_ids', 'in', env.company.id), ]) result = [] for acct in cra_accounts: @@ -130,21 +130,72 @@ def parse_payroll_summary(env, params): } +def _resolve_account_id(env, val): + """Resolve an account code or ID to a valid account ID. + Accepts: integer ID, string ID, or account code string like '2201'.""" + if not val: + return False + val_str = str(val).strip() + # Try as a direct ID first + try: + acct = env['account.account'].browse(int(val_str)) + if acct.exists(): + return acct.id + except (ValueError, TypeError): + pass + # Try as an account code + acct = env['account.account'].search([ + ('code', '=', val_str), + ('company_ids', 'in', env.company.id), + ], limit=1) + if acct: + return acct.id + return False + + def create_payroll_journal_entry(env, params): journal_id = int(params['journal_id']) date = params['date'] + ref = params.get('ref', 'Payroll Entry') lines_data = params['lines'] - move_vals = { - 'journal_id': journal_id, - 'date': date, - 'ref': params.get('ref', 'Payroll Entry'), - 'line_ids': [(0, 0, { - 'account_id': int(line['account_id']), + + # Duplicate check: same journal + date + ref + similar amount + total_debit = sum(float(l.get('debit', 0)) for l in lines_data) + existing = env['account.move'].search([ + ('journal_id', '=', journal_id), + ('date', '=', date), + ('ref', 'ilike', ref[:30]), + ('state', 'in', ('draft', 'posted')), + ], limit=1) + if existing: + return { + 'status': 'duplicate', + 'error': f'Entry already exists: {existing.name} (ref: {existing.ref}) on {existing.date} ' + f'for ${existing.amount_total:,.2f}. Skipping to avoid duplicate.', + 'existing_move_id': existing.id, + 'existing_name': existing.name, + } + + # Resolve account codes to IDs + resolved_lines = [] + for line in lines_data: + account_id = _resolve_account_id(env, line['account_id']) + if not account_id: + return {'error': f"Account not found: {line['account_id']}. " + f"Provide a valid account code (e.g. '2201') or database ID."} + resolved_lines.append((0, 0, { + 'account_id': account_id, 'name': line.get('name', 'Payroll'), 'debit': float(line.get('debit', 0)), 'credit': float(line.get('credit', 0)), 'partner_id': int(line['partner_id']) if line.get('partner_id') else False, - }) for line in lines_data], + })) + + move_vals = { + 'journal_id': journal_id, + 'date': date, + 'ref': ref, + 'line_ids': resolved_lines, } move = env['account.move'].create(move_vals) return {'status': 'created', 'move_id': move.id, 'name': move.name} diff --git a/fusion_accounting/services/tools/reporting.py b/fusion_accounting/services/tools/reporting.py index 92324b2d..dad430bb 100644 --- a/fusion_accounting/services/tools/reporting.py +++ b/fusion_accounting/services/tools/reporting.py @@ -106,6 +106,171 @@ def export_report(env, params): return {'error': f'Export failed: {str(e)}'} +def get_invoicing_summary(env, params): + """Get invoicing summary — total invoiced by month, by partner, or for a date range. + Supports: monthly breakdown for a year, current month totals, or filtered by partner.""" + from datetime import date, timedelta + import calendar + + year = int(params.get('year', date.today().year)) + partner_name = params.get('partner_name') + date_from = params.get('date_from') + date_to = params.get('date_to') + + domain = [ + ('move_type', '=', 'out_invoice'), + ('state', '=', 'posted'), + ('company_id', '=', env.company.id), + ] + + if partner_name: + partner = env['res.partner'].search([('name', 'ilike', partner_name)], limit=1) + if partner: + domain.append(('partner_id', '=', partner.id)) + else: + return {'error': f'Partner not found: {partner_name}'} + + if date_from and date_to: + domain += [('date', '>=', date_from), ('date', '<=', date_to)] + invoices = env['account.move'].search(domain, order='date desc') + total = sum(inv.amount_total for inv in invoices) + return { + 'period': f'{date_from} to {date_to}', + 'count': len(invoices), + 'total': total, + 'invoices': [{ + 'id': inv.id, 'name': inv.name, 'partner': inv.partner_id.name, + 'date': str(inv.date), 'amount': inv.amount_total, + 'payment_state': inv.payment_state, + } for inv in invoices[:30]], + } + + # Monthly breakdown for the year + months = [] + grand_total = 0 + for month in range(1, 13): + m_start = f'{year}-{month:02d}-01' + last_day = calendar.monthrange(year, month)[1] + m_end = f'{year}-{month:02d}-{last_day}' + m_domain = domain + [('date', '>=', m_start), ('date', '<=', m_end)] + invoices = env['account.move'].search(m_domain) + total = sum(inv.amount_total for inv in invoices) + grand_total += total + months.append({ + 'month': f'{year}-{month:02d}', + 'month_name': calendar.month_name[month], + 'count': len(invoices), + 'total': round(total, 2), + }) + + return { + 'year': year, + 'grand_total': round(grand_total, 2), + 'months': months, + 'partner': partner_name or 'All', + } + + +def get_billing_summary(env, params): + """Get billing (vendor bills) summary — total billed by month or date range.""" + from datetime import date + import calendar + + year = int(params.get('year', date.today().year)) + partner_name = params.get('partner_name') + date_from = params.get('date_from') + date_to = params.get('date_to') + + domain = [ + ('move_type', '=', 'in_invoice'), + ('state', '=', 'posted'), + ('company_id', '=', env.company.id), + ] + + if partner_name: + partner = env['res.partner'].search([('name', 'ilike', partner_name)], limit=1) + if partner: + domain.append(('partner_id', '=', partner.id)) + else: + return {'error': f'Partner not found: {partner_name}'} + + if date_from and date_to: + domain += [('date', '>=', date_from), ('date', '<=', date_to)] + bills = env['account.move'].search(domain, order='date desc') + total = sum(b.amount_total for b in bills) + return { + 'period': f'{date_from} to {date_to}', + 'count': len(bills), + 'total': total, + 'bills': [{ + 'id': b.id, 'name': b.name, 'partner': b.partner_id.name, + 'date': str(b.date), 'amount': b.amount_total, + 'payment_state': b.payment_state, + } for b in bills[:30]], + } + + # Monthly breakdown + months = [] + grand_total = 0 + for month in range(1, 13): + m_start = f'{year}-{month:02d}-01' + last_day = calendar.monthrange(year, month)[1] + m_end = f'{year}-{month:02d}-{last_day}' + m_domain = domain + [('date', '>=', m_start), ('date', '<=', m_end)] + bills = env['account.move'].search(m_domain) + total = sum(b.amount_total for b in bills) + grand_total += total + months.append({ + 'month': f'{year}-{month:02d}', + 'month_name': calendar.month_name[month], + 'count': len(bills), + 'total': round(total, 2), + }) + + return { + 'year': year, + 'grand_total': round(grand_total, 2), + 'months': months, + 'partner': partner_name or 'All', + } + + +def get_collections_summary(env, params): + """Get payment collections summary — how much was collected (received) in a period.""" + date_from = params.get('date_from') + date_to = params.get('date_to') + if not date_from or not date_to: + from datetime import date + today = date.today() + date_from = date_from or f'{today.year}-{today.month:02d}-01' + date_to = date_to or str(today) + + payments = env['account.payment'].search([ + ('payment_type', '=', 'inbound'), + ('state', '=', 'posted'), + ('date', '>=', date_from), + ('date', '<=', date_to), + ('company_id', '=', env.company.id), + ], order='date desc') + + total = sum(p.amount for p in payments) + by_partner = {} + for p in payments: + pname = p.partner_id.name if p.partner_id else 'Unknown' + by_partner.setdefault(pname, {'count': 0, 'total': 0}) + by_partner[pname]['count'] += 1 + by_partner[pname]['total'] += p.amount + + top_partners = sorted(by_partner.items(), key=lambda x: -x[1]['total'])[:15] + + return { + 'period': f'{date_from} to {date_to}', + 'total_collected': round(total, 2), + 'payment_count': len(payments), + 'by_partner': [{'partner': k, 'count': v['count'], 'total': round(v['total'], 2)} for k, v in top_partners], + } + + TOOLS = { 'get_profit_loss': get_profit_loss, 'get_balance_sheet': get_balance_sheet, @@ -114,4 +279,7 @@ TOOLS = { 'compare_periods': compare_periods, 'answer_financial_question': answer_financial_question, 'export_report': export_report, + 'get_invoicing_summary': get_invoicing_summary, + 'get_billing_summary': get_billing_summary, + 'get_collections_summary': get_collections_summary, } diff --git a/fusion_accounting/static/src/components/chat/approval_card.js b/fusion_accounting/static/src/components/chat/approval_card.js index 596fcbc7..af78bfe3 100644 --- a/fusion_accounting/static/src/components/chat/approval_card.js +++ b/fusion_accounting/static/src/components/chat/approval_card.js @@ -6,8 +6,25 @@ export class FusionApprovalCard extends Component { static template = "fusion_accounting.ApprovalCard"; static props = ["approval", "onApprove", "onReject"]; - get confidencePercent() { - return Math.round((this.props.approval.confidence || 0) * 100); + get toolLabel() { + const name = this.props.approval.tool_name || ""; + // Short labels for common tools + const labels = { + create_payroll_journal_entry: "Payroll JE", + create_vendor_bill: "Vendor Bill", + register_bill_payment: "Bill Payment", + create_expense_entry: "Expense", + register_hst_payment: "HST Payment", + apply_payment: "Payment", + send_followup: "Follow-up", + flag_entry: "Flag", + }; + return labels[name] || name.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase()); + } + + formatAmount(val) { + if (!val) return ""; + return Number(val).toLocaleString("en-CA", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } approve() { diff --git a/fusion_accounting/static/src/components/chat/approval_card.xml b/fusion_accounting/static/src/components/chat/approval_card.xml index 467e2f27..376312e0 100644 --- a/fusion_accounting/static/src/components/chat/approval_card.xml +++ b/fusion_accounting/static/src/components/chat/approval_card.xml @@ -1,29 +1,29 @@ -
-
-
- - - % conf - -
-

+ + + + + + + -

- Amount: $ -

+ $ -
- - -
-
-
+ + + + + +
diff --git a/fusion_accounting/static/src/components/chat/chat_panel.js b/fusion_accounting/static/src/components/chat/chat_panel.js index b053fb62..46d6a189 100644 --- a/fusion_accounting/static/src/components/chat/chat_panel.js +++ b/fusion_accounting/static/src/components/chat/chat_panel.js @@ -192,6 +192,7 @@ export class FusionChatPanel extends Component { setup() { this.inputRef = useRef("chatInput"); this.messagesRef = useRef("messages"); + this.fileRef = useRef("fileInput"); // Track parsed table data per message index for interactive tables this._parsedTables = {}; this.state = useState({ @@ -207,7 +208,13 @@ export class FusionChatPanel extends Component { // Session history picker showSessionPicker: false, sessionList: [], + // Image attachment + pendingImage: null, // { name, dataUrl, base64, mediaType } + // Live execution status (from polling) + liveThinking: '', + liveToolCalls: [], }); + this._statusPollInterval = null; onWillStart(async () => { await this.loadLatestSession(); @@ -229,33 +236,51 @@ export class FusionChatPanel extends Component { for (const div of richDivs) { const idx = parseInt(div.dataset.idx); const msg = this.state.messages[idx]; - if (msg && msg.role === "assistant" && msg.content) { - // Check for fusion-table blocks + if (msg && msg.role === "assistant") { + let html = ""; + + // Priority 1: Server-side reconciliation table (direct from tool result) + if (msg.reconciliationTable && !div.dataset.reconMounted) { + const reconKey = `recon_${idx}`; + // Strip any fusion-table blocks from AI text to avoid double rendering + const cleanText = (msg.content || "").replace(/```fusion-table[\s\S]*?```/g, '').trim(); + html = mdToHtml(cleanText); + html += `
`; + this._parsedTables[reconKey] = { + mode: "reconciliation", + source_tool: "suggest_bank_line_matches", + bank_line: msg.reconciliationTable.bank_line, + candidates: msg.reconciliationTable.candidates, + best_combination: msg.reconciliationTable.best_combination, + }; + div.innerHTML = html; + div.dataset.reconMounted = "true"; + this._mountInteractiveTables(div); + } + // Priority 2: AI-generated fusion-table blocks + else if (msg.content && !div.dataset.reconMounted) { const parsed = parseFusionTableBlock(msg.content); if (parsed) { - // Build HTML with placeholders for interactive tables - let html = ""; for (const part of parsed.parts) { if (part.type === "md") { html += mdToHtml(part.content); } else if (part.type === "table") { const tableKey = `${idx}_${part.idx}`; html += `
`; - // Store table data for OWL mounting this._parsedTables[tableKey] = parsed.tables[part.idx]; } } if (div.innerHTML !== html) { div.innerHTML = html; } - // Mount OWL interactive table components into placeholders this._mountInteractiveTables(div); } else { - const html = mdToHtml(msg.content); - if (div.innerHTML !== html) { - div.innerHTML = html; + const mdHtml = mdToHtml(msg.content); + if (div.innerHTML !== mdHtml) { + div.innerHTML = mdHtml; } } + } } } } @@ -269,8 +294,13 @@ export class FusionChatPanel extends Component { if (!tableData) continue; el.dataset.mounted = "true"; - el.innerHTML = this._buildInteractiveTableHtml(tableData, key); - this._wireTableEvents(el, tableData, key); + if (tableData.mode === "reconciliation") { + el.innerHTML = this._buildReconciliationTableHtml(tableData, key); + this._wireReconciliationEvents(el, tableData, key); + } else { + el.innerHTML = this._buildInteractiveTableHtml(tableData, key); + this._wireTableEvents(el, tableData, key); + } } } @@ -464,6 +494,383 @@ export class FusionChatPanel extends Component { return result; } + // ================================================================ + // Image Upload + // ================================================================ + + triggerFileUpload() { + const input = this.fileRef.el; + if (input) input.click(); + } + + onFileSelected(ev) { + const file = ev.target.files?.[0]; + if (!file) return; + if (!file.type.startsWith('image/')) { + console.warn("Only image files are supported"); + return; + } + if (file.size > 10 * 1024 * 1024) { + console.warn("Image too large (max 10MB)"); + return; + } + const reader = new FileReader(); + reader.onload = () => { + const dataUrl = reader.result; + // Extract base64 and media type + const match = dataUrl.match(/^data:(image\/[^;]+);base64,(.+)$/); + if (match) { + this.state.pendingImage = { + name: file.name, + dataUrl, + base64: match[2], + mediaType: match[1], + }; + } + }; + reader.readAsDataURL(file); + // Reset input so same file can be selected again + ev.target.value = ''; + } + + clearImage() { + this.state.pendingImage = null; + } + + _startStatusPolling() { + this._stopStatusPolling(); + this.state.liveThinking = ''; + this.state.liveToolCalls = []; + this._statusPollInterval = setInterval(async () => { + if (!this.state.sending || !this.sessionId) { + this._stopStatusPolling(); + return; + } + try { + const status = await rpc('/fusion_accounting/chat/status', { + session_id: this.sessionId, + }); + if (status.thinking) { + this.state.liveThinking = status.thinking; + } + if (status.tool_calls && status.tool_calls.length) { + this.state.liveToolCalls = status.tool_calls; + } + } catch (e) { + // Polling failure is not critical — just skip + } + }, 600); + } + + _stopStatusPolling() { + if (this._statusPollInterval) { + clearInterval(this._statusPollInterval); + this._statusPollInterval = null; + } + this.state.liveThinking = ''; + this.state.liveToolCalls = []; + } + + // ================================================================ + // Reconciliation Table Mode + // ================================================================ + + _buildReconciliationTableHtml(tableData, key) { + const bankLine = tableData.bank_line || {}; + // Accept candidates from either 'candidates' (tool output) or 'rows' (AI formatted) + let rawCandidates = tableData.candidates || tableData.rows || []; + // Normalize: if AI sent rows with 'id' instead of 'aml_id', or 'cells' arrays, fix them + const candidates = rawCandidates.map(c => { + if (c.aml_id) return c; // Already in correct format + // Try to extract from cells array if AI used interactive table format + const norm = { ...c }; + if (!norm.aml_id && norm.id) norm.aml_id = norm.id; + if (!norm.amount_residual && typeof norm.amount_residual !== 'number') norm.amount_residual = norm.amount_total || norm.amount || 0; + return norm; + }); + // Store normalized rows back for collect function + tableData.rows = candidates; + const bestCombo = new Set((tableData.best_combination || []).map(String)); + const bankAmount = Math.abs(bankLine.abs_amount || bankLine.amount || 0); + const title = tableData.title || `Match: ${bankLine.ref || ''} $${bankAmount.toFixed(2)}`; + + let h = `
`; + + // Header + h += `
`; + h += `
${this._esc(title)}`; + h += `${bankLine.direction || ''}
`; + h += `${bankLine.journal || ''}
`; + + // Table + h += '
'; + h += ''; + h += ''; + h += ''; + h += ''; + h += ''; + h += ''; + h += ''; + h += ''; + h += ''; + + for (let i = 0; i < candidates.length; i++) { + const c = candidates[i]; + const inCombo = bestCombo.has(String(c.aml_id)); + const checked = inCombo ? 'checked' : ''; + const residual = c.amount_residual || c.amount_total || 0; + const score = c.score || 0; + const scoreClass = score >= 60 ? 'text-success' : score >= 30 ? 'text-warning' : 'text-muted'; + + h += ``; + h += ``; + h += ``; + const typeClass = c.type === 'payment' ? 'bg-success-subtle text-success' : c.type === 'bill' ? 'bg-info-subtle text-info' : 'bg-primary-subtle text-primary'; + h += ``; + h += ``; + h += ``; + h += ``; + h += ``; + h += ``; + } + h += '
EntryTypePartnerDateResidualApplyScore
${this._esc(c.name)}`; + if (c.ref) h += `
${this._esc(c.ref)}`; + h += `
${this._esc(c.type || 'entry')}${this._esc(c.partner)}${this._esc(c.date)}$${residual.toFixed(2)}`; + h += ``; + h += `${score}`; + if (c.reasons) h += ` — ${this._esc(c.reasons)}`; + h += `
'; + + // Search bar + h += ''; + + // Running total footer + h += '
'; + h += '
'; + h += '$0.00'; + h += ` / Bank: $${bankAmount.toFixed(2)}`; + h += ' '; + h += '
'; + h += `'; + h += '
'; + + h += '
'; + return h; + } + + _wireReconciliationEvents(container, tableData, key) { + const bankAmount = parseFloat(container.querySelector('.fusion_recon_table')?.dataset.bankAmount || '0'); + + const recalcTotal = () => { + let total = 0; + const checks = container.querySelectorAll('.recon-row-check:checked'); + for (const cb of checks) { + const idx = parseInt(cb.dataset.idx); + const amtInput = container.querySelector(`.fusion_apply_amount[data-idx="${idx}"]`); + if (amtInput) total += parseFloat(amtInput.value) || 0; + } + total = Math.round(total * 100) / 100; + const totalEl = container.querySelector('.fusion_selected_total'); + const badgeEl = container.querySelector('.fusion_remaining_badge'); + const applyBtn = container.querySelector('.fusion_apply_match_btn'); + if (totalEl) totalEl.textContent = `$${total.toFixed(2)}`; + const remaining = Math.round((bankAmount - total) * 100) / 100; + if (badgeEl) { + if (Math.abs(remaining) < 0.01) { + badgeEl.textContent = 'Balanced ✓'; + badgeEl.className = 'fusion_remaining_badge badge ms-1 bg-success'; + } else if (remaining > 0) { + badgeEl.textContent = `Remaining: $${remaining.toFixed(2)}`; + badgeEl.className = 'fusion_remaining_badge badge ms-1 bg-warning text-dark'; + } else { + badgeEl.textContent = `Over: $${Math.abs(remaining).toFixed(2)}`; + badgeEl.className = 'fusion_remaining_badge badge ms-1 bg-danger'; + } + } + if (applyBtn) applyBtn.disabled = total === 0; + }; + + // Select-all + const selectAll = container.querySelector('[data-action="recon-select-all"]'); + if (selectAll) { + selectAll.addEventListener('change', () => { + for (const cb of container.querySelectorAll('.recon-row-check')) cb.checked = selectAll.checked; + recalcTotal(); + }); + } + + // Row checkboxes + for (const cb of container.querySelectorAll('.recon-row-check')) { + cb.addEventListener('change', recalcTotal); + } + + // Amount inputs + for (const inp of container.querySelectorAll('.fusion_apply_amount')) { + inp.addEventListener('input', () => { + const max = parseFloat(inp.dataset.max) || 0; + let val = parseFloat(inp.value) || 0; + if (val > max) { inp.value = max.toFixed(2); } + if (val < 0) { inp.value = '0.00'; } + recalcTotal(); + }); + } + + // Apply Match button + const applyBtn = container.querySelector('.fusion_apply_match_btn'); + if (applyBtn) { + applyBtn.addEventListener('click', () => { + const rows = this._collectReconciliationRows(container, tableData); + if (!rows.length) return; + const bankLineId = container.querySelector('.fusion_recon_table')?.dataset.bankLineId; + this.onTableAction({ + action: 'apply_match', + source_tool: tableData.source_tool || 'suggest_bank_line_matches', + bank_line_id: bankLineId, + bank_amount: bankAmount, + rows, + }); + }); + } + + // Search bar with debounce + const searchInput = container.querySelector('.fusion_match_search'); + const resultsDiv = container.querySelector('.fusion_search_results'); + let searchTimeout = null; + if (searchInput && resultsDiv) { + searchInput.addEventListener('input', () => { + clearTimeout(searchTimeout); + const q = searchInput.value.trim(); + if (q.length < 2) { resultsDiv.classList.add('d-none'); return; } + searchTimeout = setTimeout(async () => { + try { + const data = await rpc('/fusion_accounting/search_matches', { + statement_line_id: parseInt(searchInput.dataset.lineId) || 0, + query: q, + }); + this._renderSearchResults(resultsDiv, container, data.candidates || [], tableData, key); + } catch (e) { + resultsDiv.innerHTML = '
Search failed
'; + resultsDiv.classList.remove('d-none'); + } + }, 300); + }); + } + + // Initial total calculation + recalcTotal(); + } + + _renderSearchResults(resultsDiv, tableContainer, candidates, tableData, key) { + if (!candidates.length) { + resultsDiv.innerHTML = '
No matching entries found
'; + resultsDiv.classList.remove('d-none'); + return; + } + let h = '
'; + for (const c of candidates.slice(0, 8)) { + // Skip if already in the table + if (tableContainer.querySelector(`tr[data-aml-id="${c.aml_id}"]`)) continue; + h += ``; + } + h += '
'; + resultsDiv.innerHTML = h; + resultsDiv.classList.remove('d-none'); + + // Wire add-on-click + for (const btn of resultsDiv.querySelectorAll('.fusion_search_result')) { + btn.addEventListener('click', () => { + const candidate = JSON.parse(btn.dataset.aml); + this._addRowToReconciliationTable(tableContainer, candidate, tableData); + resultsDiv.classList.add('d-none'); + const searchInput = tableContainer.querySelector('.fusion_match_search'); + if (searchInput) searchInput.value = ''; + }); + } + } + + _addRowToReconciliationTable(container, candidate, tableData) { + const tbody = container.querySelector('tbody'); + if (!tbody) return; + const rows = tableData.rows || []; + const idx = rows.length; + rows.push(candidate); + const residual = candidate.amount_residual || 0; + + const tr = document.createElement('tr'); + tr.dataset.rowIdx = idx; + tr.dataset.amlId = candidate.aml_id; + const cType = candidate.type || 'entry'; + const cTypeClass = cType === 'payment' ? 'bg-success-subtle text-success' : cType === 'bill' ? 'bg-info-subtle text-info' : 'bg-primary-subtle text-primary'; + tr.innerHTML = ` + + ${this._esc(candidate.name)}${candidate.ref ? '
' + this._esc(candidate.ref) + '' : ''} + ${this._esc(cType)} + ${this._esc(candidate.partner)} + ${this._esc(candidate.date)} + $${residual.toFixed(2)} + + + + added + `; + tbody.appendChild(tr); + + // Wire new row's checkbox and amount input + const cb = tr.querySelector('.recon-row-check'); + const amt = tr.querySelector('.fusion_apply_amount'); + const recalc = () => { + // Trigger recalc by dispatching change on first checkbox + const first = container.querySelector('.recon-row-check'); + if (first) first.dispatchEvent(new Event('change')); + }; + if (cb) cb.addEventListener('change', recalc); + if (amt) amt.addEventListener('input', recalc); + recalc(); + } + + _collectReconciliationRows(container, tableData) { + const result = []; + const checks = container.querySelectorAll('.recon-row-check:checked'); + const rows = tableData.rows || []; + for (const cb of checks) { + const idx = parseInt(cb.dataset.idx); + const tr = cb.closest('tr'); + const amlId = tr?.dataset.amlId; + const amtInput = container.querySelector(`.fusion_apply_amount[data-idx="${idx}"]`); + const applyAmount = parseFloat(amtInput?.value) || 0; + const maxAmount = parseFloat(amtInput?.dataset.max) || 0; + const c = rows[idx] || {}; + result.push({ + aml_id: parseInt(amlId) || c.aml_id, + name: c.name || '', + apply_amount: applyAmount, + amount_residual: maxAmount, + is_partial: applyAmount < maxAmount - 0.01, + }); + } + // Sort: full matches first, partial last (Odoo applies partial on last AML) + result.sort((a, b) => (a.is_partial ? 1 : 0) - (b.is_partial ? 1 : 0)); + return result; + } + get sessionId() { return this.state.internalSessionId || this.props.sessionId; } @@ -476,6 +883,9 @@ export class FusionChatPanel extends Component { this.state.internalSessionId = data.session_id; this.state.messages = data.messages || []; this.state.sessionName = data.name; + if (data.pending_approvals && data.pending_approvals.length) { + this.state.pendingApprovals = data.pending_approvals; + } } } catch (e) { console.error("Failed to load session:", e); @@ -569,7 +979,8 @@ export class FusionChatPanel extends Component { async sendMessage() { const text = this.state.inputText.trim(); - if (!text || this.state.sending) return; + const image = this.state.pendingImage; + if ((!text && !image) || this.state.sending) return; if (!this.sessionId) { const session = await rpc("/fusion_accounting/session/create", {}); @@ -577,30 +988,54 @@ export class FusionChatPanel extends Component { this.state.sessionName = session.name; } - this.state.messages.push({ role: "user", content: text }); + this.state.messages.push({ + role: "user", + content: text || (image ? `[Attached: ${image.name}]` : ''), + hasImage: !!image, + imageUrl: image?.dataUrl || null, + }); this.state.inputText = ""; + this.state.pendingImage = null; this.state.sending = true; this.scrollToBottom(); + this._startStatusPolling(); + + const chatPayload = { + session_id: this.sessionId, + message: text || "Please analyze the attached image.", + }; + if (image) { + chatPayload.image = { + base64: image.base64, + media_type: image.mediaType, + name: image.name, + }; + } try { - const result = await rpc("/fusion_accounting/chat", { - session_id: this.sessionId, - message: text, - }); - if (result.text) { - this.state.messages.push({ role: "assistant", content: result.text }); + const result = await rpc("/fusion_accounting/chat", chatPayload); + this._stopStatusPolling(); + if (result.text || (result.tool_calls_log && result.tool_calls_log.length)) { + this.state.messages.push({ + role: "assistant", + content: result.text || "", + toolCalls: result.tool_calls_log || [], + // Attach server-side reconciliation data for direct rendering + reconciliationTable: result.reconciliation_table || null, + }); } if (result.pending_approvals) { this.state.pendingApprovals = result.pending_approvals; } } catch (e) { + this._stopStatusPolling(); this.state.messages.push({ role: "assistant", content: `Error: ${e.message || "Something went wrong."}`, }); } this.state.sending = false; - this.scrollToBottom(); + this.scrollToNewReply(); } /** @@ -615,25 +1050,41 @@ export class FusionChatPanel extends Component { create_rule: "Create Rules", dismiss: "Dismiss", submit_notes: "Submit Notes", + apply_match: "Apply Match", }; const label = actionLabels[action] || action; - // Build a structured message for the AI - let parts = [`[TABLE_ACTION] source=${source_tool} action=${action}`]; - for (const row of rows) { - const cellSummary = (row.cells || []).join(" | "); - let line = `- Row #${row.id}: ${cellSummary}`; - if (row.recommendation) { - line += ` (AI suggested: ${row.recommendation.action} - ${row.recommendation.reason})`; + let message; + if (action === 'apply_match') { + // Reconciliation-specific format with AML IDs and custom amounts + const bankLineId = payload.bank_line_id || ''; + const bankAmount = payload.bank_amount || 0; + const total = rows.reduce((s, r) => s + (r.apply_amount || 0), 0); + let parts = [`[TABLE_ACTION] source=${source_tool} action=apply_match`]; + parts.push(`bank_line_id=${bankLineId} bank_amount=${bankAmount}`); + for (const row of rows) { + const tag = row.is_partial ? 'partial' : 'full'; + parts.push(`- AML #${row.aml_id}: ${row.name} | apply=$${(row.apply_amount || 0).toFixed(2)} residual=$${(row.amount_residual || 0).toFixed(2)} (${tag})`); } - if (row.userNote) { - line += ` [User note: ${row.userNote}]`; + parts.push(`Total: $${total.toFixed(2)} / Bank: $${bankAmount.toFixed(2)}`); + message = parts.join('\n'); + } else { + // Standard interactive table format + let parts = [`[TABLE_ACTION] source=${source_tool} action=${action}`]; + for (const row of rows) { + const cellSummary = (row.cells || []).join(" | "); + let line = `- Row #${row.id}: ${cellSummary}`; + if (row.recommendation) { + line += ` (AI suggested: ${row.recommendation.action} - ${row.recommendation.reason})`; + } + if (row.userNote) { + line += ` [User note: ${row.userNote}]`; + } + parts.push(line); } - parts.push(line); + message = parts.join('\n'); } - const message = parts.join("\n"); - // Show user what we're sending this.state.messages.push({ role: "user", @@ -641,26 +1092,38 @@ export class FusionChatPanel extends Component { }); this.state.sending = true; this.scrollToBottom(); + this._startStatusPolling(); try { const result = await rpc("/fusion_accounting/chat", { session_id: this.sessionId, message: message, }); - if (result.text) { - this.state.messages.push({ role: "assistant", content: result.text }); + this._stopStatusPolling(); + if (result.text || (result.tool_calls_log && result.tool_calls_log.length)) { + this.state.messages.push({ + role: "assistant", + content: result.text || "", + toolCalls: result.tool_calls_log || [], + }); } if (result.pending_approvals) { this.state.pendingApprovals = result.pending_approvals; } } catch (e) { + this._stopStatusPolling(); this.state.messages.push({ role: "assistant", content: `Error processing table action: ${e.message || "Something went wrong."}`, }); } this.state.sending = false; - this.scrollToBottom(); + this.scrollToNewReply(); + } + + sendStarter(text) { + this.state.inputText = text; + this.sendMessage(); } onKeyDown(ev) { @@ -670,6 +1133,33 @@ export class FusionChatPanel extends Component { } } + onPaste(ev) { + const items = ev.clipboardData?.items; + if (!items) return; + for (const item of items) { + if (item.type.startsWith('image/')) { + ev.preventDefault(); + const file = item.getAsFile(); + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + const dataUrl = reader.result; + const match = dataUrl.match(/^data:(image\/[^;]+);base64,(.+)$/); + if (match) { + this.state.pendingImage = { + name: `screenshot-${Date.now()}.png`, + dataUrl, + base64: match[2], + mediaType: match[1], + }; + } + }; + reader.readAsDataURL(file); + return; + } + } + } + scrollToBottom() { const el = this.messagesRef.el; if (el) { @@ -677,6 +1167,23 @@ export class FusionChatPanel extends Component { } } + scrollToNewReply() { + // Scroll so the TOP of the latest AI message is visible + const el = this.messagesRef.el; + if (!el) return; + setTimeout(() => { + const aiMsgs = el.querySelectorAll(".fusion_ai_msg"); + if (aiMsgs.length) { + const last = aiMsgs[aiMsgs.length - 1]; + // Scroll so the message top aligns near the top of the container + const offset = last.offsetTop - el.offsetTop - 8; + el.scrollTop = offset; + } else { + el.scrollTop = el.scrollHeight; + } + }, 120); + } + async onApprove(matchHistoryId) { await rpc("/fusion_accounting/approve", { match_history_id: matchHistoryId }); this.state.pendingApprovals = this.state.pendingApprovals.filter(a => a.id !== matchHistoryId); diff --git a/fusion_accounting/static/src/components/chat/chat_panel.xml b/fusion_accounting/static/src/components/chat/chat_panel.xml index a460ba38..c9494473 100644 --- a/fusion_accounting/static/src/components/chat/chat_panel.xml +++ b/fusion_accounting/static/src/components/chat/chat_panel.xml @@ -66,10 +66,51 @@ -
- -

Ask me about your accounting data.
- I can help with bank reconciliation, tax analysis, AR/AP, auditing, and more.

+
+ +

What would you like to work on?

+
+
+ + + + + + + + + +
@@ -78,7 +119,14 @@
You + + + + + +
@@ -88,59 +136,161 @@ Fusion AI + + +
+ + + tool calls + +
+ +
+ + + + + + + + (ms) + + +
+
+
+
+
-
+
Fusion AI - Thinking... + + +
+ + +
+
+ + +
+ +
+ + + + + + + + + + + + + + + + + + (ms) + +
+
+
+
+ + + Thinking... +
- + -
-
- Pending Approvals (): +
+
+ + + Pending Approvals +
- -
- - - +
+ + + + + + + + + + + + + + +
TypeDetailsAmount
+
+ + +
+ + + +
+
+