changes
This commit is contained in:
@@ -40,9 +40,9 @@ class FusionAccountingChatController(http.Controller):
|
|||||||
return {'status': 'closed'}
|
return {'status': 'closed'}
|
||||||
|
|
||||||
@http.route('/fusion_accounting/chat', type='jsonrpc', auth='user')
|
@http.route('/fusion_accounting/chat', type='jsonrpc', auth='user')
|
||||||
def chat(self, session_id, message, context=None, **kwargs):
|
def chat(self, session_id, message, context=None, image=None, **kwargs):
|
||||||
if not message:
|
if not message and not image:
|
||||||
return {'error': 'Message is required'}
|
return {'error': 'Message or image is required'}
|
||||||
# S3: Ownership check
|
# S3: Ownership check
|
||||||
session = request.env['fusion.accounting.session'].browse(int(session_id))
|
session = request.env['fusion.accounting.session'].browse(int(session_id))
|
||||||
if session.exists():
|
if session.exists():
|
||||||
@@ -50,7 +50,7 @@ class FusionAccountingChatController(http.Controller):
|
|||||||
if error:
|
if error:
|
||||||
return error
|
return error
|
||||||
agent = request.env['fusion.accounting.agent']
|
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
|
return result
|
||||||
|
|
||||||
@http.route('/fusion_accounting/approve', type='jsonrpc', auth='user')
|
@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.'})
|
results.append({'id': mid, 'status': 'error', 'error': 'Action could not be rejected. Check server logs for details.'})
|
||||||
return {'results': results}
|
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')
|
@http.route('/fusion_accounting/session/list', type='jsonrpc', auth='user')
|
||||||
def session_list(self, limit=20, **kwargs):
|
def session_list(self, limit=20, **kwargs):
|
||||||
"""List recent sessions for the session picker dropdown."""
|
"""List recent sessions for the session picker dropdown."""
|
||||||
@@ -187,10 +211,20 @@ class FusionAccountingChatController(http.Controller):
|
|||||||
for block in msg['content']:
|
for block in msg['content']:
|
||||||
if isinstance(block, dict) and block.get('type') == 'text' and block['text'].strip():
|
if isinstance(block, dict) and block.get('type') == 'text' and block['text'].strip():
|
||||||
display_messages.append({'role': msg['role'], 'content': block['text']})
|
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 {
|
return {
|
||||||
'session_id': session.id,
|
'session_id': session.id,
|
||||||
'messages': display_messages,
|
'messages': display_messages,
|
||||||
'name': session.name,
|
'name': session.name,
|
||||||
|
'pending_approvals': pending_approvals,
|
||||||
}
|
}
|
||||||
|
|
||||||
@http.route('/fusion_accounting/session/history', type='jsonrpc', auth='user')
|
@http.route('/fusion_accounting/session/history', type='jsonrpc', auth='user')
|
||||||
|
|||||||
@@ -59,6 +59,17 @@ for rule in model.search([('active', '=', True), ('approval_tier', '=', 'needs_a
|
|||||||
<field name="active">True</field>
|
<field name="active">True</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
|
<!-- Daily auto-reconcile payroll cheques against open liability entries -->
|
||||||
|
<record id="cron_fusion_payroll_cheque_reconcile" model="ir.cron">
|
||||||
|
<field name="name">Fusion AI: Reconcile Payroll Cheques</field>
|
||||||
|
<field name="model_id" ref="model_fusion_accounting_agent"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">model._reconcile_payroll_cheques()</field>
|
||||||
|
<field name="interval_number">1</field>
|
||||||
|
<field name="interval_type">days</field>
|
||||||
|
<field name="active">True</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
<!-- Weekly vendor tax profile rebuild -->
|
<!-- Weekly vendor tax profile rebuild -->
|
||||||
<record id="cron_fusion_vendor_profiles" model="ir.cron">
|
<record id="cron_fusion_vendor_profiles" model="ir.cron">
|
||||||
<field name="name">Fusion AI: Rebuild Vendor Tax Profiles</field>
|
<field name="name">Fusion AI: Rebuild Vendor Tax Profiles</field>
|
||||||
|
|||||||
@@ -151,10 +151,10 @@
|
|||||||
<record id="tool_get_partner_balance" model="fusion.accounting.tool">
|
<record id="tool_get_partner_balance" model="fusion.accounting.tool">
|
||||||
<field name="name">get_partner_balance</field>
|
<field name="name">get_partner_balance</field>
|
||||||
<field name="display_name_field">Get Partner Balance</field>
|
<field name="display_name_field">Get Partner Balance</field>
|
||||||
<field name="description">Get a single partner's AR balance and open items.</field>
|
<field name="description">[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?".</field>
|
||||||
<field name="domain">accounts_receivable</field>
|
<field name="domain">accounts_receivable</field>
|
||||||
<field name="tier">1</field>
|
<field name="tier">1</field>
|
||||||
<field name="parameters_schema">{"type": "object", "properties": {"partner_id": {"type": "integer"}}, "required": ["partner_id"]}</field>
|
<field name="parameters_schema">{"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')"}}}</field>
|
||||||
</record>
|
</record>
|
||||||
<record id="tool_send_followup" model="fusion.accounting.tool">
|
<record id="tool_send_followup" model="fusion.accounting.tool">
|
||||||
<field name="name">send_followup</field>
|
<field name="name">send_followup</field>
|
||||||
@@ -476,6 +476,16 @@
|
|||||||
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
|
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
|
<record id="tool_register_adp_batch_payment" model="fusion.accounting.tool">
|
||||||
|
<field name="name">register_adp_batch_payment</field>
|
||||||
|
<field name="display_name_field">Register ADP Batch Payment</field>
|
||||||
|
<field name="description">[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".</field>
|
||||||
|
<field name="domain">adp</field>
|
||||||
|
<field name="tier">3</field>
|
||||||
|
<field name="parameters_schema">{"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"]}</field>
|
||||||
|
<field name="required_groups">fusion_accounting.group_fusion_accounting_manager</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
<!-- Domain 10: Reporting -->
|
<!-- Domain 10: Reporting -->
|
||||||
<record id="tool_get_profit_loss" model="fusion.accounting.tool">
|
<record id="tool_get_profit_loss" model="fusion.accounting.tool">
|
||||||
<field name="name">get_profit_loss</field>
|
<field name="name">get_profit_loss</field>
|
||||||
@@ -535,6 +545,31 @@
|
|||||||
<field name="required_groups">fusion_accounting.group_fusion_accounting_manager</field>
|
<field name="required_groups">fusion_accounting.group_fusion_accounting_manager</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
|
<record id="tool_get_invoicing_summary" model="fusion.accounting.tool">
|
||||||
|
<field name="name">get_invoicing_summary</field>
|
||||||
|
<field name="display_name_field">Get Invoicing Summary</field>
|
||||||
|
<field name="description">[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?".</field>
|
||||||
|
<field name="domain">reporting</field>
|
||||||
|
<field name="tier">1</field>
|
||||||
|
<field name="parameters_schema">{"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)"}}}</field>
|
||||||
|
</record>
|
||||||
|
<record id="tool_get_billing_summary" model="fusion.accounting.tool">
|
||||||
|
<field name="name">get_billing_summary</field>
|
||||||
|
<field name="display_name_field">Get Billing Summary</field>
|
||||||
|
<field name="description">[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".</field>
|
||||||
|
<field name="domain">reporting</field>
|
||||||
|
<field name="tier">1</field>
|
||||||
|
<field name="parameters_schema">{"type": "object", "properties": {"year": {"type": "integer"}, "partner_name": {"type": "string"}, "date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
|
||||||
|
</record>
|
||||||
|
<record id="tool_get_collections_summary" model="fusion.accounting.tool">
|
||||||
|
<field name="name">get_collections_summary</field>
|
||||||
|
<field name="display_name_field">Get Collections Summary</field>
|
||||||
|
<field name="description">[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".</field>
|
||||||
|
<field name="domain">reporting</field>
|
||||||
|
<field name="tier">1</field>
|
||||||
|
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
<!-- Domain 11: Audit -->
|
<!-- Domain 11: Audit -->
|
||||||
<record id="tool_audit_posted_entry" model="fusion.accounting.tool">
|
<record id="tool_audit_posted_entry" model="fusion.accounting.tool">
|
||||||
<field name="name">audit_posted_entry</field>
|
<field name="name">audit_posted_entry</field>
|
||||||
@@ -763,6 +798,34 @@
|
|||||||
<field name="parameters_schema">{"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"]}</field>
|
<field name="parameters_schema">{"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"]}</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
|
<record id="tool_suggest_bank_line_matches" model="fusion.accounting.tool">
|
||||||
|
<field name="name">suggest_bank_line_matches</field>
|
||||||
|
<field name="display_name_field">Suggest Bank Line Matches</field>
|
||||||
|
<field name="description">[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.</field>
|
||||||
|
<field name="domain">bank_reconciliation</field>
|
||||||
|
<field name="tier">1</field>
|
||||||
|
<field name="parameters_schema">{"type": "object", "properties": {"statement_line_id": {"type": "integer", "description": "Bank statement line ID to find matches for"}}, "required": ["statement_line_id"]}</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="tool_find_unreconciled_cheques" model="fusion.accounting.tool">
|
||||||
|
<field name="name">find_unreconciled_cheques</field>
|
||||||
|
<field name="display_name_field">Find Unreconciled Cheques</field>
|
||||||
|
<field name="description">[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).</field>
|
||||||
|
<field name="domain">bank_reconciliation</field>
|
||||||
|
<field name="tier">1</field>
|
||||||
|
<field name="parameters_schema">{"type": "object", "properties": {"journal_id": {"type": "integer", "description": "Bank journal ID (default 50 = Scotia Current)"}, "limit": {"type": "integer", "description": "Max results (default 50)"}}}</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="tool_reconcile_payroll_cheques" model="fusion.accounting.tool">
|
||||||
|
<field name="name">reconcile_payroll_cheques</field>
|
||||||
|
<field name="display_name_field">Reconcile Payroll Cheques</field>
|
||||||
|
<field name="description">[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).</field>
|
||||||
|
<field name="domain">bank_reconciliation</field>
|
||||||
|
<field name="tier">3</field>
|
||||||
|
<field name="parameters_schema">{"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."}}}</field>
|
||||||
|
<field name="required_groups">fusion_accounting.group_fusion_accounting_manager</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
<record id="tool_create_expense_entry" model="fusion.accounting.tool">
|
<record id="tool_create_expense_entry" model="fusion.accounting.tool">
|
||||||
<field name="name">create_expense_entry</field>
|
<field name="name">create_expense_entry</field>
|
||||||
<field name="display_name_field">Create Direct GL Expense</field>
|
<field name="display_name_field">Create Direct GL Expense</field>
|
||||||
|
|||||||
@@ -209,59 +209,111 @@ class FusionAccountingDashboard(models.TransientModel):
|
|||||||
def _compute_action_centre(self):
|
def _compute_action_centre(self):
|
||||||
for rec in self:
|
for rec in self:
|
||||||
attention = []
|
attention = []
|
||||||
|
today = fields.Date.today()
|
||||||
|
|
||||||
unrecon = self.env['account.bank.statement.line'].search_count([
|
# Pending AI approvals (highest priority)
|
||||||
('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 = self.env['fusion.accounting.match.history'].search_count([
|
pending = self.env['fusion.accounting.match.history'].search_count([
|
||||||
('decision', '=', 'pending'),
|
('decision', '=', 'pending'),
|
||||||
('company_id', '=', rec.company_id.id),
|
('company_id', '=', rec.company_id.id),
|
||||||
])
|
])
|
||||||
if pending > 0:
|
if pending > 0:
|
||||||
attention.append({
|
attention.append({
|
||||||
'priority': 0,
|
'priority': 0, 'severity': 'danger',
|
||||||
'title': f'{pending} AI actions awaiting approval',
|
'title': f'{pending} AI actions awaiting your approval',
|
||||||
'domain': 'audit',
|
'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([
|
drafts = self.env['account.move'].search_count([
|
||||||
('state', '=', 'draft'),
|
('state', '=', 'draft'),
|
||||||
('date', '<=', fields.Date.today() - timedelta(days=30)),
|
('date', '<=', today - timedelta(days=30)),
|
||||||
('company_id', '=', rec.company_id.id),
|
('company_id', '=', rec.company_id.id),
|
||||||
])
|
])
|
||||||
if drafts > 0:
|
if drafts > 0:
|
||||||
attention.append({
|
attention.append({
|
||||||
'priority': 3,
|
'priority': 4, 'severity': 'muted',
|
||||||
'title': f'{drafts} stale draft entries (30+ days)',
|
'title': f'{drafts} stale draft entries (30+ days)',
|
||||||
'domain': 'journal_review',
|
'domain': 'journal_review',
|
||||||
'action': 'Post or delete stale draft entries',
|
'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'])
|
attention.sort(key=lambda x: x['priority'])
|
||||||
rec.needs_attention_json = json.dumps(attention)
|
rec.needs_attention_json = json.dumps(attention)
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,83 @@
|
|||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from odoo import models, fields, api
|
from odoo import models, fields, api
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_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):
|
class FusionAccountingMatchHistory(models.Model):
|
||||||
_name = 'fusion.accounting.match.history'
|
_name = 'fusion.accounting.match.history'
|
||||||
_description = 'Fusion Accounting Match History'
|
_description = 'Fusion Accounting Match History'
|
||||||
_order = 'proposed_at desc'
|
_order = 'proposed_at desc'
|
||||||
|
_rec_name = 'display_label'
|
||||||
|
|
||||||
|
display_label = fields.Char(
|
||||||
|
string='Label', compute='_compute_display_label', store=True,
|
||||||
|
)
|
||||||
session_id = fields.Many2one(
|
session_id = fields.Many2one(
|
||||||
'fusion.accounting.session', string='Session',
|
'fusion.accounting.session', string='Session',
|
||||||
index=True, ondelete='cascade',
|
index=True, ondelete='cascade',
|
||||||
)
|
)
|
||||||
tool_name = fields.Char(string='Tool Name', required=True, index=True)
|
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_params = fields.Text(string='Tool Parameters (JSON)')
|
||||||
tool_result = fields.Text(string='Tool Result (JSON)')
|
tool_result = fields.Text(string='Tool Result (JSON)')
|
||||||
ai_reasoning = fields.Text(string='AI Reasoning')
|
ai_reasoning = fields.Text(string='AI Reasoning')
|
||||||
@@ -60,6 +124,30 @@ class FusionAccountingMatchHistory(models.Model):
|
|||||||
default=lambda self: self.env.company,
|
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):
|
def action_approve(self):
|
||||||
self.write({
|
self.write({
|
||||||
'decision': 'approved',
|
'decision': 'approved',
|
||||||
|
|||||||
@@ -50,9 +50,9 @@ class FusionAccountingAdapterClaude(models.AbstractModel):
|
|||||||
def _supports_extended_thinking(self, model):
|
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
|
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()
|
client = self._get_client()
|
||||||
model = self._get_model_name()
|
model = model_override or self._get_model_name()
|
||||||
|
|
||||||
api_messages = []
|
api_messages = []
|
||||||
for msg in messages:
|
for msg in messages:
|
||||||
|
|||||||
@@ -52,9 +52,9 @@ class FusionAccountingAdapterOpenAI(models.AbstractModel):
|
|||||||
def _is_reasoning_model(self, model):
|
def _is_reasoning_model(self, model):
|
||||||
return model.startswith('o1') or model.startswith('o3') or model.startswith('o4')
|
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()
|
client = self._get_client()
|
||||||
model = self._get_model_name()
|
model = model_override or self._get_model_name()
|
||||||
is_reasoning = self._is_reasoning_model(model)
|
is_reasoning = self._is_reasoning_model(model)
|
||||||
|
|
||||||
if is_reasoning:
|
if is_reasoning:
|
||||||
|
|||||||
@@ -8,6 +8,17 @@ from odoo.exceptions import UserError
|
|||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_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)
|
# Inter-account transfer pairs: (source_journal, cc_journal, cc_account_pattern)
|
||||||
# Source sends "MB-CREDIT CARD" (outgoing), CC receives "PAYMENT FROM" (incoming)
|
# Source sends "MB-CREDIT CARD" (outgoing), CC receives "PAYMENT FROM" (incoming)
|
||||||
TRANSFER_PAIRS = [
|
TRANSFER_PAIRS = [
|
||||||
@@ -25,12 +36,75 @@ class FusionAccountingAgent(models.AbstractModel):
|
|||||||
ICP = self.env['ir.config_parameter'].sudo()
|
ICP = self.env['ir.config_parameter'].sudo()
|
||||||
return ICP.get_param(f'fusion_accounting.{key}', default)
|
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):
|
def _get_adapter(self):
|
||||||
provider = self._get_config('ai_provider', 'claude')
|
provider = self._get_config('ai_provider', 'claude')
|
||||||
if provider == 'claude':
|
if provider == 'claude':
|
||||||
return self.env['fusion.accounting.adapter.claude']
|
return self.env['fusion.accounting.adapter.claude']
|
||||||
return self.env['fusion.accounting.adapter.openai']
|
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):
|
def _get_tool_registry(self):
|
||||||
return self.env['fusion.accounting.tool'].search([('active', '=', True)])
|
return self.env['fusion.accounting.tool'].search([('active', '=', True)])
|
||||||
|
|
||||||
@@ -114,8 +188,8 @@ class FusionAccountingAgent(models.AbstractModel):
|
|||||||
vals = {
|
vals = {
|
||||||
'session_id': session.id if session else False,
|
'session_id': session.id if session else False,
|
||||||
'tool_name': tool_name,
|
'tool_name': tool_name,
|
||||||
'tool_params': json.dumps(params) if params else '{}',
|
'tool_params': json.dumps(params, indent=2, default=str) if params else '{}',
|
||||||
'tool_result': json.dumps(result) if result else '{}',
|
'tool_result': json.dumps(result, indent=2, default=str) if result else '{}',
|
||||||
'ai_reasoning': reasoning,
|
'ai_reasoning': reasoning,
|
||||||
'ai_confidence': confidence,
|
'ai_confidence': confidence,
|
||||||
'rule_id': rule.id if rule else False,
|
'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)
|
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)
|
session = self.env['fusion.accounting.session'].browse(session_id)
|
||||||
if not session.exists():
|
if not session.exists():
|
||||||
raise UserError(_("Session not found."))
|
raise UserError(_("Session not found."))
|
||||||
@@ -155,36 +229,106 @@ class FusionAccountingAgent(models.AbstractModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
messages_json = json.loads(session.message_ids_json or '[]')
|
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)
|
max_turns = max(int(self._get_config('max_tool_calls', '20')), 1)
|
||||||
total_tokens_in = 0
|
total_tokens_in = 0
|
||||||
total_tokens_out = 0
|
total_tokens_out = 0
|
||||||
response = {'text': '', 'tool_calls': None}
|
response = {'text': '', 'tool_calls': None}
|
||||||
has_pending_tier3 = False
|
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):
|
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(
|
response = adapter.call_with_tools(
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
messages=messages_json,
|
messages=messages_json,
|
||||||
tools=tool_definitions,
|
tools=tool_definitions,
|
||||||
|
model_override=model_override,
|
||||||
)
|
)
|
||||||
total_tokens_in += response.get('tokens_in', 0)
|
total_tokens_in += response.get('tokens_in', 0)
|
||||||
total_tokens_out += response.get('tokens_out', 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'):
|
if response.get('tool_calls'):
|
||||||
tool_results = []
|
tool_results = []
|
||||||
|
_execution_state[session.id]['status'] = 'calling_tools'
|
||||||
|
|
||||||
for tc in response['tool_calls']:
|
for tc in response['tool_calls']:
|
||||||
tool_name = tc['name']
|
tool_name = tc['name']
|
||||||
tool_params = tc.get('arguments', {})
|
tool_params = tc.get('arguments', {})
|
||||||
tool_rec = tools.filtered(lambda t: t.name == tool_name)
|
tool_rec = tools.filtered(lambda t: t.name == tool_name)
|
||||||
tier = tool_rec.tier if tool_rec else '1'
|
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':
|
if tier == '3':
|
||||||
has_pending_tier3 = True
|
has_pending_tier3 = True
|
||||||
history_rec = self._log_match_history(
|
history_rec = self._log_match_history(
|
||||||
session, tool_name, tool_params, None,
|
session, tool_name, tool_params, None,
|
||||||
reasoning=tc.get('reasoning', ''),
|
reasoning=thinking or '',
|
||||||
confidence=tc.get('confidence', 0.0),
|
confidence=tc.get('confidence', 0.0),
|
||||||
tier='3',
|
tier='3',
|
||||||
)
|
)
|
||||||
@@ -196,17 +340,43 @@ class FusionAccountingAgent(models.AbstractModel):
|
|||||||
'message': f'Action requires user approval. Match history ID: {history_rec.id}',
|
'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:
|
else:
|
||||||
|
t0 = time.time()
|
||||||
result = self._execute_tool(tool_name, tool_params, session.id)
|
result = self._execute_tool(tool_name, tool_params, session.id)
|
||||||
|
elapsed = round((time.time() - t0) * 1000)
|
||||||
self._log_match_history(
|
self._log_match_history(
|
||||||
session, tool_name, tool_params, result,
|
session, tool_name, tool_params, result,
|
||||||
reasoning=tc.get('reasoning', ''),
|
reasoning=thinking or '',
|
||||||
tier=tier,
|
tier=tier,
|
||||||
)
|
)
|
||||||
tool_results.append({
|
tool_results.append({
|
||||||
'tool_call_id': tc.get('id', ''),
|
'tool_call_id': tc.get('id', ''),
|
||||||
'result': json.dumps(result) if not isinstance(result, str) else result,
|
'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:
|
try:
|
||||||
self._check_rule_proposal(tool_name, tool_params, session)
|
self._check_rule_proposal(tool_name, tool_params, session)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -225,6 +395,7 @@ class FusionAccountingAgent(models.AbstractModel):
|
|||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
messages=messages_json,
|
messages=messages_json,
|
||||||
tools=[],
|
tools=[],
|
||||||
|
model_override=model_override,
|
||||||
)
|
)
|
||||||
total_tokens_in += response.get('tokens_in', 0)
|
total_tokens_in += response.get('tokens_in', 0)
|
||||||
total_tokens_out += response.get('tokens_out', 0)
|
total_tokens_out += response.get('tokens_out', 0)
|
||||||
@@ -249,6 +420,7 @@ class FusionAccountingAgent(models.AbstractModel):
|
|||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
messages=messages_json,
|
messages=messages_json,
|
||||||
tools=[],
|
tools=[],
|
||||||
|
model_override=model_override,
|
||||||
)
|
)
|
||||||
total_tokens_in += response.get('tokens_in', 0)
|
total_tokens_in += response.get('tokens_in', 0)
|
||||||
total_tokens_out += response.get('tokens_out', 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_in': session.token_count_in + total_tokens_in,
|
||||||
'token_count_out': session.token_count_out + total_tokens_out,
|
'token_count_out': session.token_count_out + total_tokens_out,
|
||||||
'ai_provider': provider,
|
'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([
|
pending = self.env['fusion.accounting.match.history'].search([
|
||||||
@@ -272,19 +444,190 @@ class FusionAccountingAgent(models.AbstractModel):
|
|||||||
('decision', '=', 'pending'),
|
('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', ''),
|
'text': response.get('text', ''),
|
||||||
'pending_approvals': [{
|
'tool_calls_log': tool_calls_log,
|
||||||
'id': p.id,
|
'pending_approvals': [self._format_pending_approval(p) for p in pending],
|
||||||
'tool_name': p.tool_name,
|
|
||||||
'params': p.tool_params,
|
|
||||||
'reasoning': p.ai_reasoning,
|
|
||||||
'confidence': p.ai_confidence,
|
|
||||||
'amount': p.amount,
|
|
||||||
} for p in pending],
|
|
||||||
'session_id': session.id,
|
'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):
|
def approve_action(self, match_history_id):
|
||||||
history = self.env['fusion.accounting.match.history'].browse(match_history_id)
|
history = self.env['fusion.accounting.match.history'].browse(match_history_id)
|
||||||
if not history.exists() or history.decision != 'pending':
|
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)
|
_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),
|
||||||
|
)
|
||||||
|
|||||||
@@ -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.
|
- Fee differences (e.g., Elavon card processing fees) should be allocated to the fee account.
|
||||||
- Weekend batches may combine multiple days of card payments.
|
- Weekend batches may combine multiple days of card payments.
|
||||||
- Always verify amounts before proposing a match.
|
- 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": <copy bank_line from tool result>,
|
||||||
|
"candidates": <copy candidates array from tool result>,
|
||||||
|
"best_combination": <copy best_combination from tool result>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
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': """
|
'hst_management': """
|
||||||
@@ -119,10 +147,31 @@ INVENTORY & COGS CONTEXT:
|
|||||||
""",
|
""",
|
||||||
|
|
||||||
'adp': """
|
'adp': """
|
||||||
ADP RECONCILIATION CONTEXT:
|
ADP (ASSISTIVE DEVICE PROGRAM) RECONCILIATION CONTEXT:
|
||||||
- ADP Receivable tracked on account 1101.
|
- ADP Receivable tracked on account 1101.
|
||||||
- ADP invoices have customer portion + ADP portion = total.
|
- 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': """
|
'reporting': """
|
||||||
|
|||||||
@@ -89,6 +89,48 @@ LINKING TO ODOO RECORDS:
|
|||||||
- Bank statement lines: mention the date, reference, and amount clearly.
|
- Bank statement lines: mention the date, reference, and amount clearly.
|
||||||
- When tool results include record IDs, always link them.
|
- 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:
|
TOOL CALLING:
|
||||||
- Call tools by name with the required parameters.
|
- Call tools by name with the required parameters.
|
||||||
- You may call multiple tools in sequence to gather data before proposing an action.
|
- You may call multiple tools in sequence to gather data before proposing an action.
|
||||||
|
|||||||
@@ -65,27 +65,56 @@ def get_overdue_invoices(env, params):
|
|||||||
|
|
||||||
|
|
||||||
def get_partner_balance(env, params):
|
def get_partner_balance(env, params):
|
||||||
partner_id = int(params['partner_id'])
|
"""Get AR and AP balance for a partner. Accepts partner_id or partner_name."""
|
||||||
partner = env['res.partner'].browse(partner_id)
|
partner = None
|
||||||
if not partner.exists():
|
if params.get('partner_id'):
|
||||||
return {'error': 'Partner not found'}
|
partner = env['res.partner'].browse(int(params['partner_id']))
|
||||||
amls = env['account.move.line'].search([
|
elif params.get('partner_name'):
|
||||||
('partner_id', '=', partner_id),
|
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'),
|
('account_id.account_type', '=', 'asset_receivable'),
|
||||||
('parent_state', '=', 'posted'),
|
('parent_state', '=', 'posted'),
|
||||||
('reconciled', '=', False),
|
('reconciled', '=', False),
|
||||||
('company_id', '=', env.company.id),
|
('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 {
|
return {
|
||||||
'partner': partner.name,
|
'partner': partner.name,
|
||||||
'balance': sum(aml.amount_residual for aml in amls),
|
'partner_id': partner.id,
|
||||||
'open_items': [{
|
'ar_balance': ar_balance,
|
||||||
'id': aml.id,
|
'ap_balance': ap_balance,
|
||||||
'ref': aml.ref or aml.move_id.name,
|
'net_balance': ar_balance + ap_balance,
|
||||||
'date': str(aml.date),
|
'they_owe_us': ar_balance if ar_balance > 0 else 0,
|
||||||
'amount_residual': aml.amount_residual,
|
'we_owe_them': abs(ap_balance) if ap_balance < 0 else 0,
|
||||||
'date_maturity': str(aml.date_maturity) if aml.date_maturity else '',
|
'open_items': open_items,
|
||||||
} for aml in amls],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ _logger = logging.getLogger(__name__)
|
|||||||
def get_adp_receivable_aging(env, params):
|
def get_adp_receivable_aging(env, params):
|
||||||
accounts = env['account.account'].search([
|
accounts = env['account.account'].search([
|
||||||
('code', '=like', '1101%'),
|
('code', '=like', '1101%'),
|
||||||
('company_id', '=', env.company.id),
|
('company_ids', 'in', env.company.id),
|
||||||
])
|
])
|
||||||
today = fields.Date.today()
|
today = fields.Date.today()
|
||||||
amls = env['account.move.line'].search([
|
amls = env['account.move.line'].search([
|
||||||
@@ -81,7 +81,7 @@ def get_adp_summary(env, params):
|
|||||||
date_from = params.get('date_from')
|
date_from = params.get('date_from')
|
||||||
date_to = params.get('date_to')
|
date_to = params.get('date_to')
|
||||||
accounts = env['account.account'].search([
|
accounts = env['account.account'].search([
|
||||||
('code', '=like', '1101%'), ('company_id', '=', env.company.id),
|
('code', '=like', '1101%'), ('company_ids', 'in', env.company.id),
|
||||||
])
|
])
|
||||||
domain = [
|
domain = [
|
||||||
('account_id', 'in', accounts.ids),
|
('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 = {
|
TOOLS = {
|
||||||
'get_adp_receivable_aging': get_adp_receivable_aging,
|
'get_adp_receivable_aging': get_adp_receivable_aging,
|
||||||
'match_adp_payment_to_invoice': match_adp_payment_to_invoice,
|
'match_adp_payment_to_invoice': match_adp_payment_to_invoice,
|
||||||
'verify_adp_split': verify_adp_split,
|
'verify_adp_split': verify_adp_split,
|
||||||
'find_adp_without_payment': find_adp_without_payment,
|
'find_adp_without_payment': find_adp_without_payment,
|
||||||
'get_adp_summary': get_adp_summary,
|
'get_adp_summary': get_adp_summary,
|
||||||
|
'register_adp_batch_payment': register_adp_batch_payment,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ def get_unreconciled_receipts(env, params):
|
|||||||
account_code = params.get('account_code', '1122')
|
account_code = params.get('account_code', '1122')
|
||||||
accounts = env['account.account'].search([
|
accounts = env['account.account'].search([
|
||||||
('code', '=like', f'{account_code}%'),
|
('code', '=like', f'{account_code}%'),
|
||||||
('company_id', '=', env.company.id),
|
('company_ids', 'in', env.company.id),
|
||||||
])
|
])
|
||||||
domain = [
|
domain = [
|
||||||
('account_id', 'in', accounts.ids),
|
('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 = {
|
TOOLS = {
|
||||||
'get_unreconciled_bank_lines': get_unreconciled_bank_lines,
|
'get_unreconciled_bank_lines': get_unreconciled_bank_lines,
|
||||||
'get_unreconciled_receipts': get_unreconciled_receipts,
|
'get_unreconciled_receipts': get_unreconciled_receipts,
|
||||||
@@ -496,4 +954,8 @@ TOOLS = {
|
|||||||
'get_bank_line_details': get_bank_line_details,
|
'get_bank_line_details': get_bank_line_details,
|
||||||
'check_recurring_pattern': check_recurring_pattern,
|
'check_recurring_pattern': check_recurring_pattern,
|
||||||
'match_internal_transfers': match_internal_transfers,
|
'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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ def calculate_hst_balance(env, params):
|
|||||||
# (shared chart of accounts). Use try/except to handle both cases.
|
# (shared chart of accounts). Use try/except to handle both cases.
|
||||||
try:
|
try:
|
||||||
collected_accounts = env['account.account'].search([
|
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([
|
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:
|
except Exception:
|
||||||
collected_accounts = env['account.account'].search([
|
collected_accounts = env['account.account'].search([
|
||||||
@@ -216,7 +216,7 @@ def create_expense_entry(env, params):
|
|||||||
# Fallback to AP account
|
# Fallback to AP account
|
||||||
credit_account = env['account.account'].search([
|
credit_account = env['account.account'].search([
|
||||||
('account_type', '=', 'liability_payable'),
|
('account_type', '=', 'liability_payable'),
|
||||||
('company_id', '=', env.company.id),
|
('company_ids', 'in', env.company.id),
|
||||||
], limit=1)
|
], limit=1)
|
||||||
|
|
||||||
if not credit_account:
|
if not credit_account:
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ _logger = logging.getLogger(__name__)
|
|||||||
def get_stock_valuation(env, params):
|
def get_stock_valuation(env, params):
|
||||||
accounts = env['account.account'].search([
|
accounts = env['account.account'].search([
|
||||||
('code', '=like', '1069%'),
|
('code', '=like', '1069%'),
|
||||||
('company_id', '=', env.company.id),
|
('company_ids', 'in', env.company.id),
|
||||||
])
|
])
|
||||||
result = []
|
result = []
|
||||||
for acct in accounts:
|
for acct in accounts:
|
||||||
@@ -22,7 +22,7 @@ def get_stock_valuation(env, params):
|
|||||||
def get_price_differences(env, params):
|
def get_price_differences(env, params):
|
||||||
accounts = env['account.account'].search([
|
accounts = env['account.account'].search([
|
||||||
('code', '=like', '5010%'),
|
('code', '=like', '5010%'),
|
||||||
('company_id', '=', env.company.id),
|
('company_ids', 'in', env.company.id),
|
||||||
])
|
])
|
||||||
domain = [
|
domain = [
|
||||||
('account_id', 'in', accounts.ids),
|
('account_id', 'in', accounts.ids),
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ def find_wrong_account_entries(env, params):
|
|||||||
tax_accounts = env['account.account'].search([
|
tax_accounts = env['account.account'].search([
|
||||||
('account_type', 'in', ('liability_current', 'asset_current')),
|
('account_type', 'in', ('liability_current', 'asset_current')),
|
||||||
('code', '=like', '2005%'),
|
('code', '=like', '2005%'),
|
||||||
('company_id', '=', env.company.id),
|
('company_ids', 'in', env.company.id),
|
||||||
])
|
])
|
||||||
if tax_accounts:
|
if tax_accounts:
|
||||||
revenue_on_tax = env['account.move.line'].search(
|
revenue_on_tax = env['account.move.line'].search(
|
||||||
@@ -171,7 +171,7 @@ def find_draft_entries(env, params):
|
|||||||
def find_unreconciled_suspense(env, params):
|
def find_unreconciled_suspense(env, params):
|
||||||
suspense_accounts = env['account.account'].search([
|
suspense_accounts = env['account.account'].search([
|
||||||
('code', '=like', '999%'),
|
('code', '=like', '999%'),
|
||||||
('company_id', '=', env.company.id),
|
('company_ids', 'in', env.company.id),
|
||||||
])
|
])
|
||||||
issues = []
|
issues = []
|
||||||
for acct in suspense_accounts:
|
for acct in suspense_accounts:
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ def get_close_checklist(env, params):
|
|||||||
def get_unreconciled_counts(env, params):
|
def get_unreconciled_counts(env, params):
|
||||||
accounts = env['account.account'].search([
|
accounts = env['account.account'].search([
|
||||||
('reconcile', '=', True),
|
('reconcile', '=', True),
|
||||||
('company_id', '=', env.company.id),
|
('company_ids', 'in', env.company.id),
|
||||||
])
|
])
|
||||||
result = []
|
result = []
|
||||||
for acct in accounts:
|
for acct in accounts:
|
||||||
@@ -77,7 +77,7 @@ def get_accrual_status(env, params):
|
|||||||
for code in accrual_codes:
|
for code in accrual_codes:
|
||||||
accounts = env['account.account'].search([
|
accounts = env['account.account'].search([
|
||||||
('code', '=like', f'{code}%'),
|
('code', '=like', f'{code}%'),
|
||||||
('company_id', '=', env.company.id),
|
('company_ids', 'in', env.company.id),
|
||||||
])
|
])
|
||||||
for acct in accounts:
|
for acct in accounts:
|
||||||
balance = sum(env['account.move.line'].search([
|
balance = sum(env['account.move.line'].search([
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ def verify_source_deductions(env, params):
|
|||||||
def get_cra_remittance_status(env, params):
|
def get_cra_remittance_status(env, params):
|
||||||
cra_accounts = env['account.account'].search([
|
cra_accounts = env['account.account'].search([
|
||||||
('name', 'ilike', 'CRA'),
|
('name', 'ilike', 'CRA'),
|
||||||
('company_id', '=', env.company.id),
|
('company_ids', 'in', env.company.id),
|
||||||
])
|
])
|
||||||
result = []
|
result = []
|
||||||
for acct in cra_accounts:
|
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):
|
def create_payroll_journal_entry(env, params):
|
||||||
journal_id = int(params['journal_id'])
|
journal_id = int(params['journal_id'])
|
||||||
date = params['date']
|
date = params['date']
|
||||||
|
ref = params.get('ref', 'Payroll Entry')
|
||||||
lines_data = params['lines']
|
lines_data = params['lines']
|
||||||
move_vals = {
|
|
||||||
'journal_id': journal_id,
|
# Duplicate check: same journal + date + ref + similar amount
|
||||||
'date': date,
|
total_debit = sum(float(l.get('debit', 0)) for l in lines_data)
|
||||||
'ref': params.get('ref', 'Payroll Entry'),
|
existing = env['account.move'].search([
|
||||||
'line_ids': [(0, 0, {
|
('journal_id', '=', journal_id),
|
||||||
'account_id': int(line['account_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'),
|
'name': line.get('name', 'Payroll'),
|
||||||
'debit': float(line.get('debit', 0)),
|
'debit': float(line.get('debit', 0)),
|
||||||
'credit': float(line.get('credit', 0)),
|
'credit': float(line.get('credit', 0)),
|
||||||
'partner_id': int(line['partner_id']) if line.get('partner_id') else False,
|
'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)
|
move = env['account.move'].create(move_vals)
|
||||||
return {'status': 'created', 'move_id': move.id, 'name': move.name}
|
return {'status': 'created', 'move_id': move.id, 'name': move.name}
|
||||||
|
|||||||
@@ -106,6 +106,171 @@ def export_report(env, params):
|
|||||||
return {'error': f'Export failed: {str(e)}'}
|
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 = {
|
TOOLS = {
|
||||||
'get_profit_loss': get_profit_loss,
|
'get_profit_loss': get_profit_loss,
|
||||||
'get_balance_sheet': get_balance_sheet,
|
'get_balance_sheet': get_balance_sheet,
|
||||||
@@ -114,4 +279,7 @@ TOOLS = {
|
|||||||
'compare_periods': compare_periods,
|
'compare_periods': compare_periods,
|
||||||
'answer_financial_question': answer_financial_question,
|
'answer_financial_question': answer_financial_question,
|
||||||
'export_report': export_report,
|
'export_report': export_report,
|
||||||
|
'get_invoicing_summary': get_invoicing_summary,
|
||||||
|
'get_billing_summary': get_billing_summary,
|
||||||
|
'get_collections_summary': get_collections_summary,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,25 @@ export class FusionApprovalCard extends Component {
|
|||||||
static template = "fusion_accounting.ApprovalCard";
|
static template = "fusion_accounting.ApprovalCard";
|
||||||
static props = ["approval", "onApprove", "onReject"];
|
static props = ["approval", "onApprove", "onReject"];
|
||||||
|
|
||||||
get confidencePercent() {
|
get toolLabel() {
|
||||||
return Math.round((this.props.approval.confidence || 0) * 100);
|
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() {
|
approve() {
|
||||||
|
|||||||
@@ -1,29 +1,29 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<templates xml:space="preserve">
|
<templates xml:space="preserve">
|
||||||
<t t-name="fusion_accounting.ApprovalCard">
|
<t t-name="fusion_accounting.ApprovalCard">
|
||||||
<div class="fusion_approval_card card border-warning mb-2">
|
<!-- Single row in the approval table — rendered inside <tbody> by chat_panel -->
|
||||||
<div class="card-body p-2">
|
<tr class="fusion_approval_row">
|
||||||
<div class="d-flex justify-content-between align-items-start mb-1">
|
<td class="px-2 py-1 small text-nowrap" t-esc="toolLabel"/>
|
||||||
<strong t-esc="props.approval.tool_name"/>
|
<td class="px-2 py-1 small" style="white-space: pre-line; max-width: 320px;">
|
||||||
<span class="badge bg-warning text-dark">
|
<t t-esc="props.approval.summary || ''"/>
|
||||||
<t t-esc="confidencePercent"/>% conf
|
</td>
|
||||||
</span>
|
<td class="px-2 py-1 small text-end text-nowrap fw-semibold">
|
||||||
</div>
|
|
||||||
<p class="small mb-1 text-muted" t-esc="props.approval.reasoning"/>
|
|
||||||
<t t-if="props.approval.amount">
|
<t t-if="props.approval.amount">
|
||||||
<p class="small mb-1">
|
$<t t-esc="formatAmount(props.approval.amount)"/>
|
||||||
Amount: <strong>$<t t-esc="(props.approval.amount || 0).toFixed(2)"/></strong>
|
|
||||||
</p>
|
|
||||||
</t>
|
</t>
|
||||||
<div class="d-flex gap-2">
|
</td>
|
||||||
<button class="btn btn-success btn-sm flex-grow-1" t-on-click="approve">
|
<td class="px-1 py-1 text-end text-nowrap">
|
||||||
<i class="fa fa-check"/> Approve
|
<button class="btn btn-success btn-xs px-2 py-0 me-1" t-on-click="approve"
|
||||||
</button>
|
style="font-size: 0.75rem; line-height: 1.5;"
|
||||||
<button class="btn btn-outline-danger btn-sm flex-grow-1" t-on-click="reject">
|
title="Approve">
|
||||||
<i class="fa fa-times"/> Reject
|
<i class="fa fa-check"/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<button class="btn btn-outline-danger btn-xs px-2 py-0" t-on-click="reject"
|
||||||
</div>
|
style="font-size: 0.75rem; line-height: 1.5;"
|
||||||
</div>
|
title="Reject">
|
||||||
|
<i class="fa fa-times"/>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</t>
|
</t>
|
||||||
</templates>
|
</templates>
|
||||||
|
|||||||
@@ -192,6 +192,7 @@ export class FusionChatPanel extends Component {
|
|||||||
setup() {
|
setup() {
|
||||||
this.inputRef = useRef("chatInput");
|
this.inputRef = useRef("chatInput");
|
||||||
this.messagesRef = useRef("messages");
|
this.messagesRef = useRef("messages");
|
||||||
|
this.fileRef = useRef("fileInput");
|
||||||
// Track parsed table data per message index for interactive tables
|
// Track parsed table data per message index for interactive tables
|
||||||
this._parsedTables = {};
|
this._parsedTables = {};
|
||||||
this.state = useState({
|
this.state = useState({
|
||||||
@@ -207,7 +208,13 @@ export class FusionChatPanel extends Component {
|
|||||||
// Session history picker
|
// Session history picker
|
||||||
showSessionPicker: false,
|
showSessionPicker: false,
|
||||||
sessionList: [],
|
sessionList: [],
|
||||||
|
// Image attachment
|
||||||
|
pendingImage: null, // { name, dataUrl, base64, mediaType }
|
||||||
|
// Live execution status (from polling)
|
||||||
|
liveThinking: '',
|
||||||
|
liveToolCalls: [],
|
||||||
});
|
});
|
||||||
|
this._statusPollInterval = null;
|
||||||
|
|
||||||
onWillStart(async () => {
|
onWillStart(async () => {
|
||||||
await this.loadLatestSession();
|
await this.loadLatestSession();
|
||||||
@@ -229,33 +236,51 @@ export class FusionChatPanel extends Component {
|
|||||||
for (const div of richDivs) {
|
for (const div of richDivs) {
|
||||||
const idx = parseInt(div.dataset.idx);
|
const idx = parseInt(div.dataset.idx);
|
||||||
const msg = this.state.messages[idx];
|
const msg = this.state.messages[idx];
|
||||||
if (msg && msg.role === "assistant" && msg.content) {
|
if (msg && msg.role === "assistant") {
|
||||||
// Check for fusion-table blocks
|
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 += `<div class="fusion_table_mount" data-table-key="${reconKey}"></div>`;
|
||||||
|
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);
|
const parsed = parseFusionTableBlock(msg.content);
|
||||||
if (parsed) {
|
if (parsed) {
|
||||||
// Build HTML with placeholders for interactive tables
|
|
||||||
let html = "";
|
|
||||||
for (const part of parsed.parts) {
|
for (const part of parsed.parts) {
|
||||||
if (part.type === "md") {
|
if (part.type === "md") {
|
||||||
html += mdToHtml(part.content);
|
html += mdToHtml(part.content);
|
||||||
} else if (part.type === "table") {
|
} else if (part.type === "table") {
|
||||||
const tableKey = `${idx}_${part.idx}`;
|
const tableKey = `${idx}_${part.idx}`;
|
||||||
html += `<div class="fusion_table_mount" data-table-key="${tableKey}"></div>`;
|
html += `<div class="fusion_table_mount" data-table-key="${tableKey}"></div>`;
|
||||||
// Store table data for OWL mounting
|
|
||||||
this._parsedTables[tableKey] = parsed.tables[part.idx];
|
this._parsedTables[tableKey] = parsed.tables[part.idx];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (div.innerHTML !== html) {
|
if (div.innerHTML !== html) {
|
||||||
div.innerHTML = html;
|
div.innerHTML = html;
|
||||||
}
|
}
|
||||||
// Mount OWL interactive table components into placeholders
|
|
||||||
this._mountInteractiveTables(div);
|
this._mountInteractiveTables(div);
|
||||||
} else {
|
} else {
|
||||||
const html = mdToHtml(msg.content);
|
const mdHtml = mdToHtml(msg.content);
|
||||||
if (div.innerHTML !== html) {
|
if (div.innerHTML !== mdHtml) {
|
||||||
div.innerHTML = html;
|
div.innerHTML = mdHtml;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -269,8 +294,13 @@ export class FusionChatPanel extends Component {
|
|||||||
if (!tableData) continue;
|
if (!tableData) continue;
|
||||||
|
|
||||||
el.dataset.mounted = "true";
|
el.dataset.mounted = "true";
|
||||||
el.innerHTML = this._buildInteractiveTableHtml(tableData, key);
|
if (tableData.mode === "reconciliation") {
|
||||||
this._wireTableEvents(el, tableData, key);
|
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;
|
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 = `<div class="fusion_recon_table my-2" data-key="${key}" data-bank-line-id="${bankLine.id || ''}" data-bank-amount="${bankAmount}">`;
|
||||||
|
|
||||||
|
// Header
|
||||||
|
h += `<div class="d-flex align-items-center justify-content-between mb-2">`;
|
||||||
|
h += `<div><i class="fa fa-exchange me-2 text-primary"></i><strong>${this._esc(title)}</strong>`;
|
||||||
|
h += `<span class="badge bg-secondary-subtle text-secondary ms-2">${bankLine.direction || ''}</span></div>`;
|
||||||
|
h += `<span class="badge bg-primary">${bankLine.journal || ''}</span></div>`;
|
||||||
|
|
||||||
|
// Table
|
||||||
|
h += '<div class="table-responsive"><table class="table table-sm table-hover align-middle mb-0"><thead><tr>';
|
||||||
|
h += '<th class="fit-content px-2"><input type="checkbox" class="form-check-input" data-action="recon-select-all"/></th>';
|
||||||
|
h += '<th class="px-2 py-1">Entry</th>';
|
||||||
|
h += '<th class="px-2 py-1">Type</th>';
|
||||||
|
h += '<th class="px-2 py-1">Partner</th>';
|
||||||
|
h += '<th class="px-2 py-1">Date</th>';
|
||||||
|
h += '<th class="px-2 py-1 text-end">Residual</th>';
|
||||||
|
h += '<th class="px-2 py-1 text-end" style="min-width:110px;">Apply</th>';
|
||||||
|
h += '<th class="px-2 py-1">Score</th>';
|
||||||
|
h += '</tr></thead><tbody>';
|
||||||
|
|
||||||
|
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 += `<tr data-row-idx="${i}" data-aml-id="${c.aml_id}">`;
|
||||||
|
h += `<td class="fit-content px-2"><input type="checkbox" class="form-check-input recon-row-check" data-idx="${i}" ${checked}/></td>`;
|
||||||
|
h += `<td class="px-2 py-1 small"><strong>${this._esc(c.name)}</strong>`;
|
||||||
|
if (c.ref) h += `<br/><span class="text-muted">${this._esc(c.ref)}</span>`;
|
||||||
|
h += `</td>`;
|
||||||
|
const typeClass = c.type === 'payment' ? 'bg-success-subtle text-success' : c.type === 'bill' ? 'bg-info-subtle text-info' : 'bg-primary-subtle text-primary';
|
||||||
|
h += `<td class="px-2 py-1 small"><span class="badge ${typeClass}">${this._esc(c.type || 'entry')}</span></td>`;
|
||||||
|
h += `<td class="px-2 py-1 small">${this._esc(c.partner)}</td>`;
|
||||||
|
h += `<td class="px-2 py-1 small text-nowrap">${this._esc(c.date)}</td>`;
|
||||||
|
h += `<td class="px-2 py-1 small text-end text-nowrap">$${residual.toFixed(2)}</td>`;
|
||||||
|
h += `<td class="px-2 py-1 text-end">`;
|
||||||
|
h += `<input type="number" class="form-control form-control-sm fusion_apply_amount text-end" `;
|
||||||
|
h += `data-idx="${i}" data-max="${residual}" step="0.01" min="0" max="${residual}" `;
|
||||||
|
h += `value="${residual.toFixed(2)}" style="width:110px; display:inline-block;"/>`;
|
||||||
|
h += `</td>`;
|
||||||
|
h += `<td class="px-2 py-1 small"><span class="${scoreClass}">${score}</span>`;
|
||||||
|
if (c.reasons) h += ` <span class="text-muted">— ${this._esc(c.reasons)}</span>`;
|
||||||
|
h += `</td></tr>`;
|
||||||
|
}
|
||||||
|
h += '</tbody></table></div>';
|
||||||
|
|
||||||
|
// Search bar
|
||||||
|
h += '<div class="fusion_recon_search p-2 border-top position-relative">';
|
||||||
|
h += '<div class="input-group input-group-sm">';
|
||||||
|
h += '<span class="input-group-text"><i class="fa fa-search"></i></span>';
|
||||||
|
h += `<input type="text" class="form-control fusion_match_search" placeholder="Search by invoice #, amount, or partner..." data-key="${key}" data-line-id="${bankLine.id || ''}"/>`;
|
||||||
|
h += '</div>';
|
||||||
|
h += `<div class="fusion_search_results d-none" data-key="${key}"></div>`;
|
||||||
|
h += '</div>';
|
||||||
|
|
||||||
|
// Running total footer
|
||||||
|
h += '<div class="fusion_match_total d-flex align-items-center justify-content-between p-2 border-top">';
|
||||||
|
h += '<div class="small">';
|
||||||
|
h += '<span class="fusion_selected_total fw-semibold">$0.00</span>';
|
||||||
|
h += ` <span class="text-muted">/ Bank: $${bankAmount.toFixed(2)}</span>`;
|
||||||
|
h += ' <span class="fusion_remaining_badge badge ms-1"></span>';
|
||||||
|
h += '</div>';
|
||||||
|
h += `<button class="btn btn-primary btn-sm fusion_apply_match_btn" data-action="apply_match" disabled>`;
|
||||||
|
h += '<i class="fa fa-check me-1"></i>Apply Match</button>';
|
||||||
|
h += '</div>';
|
||||||
|
|
||||||
|
h += '</div>';
|
||||||
|
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 = '<div class="p-2 text-danger small">Search failed</div>';
|
||||||
|
resultsDiv.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial total calculation
|
||||||
|
recalcTotal();
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderSearchResults(resultsDiv, tableContainer, candidates, tableData, key) {
|
||||||
|
if (!candidates.length) {
|
||||||
|
resultsDiv.innerHTML = '<div class="p-2 text-muted small">No matching entries found</div>';
|
||||||
|
resultsDiv.classList.remove('d-none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let h = '<div class="list-group list-group-flush">';
|
||||||
|
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 += `<button class="list-group-item list-group-item-action p-2 small fusion_search_result" `;
|
||||||
|
h += `data-aml='${JSON.stringify(c).replace(/'/g, "'")}'>`;
|
||||||
|
h += `<div class="d-flex justify-content-between">`;
|
||||||
|
h += `<span><strong>${this._esc(c.name)}</strong> — ${this._esc(c.partner)}</span>`;
|
||||||
|
h += `<span class="fw-semibold">$${(c.amount_residual || 0).toFixed(2)}</span>`;
|
||||||
|
h += `</div>`;
|
||||||
|
h += `<small class="text-muted">${this._esc(c.date)} | ${this._esc(c.ref || '')}</small>`;
|
||||||
|
h += `</button>`;
|
||||||
|
}
|
||||||
|
h += '</div>';
|
||||||
|
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 = `
|
||||||
|
<td class="fit-content px-2"><input type="checkbox" class="form-check-input recon-row-check" data-idx="${idx}" checked/></td>
|
||||||
|
<td class="px-2 py-1 small"><strong>${this._esc(candidate.name)}</strong>${candidate.ref ? '<br/><span class="text-muted">' + this._esc(candidate.ref) + '</span>' : ''}</td>
|
||||||
|
<td class="px-2 py-1 small"><span class="badge ${cTypeClass}">${this._esc(cType)}</span></td>
|
||||||
|
<td class="px-2 py-1 small">${this._esc(candidate.partner)}</td>
|
||||||
|
<td class="px-2 py-1 small text-nowrap">${this._esc(candidate.date)}</td>
|
||||||
|
<td class="px-2 py-1 small text-end text-nowrap">$${residual.toFixed(2)}</td>
|
||||||
|
<td class="px-2 py-1 text-end">
|
||||||
|
<input type="number" class="form-control form-control-sm fusion_apply_amount text-end"
|
||||||
|
data-idx="${idx}" data-max="${residual}" step="0.01" min="0" max="${residual}"
|
||||||
|
value="${residual.toFixed(2)}" style="width:110px; display:inline-block;"/>
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-1 small text-muted">added</td>
|
||||||
|
`;
|
||||||
|
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() {
|
get sessionId() {
|
||||||
return this.state.internalSessionId || this.props.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.internalSessionId = data.session_id;
|
||||||
this.state.messages = data.messages || [];
|
this.state.messages = data.messages || [];
|
||||||
this.state.sessionName = data.name;
|
this.state.sessionName = data.name;
|
||||||
|
if (data.pending_approvals && data.pending_approvals.length) {
|
||||||
|
this.state.pendingApprovals = data.pending_approvals;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to load session:", e);
|
console.error("Failed to load session:", e);
|
||||||
@@ -569,7 +979,8 @@ export class FusionChatPanel extends Component {
|
|||||||
|
|
||||||
async sendMessage() {
|
async sendMessage() {
|
||||||
const text = this.state.inputText.trim();
|
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) {
|
if (!this.sessionId) {
|
||||||
const session = await rpc("/fusion_accounting/session/create", {});
|
const session = await rpc("/fusion_accounting/session/create", {});
|
||||||
@@ -577,30 +988,54 @@ export class FusionChatPanel extends Component {
|
|||||||
this.state.sessionName = session.name;
|
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.inputText = "";
|
||||||
|
this.state.pendingImage = null;
|
||||||
this.state.sending = true;
|
this.state.sending = true;
|
||||||
this.scrollToBottom();
|
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 {
|
try {
|
||||||
const result = await rpc("/fusion_accounting/chat", {
|
const result = await rpc("/fusion_accounting/chat", chatPayload);
|
||||||
session_id: this.sessionId,
|
this._stopStatusPolling();
|
||||||
message: text,
|
if (result.text || (result.tool_calls_log && result.tool_calls_log.length)) {
|
||||||
});
|
this.state.messages.push({
|
||||||
if (result.text) {
|
role: "assistant",
|
||||||
this.state.messages.push({ role: "assistant", content: result.text });
|
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) {
|
if (result.pending_approvals) {
|
||||||
this.state.pendingApprovals = result.pending_approvals;
|
this.state.pendingApprovals = result.pending_approvals;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
this._stopStatusPolling();
|
||||||
this.state.messages.push({
|
this.state.messages.push({
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: `Error: ${e.message || "Something went wrong."}`,
|
content: `Error: ${e.message || "Something went wrong."}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
this.state.sending = false;
|
this.state.sending = false;
|
||||||
this.scrollToBottom();
|
this.scrollToNewReply();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -615,25 +1050,41 @@ export class FusionChatPanel extends Component {
|
|||||||
create_rule: "Create Rules",
|
create_rule: "Create Rules",
|
||||||
dismiss: "Dismiss",
|
dismiss: "Dismiss",
|
||||||
submit_notes: "Submit Notes",
|
submit_notes: "Submit Notes",
|
||||||
|
apply_match: "Apply Match",
|
||||||
};
|
};
|
||||||
const label = actionLabels[action] || action;
|
const label = actionLabels[action] || action;
|
||||||
|
|
||||||
// Build a structured message for the AI
|
let message;
|
||||||
let parts = [`[TABLE_ACTION] source=${source_tool} action=${action}`];
|
if (action === 'apply_match') {
|
||||||
for (const row of rows) {
|
// Reconciliation-specific format with AML IDs and custom amounts
|
||||||
const cellSummary = (row.cells || []).join(" | ");
|
const bankLineId = payload.bank_line_id || '';
|
||||||
let line = `- Row #${row.id}: ${cellSummary}`;
|
const bankAmount = payload.bank_amount || 0;
|
||||||
if (row.recommendation) {
|
const total = rows.reduce((s, r) => s + (r.apply_amount || 0), 0);
|
||||||
line += ` (AI suggested: ${row.recommendation.action} - ${row.recommendation.reason})`;
|
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) {
|
parts.push(`Total: $${total.toFixed(2)} / Bank: $${bankAmount.toFixed(2)}`);
|
||||||
line += ` [User note: ${row.userNote}]`;
|
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
|
// Show user what we're sending
|
||||||
this.state.messages.push({
|
this.state.messages.push({
|
||||||
role: "user",
|
role: "user",
|
||||||
@@ -641,26 +1092,38 @@ export class FusionChatPanel extends Component {
|
|||||||
});
|
});
|
||||||
this.state.sending = true;
|
this.state.sending = true;
|
||||||
this.scrollToBottom();
|
this.scrollToBottom();
|
||||||
|
this._startStatusPolling();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await rpc("/fusion_accounting/chat", {
|
const result = await rpc("/fusion_accounting/chat", {
|
||||||
session_id: this.sessionId,
|
session_id: this.sessionId,
|
||||||
message: message,
|
message: message,
|
||||||
});
|
});
|
||||||
if (result.text) {
|
this._stopStatusPolling();
|
||||||
this.state.messages.push({ role: "assistant", content: result.text });
|
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) {
|
if (result.pending_approvals) {
|
||||||
this.state.pendingApprovals = result.pending_approvals;
|
this.state.pendingApprovals = result.pending_approvals;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
this._stopStatusPolling();
|
||||||
this.state.messages.push({
|
this.state.messages.push({
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: `Error processing table action: ${e.message || "Something went wrong."}`,
|
content: `Error processing table action: ${e.message || "Something went wrong."}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
this.state.sending = false;
|
this.state.sending = false;
|
||||||
this.scrollToBottom();
|
this.scrollToNewReply();
|
||||||
|
}
|
||||||
|
|
||||||
|
sendStarter(text) {
|
||||||
|
this.state.inputText = text;
|
||||||
|
this.sendMessage();
|
||||||
}
|
}
|
||||||
|
|
||||||
onKeyDown(ev) {
|
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() {
|
scrollToBottom() {
|
||||||
const el = this.messagesRef.el;
|
const el = this.messagesRef.el;
|
||||||
if (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) {
|
async onApprove(matchHistoryId) {
|
||||||
await rpc("/fusion_accounting/approve", { match_history_id: matchHistoryId });
|
await rpc("/fusion_accounting/approve", { match_history_id: matchHistoryId });
|
||||||
this.state.pendingApprovals = this.state.pendingApprovals.filter(a => a.id !== matchHistoryId);
|
this.state.pendingApprovals = this.state.pendingApprovals.filter(a => a.id !== matchHistoryId);
|
||||||
|
|||||||
@@ -66,10 +66,51 @@
|
|||||||
</div>
|
</div>
|
||||||
</t>
|
</t>
|
||||||
<t t-elif="state.messages.length === 0">
|
<t t-elif="state.messages.length === 0">
|
||||||
<div class="text-center text-muted py-4">
|
<div class="text-center text-muted py-3">
|
||||||
<i class="fa fa-robot fa-3x mb-3 d-block"/>
|
<i class="fa fa-robot fa-3x mb-2 d-block"/>
|
||||||
<p>Ask me about your accounting data.<br/>
|
<p class="mb-3">What would you like to work on?</p>
|
||||||
I can help with bank reconciliation, tax analysis, AR/AP, auditing, and more.</p>
|
</div>
|
||||||
|
<div class="d-flex flex-wrap gap-2 justify-content-center px-3 pb-3">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm fusion_starter"
|
||||||
|
t-on-click="() => this.sendStarter('Reconcile the latest ADP payment on Scotia Current')">
|
||||||
|
<i class="fa fa-exchange me-1"/>Match ADP Payment
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm fusion_starter"
|
||||||
|
t-on-click="() => this.sendStarter('Show me unreconciled bank lines on all journals')">
|
||||||
|
<i class="fa fa-bank me-1"/>Unreconciled Lines
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm fusion_starter"
|
||||||
|
t-on-click="() => this.sendStarter('How much do we owe to Pride Mobility?')">
|
||||||
|
<i class="fa fa-credit-card me-1"/>Vendor Balance
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm fusion_starter"
|
||||||
|
t-on-click="() => this.sendStarter('Show me invoicing by month for this year')">
|
||||||
|
<i class="fa fa-bar-chart me-1"/>Invoicing by Month
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm fusion_starter"
|
||||||
|
t-on-click="() => this.sendStarter('How much are we collecting this month?')">
|
||||||
|
<i class="fa fa-money me-1"/>Collections
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm fusion_starter"
|
||||||
|
t-on-click="() => this.sendStarter('What is our current HST balance?')">
|
||||||
|
<i class="fa fa-percent me-1"/>HST Balance
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm fusion_starter"
|
||||||
|
t-on-click="() => this.sendStarter('Show me overdue invoices')">
|
||||||
|
<i class="fa fa-exclamation-circle me-1"/>Overdue Invoices
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm fusion_starter"
|
||||||
|
t-on-click="() => this.sendStarter('Run month-end close checklist')">
|
||||||
|
<i class="fa fa-check-square-o me-1"/>Month-End Close
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm fusion_starter"
|
||||||
|
t-on-click="() => this.sendStarter('Show me the P&L for this quarter')">
|
||||||
|
<i class="fa fa-line-chart me-1"/>Profit & Loss
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm fusion_starter"
|
||||||
|
t-on-click="() => this.sendStarter('Find duplicate bills')">
|
||||||
|
<i class="fa fa-copy me-1"/>Duplicate Bills
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</t>
|
</t>
|
||||||
<t t-foreach="state.messages" t-as="msg" t-key="msg_index">
|
<t t-foreach="state.messages" t-as="msg" t-key="msg_index">
|
||||||
@@ -78,7 +119,14 @@
|
|||||||
<div class="fusion_chat_msg mb-2 p-2 rounded bg-primary-subtle ms-4">
|
<div class="fusion_chat_msg mb-2 p-2 rounded bg-primary-subtle ms-4">
|
||||||
<small class="text-muted d-block mb-1">
|
<small class="text-muted d-block mb-1">
|
||||||
<i class="fa fa-user me-1"/>You
|
<i class="fa fa-user me-1"/>You
|
||||||
|
<t t-if="msg.hasImage">
|
||||||
|
<i class="fa fa-image ms-1 text-info" title="Image attached"/>
|
||||||
|
</t>
|
||||||
</small>
|
</small>
|
||||||
|
<t t-if="msg.imageUrl">
|
||||||
|
<img t-att-src="msg.imageUrl" class="rounded mb-1 d-block" style="max-height: 120px; max-width: 200px; cursor: pointer; object-fit: cover;"
|
||||||
|
t-on-click="() => window.open(msg.imageUrl, '_blank')"/>
|
||||||
|
</t>
|
||||||
<span style="white-space: pre-wrap;" t-esc="msg.content"/>
|
<span style="white-space: pre-wrap;" t-esc="msg.content"/>
|
||||||
</div>
|
</div>
|
||||||
</t>
|
</t>
|
||||||
@@ -88,59 +136,161 @@
|
|||||||
<small class="text-muted d-block mb-2">
|
<small class="text-muted d-block mb-2">
|
||||||
<i class="fa fa-robot me-1"/>Fusion AI
|
<i class="fa fa-robot me-1"/>Fusion AI
|
||||||
</small>
|
</small>
|
||||||
|
<!-- Collapsible tool calls log (like Claude Code) -->
|
||||||
|
<t t-if="msg.toolCalls and msg.toolCalls.length">
|
||||||
|
<details class="fusion_tool_calls mb-2">
|
||||||
|
<summary class="small text-muted cursor-pointer d-inline-flex align-items-center gap-1 user-select-none">
|
||||||
|
<i class="fa fa-wrench" style="font-size: 0.7rem;"/>
|
||||||
|
<span><t t-esc="msg.toolCalls.length"/> tool call<t t-if="msg.toolCalls.length > 1">s</t></span>
|
||||||
|
</summary>
|
||||||
|
<div class="mt-1 ms-2 border-start ps-2" style="border-color: var(--o-border-color) !important;">
|
||||||
|
<t t-foreach="msg.toolCalls" t-as="tc" t-key="tc_index">
|
||||||
|
<div class="d-flex align-items-start gap-1 py-1 small"
|
||||||
|
style="line-height: 1.3;">
|
||||||
|
<i t-att-class="'fa fa-fw ' + (tc.status === 'error' ? 'fa-times-circle text-danger' : tc.status === 'pending_approval' ? 'fa-clock-o text-warning' : 'fa-check-circle text-success')"
|
||||||
|
style="font-size: 0.7rem; margin-top: 3px;"/>
|
||||||
|
<span>
|
||||||
|
<code class="small" style="font-size: 0.78rem;" t-esc="tc.name"/>
|
||||||
|
<t t-if="tc.summary">
|
||||||
|
<span class="text-muted ms-1">— <t t-esc="tc.summary"/></span>
|
||||||
|
</t>
|
||||||
|
<t t-if="tc.duration_ms">
|
||||||
|
<span class="text-muted ms-1" style="font-size: 0.7rem;">(<t t-esc="tc.duration_ms"/>ms)</span>
|
||||||
|
</t>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</t>
|
||||||
<div class="fusion_rich_content fusion_rich_slot"
|
<div class="fusion_rich_content fusion_rich_slot"
|
||||||
t-att-data-idx="msg_index"/>
|
t-att-data-idx="msg_index"/>
|
||||||
</div>
|
</div>
|
||||||
</t>
|
</t>
|
||||||
</t>
|
</t>
|
||||||
<t t-if="state.sending">
|
<t t-if="state.sending">
|
||||||
<div class="fusion_ai_msg rounded p-3 me-4 mb-2">
|
<div class="fusion_ai_msg rounded p-3 me-4 mb-2 fusion_live_status">
|
||||||
<small class="text-muted d-block mb-1">
|
<small class="text-muted d-block mb-1">
|
||||||
<i class="fa fa-robot me-1"/>Fusion AI
|
<i class="fa fa-robot me-1"/>Fusion AI
|
||||||
</small>
|
</small>
|
||||||
<i class="fa fa-spinner fa-spin me-1"/> Thinking...
|
<!-- Live thinking text -->
|
||||||
|
<t t-if="state.liveThinking">
|
||||||
|
<div class="fusion_thinking_block mb-2 p-2 rounded small fst-italic"
|
||||||
|
style="background: rgba(var(--bs-body-color-rgb), 0.03); border-left: 3px solid var(--bs-purple, #6f42c1); max-height: 120px; overflow-y: auto;">
|
||||||
|
<i class="fa fa-brain me-1 text-purple" style="color: var(--bs-purple, #6f42c1);"/>
|
||||||
|
<span t-esc="state.liveThinking"/>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
<!-- Live tool calls -->
|
||||||
|
<t t-if="state.liveToolCalls.length > 0">
|
||||||
|
<div class="mb-1">
|
||||||
|
<t t-foreach="state.liveToolCalls" t-as="tc" t-key="tc_index">
|
||||||
|
<div class="d-flex align-items-center gap-1 small py-1" style="line-height: 1.3;">
|
||||||
|
<t t-if="tc.status === 'running'">
|
||||||
|
<i class="fa fa-spinner fa-spin text-primary" style="font-size: 0.7rem;"/>
|
||||||
|
</t>
|
||||||
|
<t t-elif="tc.status === 'ok'">
|
||||||
|
<i class="fa fa-check-circle text-success" style="font-size: 0.7rem;"/>
|
||||||
|
</t>
|
||||||
|
<t t-elif="tc.status === 'error'">
|
||||||
|
<i class="fa fa-times-circle text-danger" style="font-size: 0.7rem;"/>
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
<i class="fa fa-clock-o text-warning" style="font-size: 0.7rem;"/>
|
||||||
|
</t>
|
||||||
|
<code class="small" style="font-size: 0.75rem;" t-esc="tc.name"/>
|
||||||
|
<t t-if="tc.summary">
|
||||||
|
<span class="text-muted">— <t t-esc="tc.summary"/></span>
|
||||||
|
</t>
|
||||||
|
<t t-if="tc.duration_ms">
|
||||||
|
<span class="text-muted" style="font-size: 0.68rem;">(<t t-esc="tc.duration_ms"/>ms)</span>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
<!-- Default thinking indicator if no live data yet -->
|
||||||
|
<t t-if="!state.liveThinking and state.liveToolCalls.length === 0">
|
||||||
|
<i class="fa fa-spinner fa-spin me-1"/> Thinking...
|
||||||
|
</t>
|
||||||
</div>
|
</div>
|
||||||
</t>
|
</t>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pending Approvals -->
|
<!-- Pending Approvals — compact table -->
|
||||||
<t t-if="state.pendingApprovals.length > 0">
|
<t t-if="state.pendingApprovals.length > 0">
|
||||||
<div class="border-top p-2">
|
<div class="border-top">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
<div class="d-flex justify-content-between align-items-center px-2 py-1 bg-warning-subtle">
|
||||||
<small class="text-muted">Pending Approvals (<t t-esc="state.pendingApprovals.length"/>):</small>
|
<small class="fw-semibold">
|
||||||
|
<i class="fa fa-exclamation-triangle me-1 text-warning"/>
|
||||||
|
<t t-esc="state.pendingApprovals.length"/> Pending Approval<t t-if="state.pendingApprovals.length > 1">s</t>
|
||||||
|
</small>
|
||||||
<div class="d-flex gap-1" t-if="state.pendingApprovals.length > 1">
|
<div class="d-flex gap-1" t-if="state.pendingApprovals.length > 1">
|
||||||
<button class="btn btn-success btn-sm" t-on-click="onApproveAll">
|
<button class="btn btn-success px-2 py-0" style="font-size: 0.75rem;"
|
||||||
<i class="fa fa-check-double"/> Approve All
|
t-on-click="onApproveAll" title="Approve all">
|
||||||
|
<i class="fa fa-check me-1"/>All
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-outline-danger btn-sm" t-on-click="onRejectAll">
|
<button class="btn btn-outline-danger px-2 py-0" style="font-size: 0.75rem;"
|
||||||
Reject All
|
t-on-click="onRejectAll" title="Reject all">
|
||||||
|
<i class="fa fa-times me-1"/>All
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<t t-foreach="state.pendingApprovals" t-as="approval" t-key="approval.id">
|
<div class="overflow-auto" style="max-height: 280px;">
|
||||||
<FusionApprovalCard
|
<table class="table table-sm table-hover align-middle mb-0">
|
||||||
approval="approval"
|
<thead>
|
||||||
onApprove.bind="onApprove"
|
<tr class="small text-muted">
|
||||||
onReject.bind="onReject"/>
|
<th class="px-2 py-1 fw-semibold">Type</th>
|
||||||
</t>
|
<th class="px-2 py-1 fw-semibold">Details</th>
|
||||||
|
<th class="px-2 py-1 fw-semibold text-end">Amount</th>
|
||||||
|
<th class="px-1 py-1 fw-semibold text-end" style="width: 80px;"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<t t-foreach="state.pendingApprovals" t-as="approval" t-key="approval.id">
|
||||||
|
<FusionApprovalCard
|
||||||
|
approval="approval"
|
||||||
|
onApprove.bind="onApprove"
|
||||||
|
onReject.bind="onReject"/>
|
||||||
|
</t>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</t>
|
</t>
|
||||||
|
|
||||||
<!-- Input -->
|
<!-- Input -->
|
||||||
<div class="fusion_chat_input border-top p-2">
|
<div class="fusion_chat_input border-top p-2">
|
||||||
|
<!-- Image preview -->
|
||||||
|
<t t-if="state.pendingImage">
|
||||||
|
<div class="fusion_image_preview d-flex align-items-center gap-2 mb-1 p-1 rounded bg-body-tertiary">
|
||||||
|
<img t-att-src="state.pendingImage.dataUrl" class="rounded" style="max-height: 48px; max-width: 80px; object-fit: cover;"/>
|
||||||
|
<small class="text-muted flex-grow-1 text-truncate" t-esc="state.pendingImage.name"/>
|
||||||
|
<button class="btn btn-sm p-0 text-danger" t-on-click="clearImage" title="Remove">
|
||||||
|
<i class="fa fa-times"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" t-on-click="triggerFileUpload"
|
||||||
|
title="Attach image (screenshot, remittance advice, etc.)">
|
||||||
|
<i class="fa fa-paperclip"/>
|
||||||
|
</button>
|
||||||
<textarea
|
<textarea
|
||||||
t-ref="chatInput"
|
t-ref="chatInput"
|
||||||
class="form-control form-control-sm"
|
class="form-control form-control-sm"
|
||||||
placeholder="Ask Fusion AI..."
|
placeholder="Ask Fusion AI... (paste screenshot with Ctrl+V)"
|
||||||
rows="2"
|
rows="1"
|
||||||
t-model="state.inputText"
|
t-model="state.inputText"
|
||||||
t-on-keydown="onKeyDown"/>
|
t-on-keydown="onKeyDown"
|
||||||
|
t-on-paste="onPaste"/>
|
||||||
<button class="btn btn-primary btn-sm" t-on-click="sendMessage"
|
<button class="btn btn-primary btn-sm" t-on-click="sendMessage"
|
||||||
t-att-disabled="state.sending">
|
t-att-disabled="state.sending">
|
||||||
<i class="fa fa-paper-plane"/>
|
<i class="fa fa-paper-plane"/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<input type="file" t-ref="fileInput" class="d-none" accept="image/*"
|
||||||
|
t-on-change="onFileSelected"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</t>
|
</t>
|
||||||
|
|||||||
@@ -36,12 +36,22 @@ export class FusionDashboard extends Component {
|
|||||||
this.state.loading = false;
|
this.state.loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async onCardClick(domain) {
|
async onAttentionClick(domain, prompt) {
|
||||||
if (!this.state.chatSessionId) {
|
// Type the prompt into the chat input and send
|
||||||
const session = await rpc("/fusion_accounting/session/create", {
|
if (!prompt) return;
|
||||||
context_domain: domain,
|
const textarea = this.el?.querySelector('.fusion_chat_input textarea');
|
||||||
});
|
if (textarea) {
|
||||||
this.state.chatSessionId = session.session_id;
|
// Set value and trigger OWL's model update via input event
|
||||||
|
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||||
|
window.HTMLTextAreaElement.prototype, 'value'
|
||||||
|
).set;
|
||||||
|
nativeInputValueSetter.call(textarea, prompt);
|
||||||
|
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
// Click send button
|
||||||
|
setTimeout(() => {
|
||||||
|
const sendBtn = this.el?.querySelector('.fusion_chat_input .btn-primary');
|
||||||
|
if (sendBtn) sendBtn.click();
|
||||||
|
}, 50);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,27 +62,27 @@ export class FusionDashboard extends Component {
|
|||||||
{
|
{
|
||||||
title: "Bank Reconciliation",
|
title: "Bank Reconciliation",
|
||||||
metric: `${d.bank_recon.count} unmatched`,
|
metric: `${d.bank_recon.count} unmatched`,
|
||||||
subtext: `$${(d.bank_recon.amount || 0).toFixed(2)} total`,
|
subtext: `$${(d.bank_recon.amount || 0).toLocaleString('en-CA', {minimumFractionDigits: 0})} total`,
|
||||||
domain: "bank_reconciliation",
|
domain: "bank_reconciliation",
|
||||||
status: d.bank_recon.count === 0 ? "green" : d.bank_recon.count < 10 ? "yellow" : "red",
|
status: d.bank_recon.count === 0 ? "green" : d.bank_recon.count < 10 ? "yellow" : "red",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "AR Outstanding",
|
title: "AR Outstanding",
|
||||||
metric: `$${(d.ar.total || 0).toFixed(2)}`,
|
metric: `$${Math.abs(d.ar.total || 0).toLocaleString('en-CA', {minimumFractionDigits: 0})}`,
|
||||||
subtext: `${d.ar.overdue_count} overdue`,
|
subtext: `${d.ar.overdue_count} overdue`,
|
||||||
domain: "accounts_receivable",
|
domain: "accounts_receivable",
|
||||||
status: d.ar.overdue_count === 0 ? "green" : d.ar.overdue_count < 5 ? "yellow" : "red",
|
status: d.ar.overdue_count === 0 ? "green" : d.ar.overdue_count < 5 ? "yellow" : "red",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "AP Due",
|
title: "AP Due",
|
||||||
metric: `$${(d.ap.total || 0).toFixed(2)}`,
|
metric: `$${(d.ap.total || 0).toLocaleString('en-CA', {minimumFractionDigits: 0})}`,
|
||||||
subtext: `${d.ap.due_this_week} due this week`,
|
subtext: `${d.ap.due_this_week} due this week`,
|
||||||
domain: "accounts_payable",
|
domain: "accounts_payable",
|
||||||
status: d.ap.due_this_week === 0 ? "green" : "yellow",
|
status: d.ap.due_this_week === 0 ? "green" : "yellow",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "HST Balance",
|
title: "HST Balance",
|
||||||
metric: `$${(d.hst.balance || 0).toFixed(2)}`,
|
metric: `$${(d.hst.balance || 0).toLocaleString('en-CA', {minimumFractionDigits: 0})}`,
|
||||||
subtext: d.hst.balance > 0 ? "Owing to CRA" : "Refund expected",
|
subtext: d.hst.balance > 0 ? "Owing to CRA" : "Refund expected",
|
||||||
domain: "hst_management",
|
domain: "hst_management",
|
||||||
status: "blue",
|
status: "blue",
|
||||||
|
|||||||
@@ -2,29 +2,27 @@
|
|||||||
<templates xml:space="preserve">
|
<templates xml:space="preserve">
|
||||||
<t t-name="fusion_accounting.Dashboard">
|
<t t-name="fusion_accounting.Dashboard">
|
||||||
<div class="o_action fusion_accounting_dashboard">
|
<div class="o_action fusion_accounting_dashboard">
|
||||||
<div class="fusion_dashboard_header d-flex justify-content-between align-items-center p-3">
|
<div class="fusion_dashboard_header d-flex justify-content-between align-items-center px-3 py-2">
|
||||||
<h2 class="mb-0">Fusion AI Dashboard</h2>
|
<h4 class="mb-0"><i class="fa fa-bolt me-2"/>Fusion AI</h4>
|
||||||
<button class="btn btn-outline-primary btn-sm" t-on-click="loadDashboard">
|
<button class="btn btn-outline-secondary btn-sm" t-on-click="loadDashboard">
|
||||||
<i class="fa fa-refresh"/> Refresh
|
<i class="fa fa-refresh me-1"/>Refresh
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<t t-if="state.loading">
|
<t t-if="state.loading">
|
||||||
<div class="text-center p-5">
|
<div class="text-center p-5">
|
||||||
<i class="fa fa-spinner fa-spin fa-2x"/>
|
<i class="fa fa-spinner fa-spin fa-2x"/>
|
||||||
<p class="mt-2">Loading dashboard...</p>
|
<p class="mt-2 text-muted">Loading dashboard...</p>
|
||||||
</div>
|
</div>
|
||||||
</t>
|
</t>
|
||||||
|
|
||||||
<t t-else="">
|
<t t-else="">
|
||||||
<!-- Main layout: Left panel (cards + needs attention) | Right panel (chat) -->
|
|
||||||
<div class="fusion_main_layout d-flex">
|
<div class="fusion_main_layout d-flex">
|
||||||
|
|
||||||
<!-- LEFT SIDE: Cards (2 rows of 3) + Needs Attention -->
|
<!-- LEFT: Cards + Needs Attention -->
|
||||||
<div class="fusion_left_panel d-flex flex-column p-3 gap-3">
|
<div class="fusion_left_panel d-flex flex-column p-3 gap-3">
|
||||||
|
|
||||||
<!-- Health Cards: 2 rows x 3 cards -->
|
<div class="fusion_health_cards">
|
||||||
<div class="fusion_health_cards d-flex flex-wrap gap-2">
|
|
||||||
<t t-foreach="cards" t-as="card" t-key="card.domain">
|
<t t-foreach="cards" t-as="card" t-key="card.domain">
|
||||||
<FusionHealthCard
|
<FusionHealthCard
|
||||||
title="card.title"
|
title="card.title"
|
||||||
@@ -32,37 +30,45 @@
|
|||||||
subtext="card.subtext"
|
subtext="card.subtext"
|
||||||
status="card.status"
|
status="card.status"
|
||||||
domain="card.domain"
|
domain="card.domain"
|
||||||
onCardClick.bind="onCardClick"/>
|
onCardClick.bind="onAttentionClick"/>
|
||||||
</t>
|
</t>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Needs Attention Panel -->
|
<!-- Needs Attention -->
|
||||||
<div class="card fusion_attention_card">
|
<div class="fusion_attention_panel flex-grow-1 d-flex flex-column">
|
||||||
<div class="card-header py-2">
|
<div class="d-flex align-items-center gap-2 mb-2">
|
||||||
<h5 class="mb-0"><i class="fa fa-exclamation-triangle me-2 text-warning"/>Needs Attention</h5>
|
<i class="fa fa-bell text-warning"/>
|
||||||
|
<span class="fw-semibold small">Needs Attention</span>
|
||||||
|
<t t-if="state.data and state.data.needs_attention">
|
||||||
|
<span class="badge bg-warning text-dark" t-esc="state.data.needs_attention.length"/>
|
||||||
|
</t>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body overflow-auto p-2">
|
<div class="fusion_attention_list flex-grow-1 overflow-auto">
|
||||||
<t t-if="state.data and state.data.needs_attention and state.data.needs_attention.length">
|
<t t-if="state.data and state.data.needs_attention and state.data.needs_attention.length">
|
||||||
<t t-foreach="state.data.needs_attention" t-as="item" t-key="item_index">
|
<t t-foreach="state.data.needs_attention" t-as="item" t-key="item_index">
|
||||||
<div class="fusion_attention_item d-flex align-items-start gap-2 p-2 rounded mb-1 cursor-pointer"
|
<div class="fusion_attention_item d-flex align-items-start gap-2 p-2 rounded cursor-pointer"
|
||||||
t-on-click="() => this.onCardClick(item.domain)">
|
t-on-click="() => this.onAttentionClick(item.domain, item.prompt)">
|
||||||
<i class="fa fa-circle-o text-warning mt-1" style="font-size: 0.6rem;"/>
|
<div t-attf-class="fusion_attn_dot fusion_attn_{{item.severity || 'warning'}}"/>
|
||||||
<div>
|
<div class="flex-grow-1 small">
|
||||||
<div class="fw-semibold small" t-esc="item.title"/>
|
<div class="fw-semibold" t-esc="item.title"/>
|
||||||
<div class="text-muted" style="font-size: 0.78rem;" t-esc="item.action"/>
|
<div class="text-muted" style="font-size: 0.75rem;" t-esc="item.action"/>
|
||||||
</div>
|
</div>
|
||||||
|
<i class="fa fa-chevron-right text-muted mt-1" style="font-size: 0.6rem;"/>
|
||||||
</div>
|
</div>
|
||||||
</t>
|
</t>
|
||||||
</t>
|
</t>
|
||||||
<t t-else="">
|
<t t-else="">
|
||||||
<p class="text-muted small mb-0">AI-prioritised items will appear here after the first audit scan.</p>
|
<div class="text-center text-muted small py-3">
|
||||||
|
<i class="fa fa-check-circle fa-2x mb-2 d-block text-success"/>
|
||||||
|
All clear! No items need attention.
|
||||||
|
</div>
|
||||||
</t>
|
</t>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- RIGHT SIDE: Chat Panel (full height, input pinned to bottom) -->
|
<!-- RIGHT: Chat -->
|
||||||
<div class="fusion_right_panel border-start">
|
<div class="fusion_right_panel">
|
||||||
<FusionChatPanel sessionId="state.chatSessionId"/>
|
<FusionChatPanel sessionId="state.chatSessionId"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,14 +6,16 @@ export class FusionHealthCard extends Component {
|
|||||||
static template = "fusion_accounting.HealthCard";
|
static template = "fusion_accounting.HealthCard";
|
||||||
static props = ["title", "metric", "subtext", "status", "domain", "onCardClick"];
|
static props = ["title", "metric", "subtext", "status", "domain", "onCardClick"];
|
||||||
|
|
||||||
get statusClass() {
|
get icon() {
|
||||||
const map = {
|
const icons = {
|
||||||
green: "bg-success-subtle border-success",
|
bank_reconciliation: "fa-bank",
|
||||||
yellow: "bg-warning-subtle border-warning",
|
accounts_receivable: "fa-file-text-o",
|
||||||
red: "bg-danger-subtle border-danger",
|
accounts_payable: "fa-credit-card",
|
||||||
blue: "bg-info-subtle border-info",
|
hst_management: "fa-percent",
|
||||||
|
audit: "fa-shield",
|
||||||
|
month_end: "fa-calendar-check-o",
|
||||||
};
|
};
|
||||||
return map[this.props.status] || "bg-light";
|
return icons[this.props.domain] || "fa-bar-chart";
|
||||||
}
|
}
|
||||||
|
|
||||||
onClick() {
|
onClick() {
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<templates xml:space="preserve">
|
<templates xml:space="preserve">
|
||||||
<t t-name="fusion_accounting.HealthCard">
|
<t t-name="fusion_accounting.HealthCard">
|
||||||
<div class="fusion_health_card card border-2 cursor-pointer"
|
<div class="fusion_health_card cursor-pointer"
|
||||||
t-attf-class="{{statusClass}}"
|
t-attf-class="fusion_card_{{props.status}}"
|
||||||
style="min-width: 180px; flex: 1;"
|
|
||||||
t-on-click="onClick">
|
t-on-click="onClick">
|
||||||
<div class="card-body text-center p-3">
|
<div class="d-flex align-items-center gap-2 mb-2">
|
||||||
<h6 class="card-title text-muted mb-1" t-esc="props.title"/>
|
<div class="fusion_card_icon">
|
||||||
<h3 class="mb-1" t-esc="props.metric"/>
|
<i t-attf-class="fa {{icon}}"/>
|
||||||
<small class="text-muted" t-esc="props.subtext"/>
|
</div>
|
||||||
|
<span class="fusion_card_title" t-esc="props.title"/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="fusion_card_metric" t-esc="props.metric"/>
|
||||||
|
<div class="fusion_card_sub" t-esc="props.subtext"/>
|
||||||
</div>
|
</div>
|
||||||
</t>
|
</t>
|
||||||
</templates>
|
</templates>
|
||||||
|
|||||||
@@ -6,11 +6,22 @@
|
|||||||
|
|
||||||
.fusion_chat_msg {
|
.fusion_chat_msg {
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
|
animation: fusionFadeIn 0.25s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fusion_ai_msg {
|
.fusion_ai_msg {
|
||||||
background: var(--o-view-background-color);
|
background: var(--o-view-background-color);
|
||||||
border: 1px solid var(--o-border-color);
|
border: 1px solid var(--o-border-color);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live thinking block
|
||||||
|
.fusion_live_status {
|
||||||
|
animation: fusionPulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fusion_thinking_block {
|
||||||
|
animation: fusionFadeIn 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fusion_rich_content {
|
.fusion_rich_content {
|
||||||
@@ -72,15 +83,165 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.fusion_chat_input {
|
// Conversation starters
|
||||||
flex-shrink: 0;
|
.fusion_starter.btn {
|
||||||
textarea {
|
font-size: 0.8rem;
|
||||||
resize: none;
|
border-radius: 1rem;
|
||||||
|
padding: 0.3rem 0.8rem;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
border-color: var(--o-border-color, var(--bs-border-color));
|
||||||
|
&:hover, &:focus, &:active {
|
||||||
|
background: var(--o-action-color, #714B67) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
border-color: var(--o-action-color, #714B67) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.fusion_approval_card {
|
.fusion_chat_input {
|
||||||
border-left: 3px solid var(--bs-warning);
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: none;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
min-height: 42px;
|
||||||
|
max-height: 120px;
|
||||||
|
line-height: 1.5;
|
||||||
|
// Vertically centre single-line placeholder
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
&::placeholder { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wider send button
|
||||||
|
.btn-primary {
|
||||||
|
min-width: 52px;
|
||||||
|
padding-left: 0.85rem;
|
||||||
|
padding-right: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paperclip button
|
||||||
|
.btn-outline-secondary {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fusion_image_preview {
|
||||||
|
animation: fusionFadeIn 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fusion_approval_row {
|
||||||
|
td {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
&:hover {
|
||||||
|
background: rgba(var(--bs-body-color-rgb), 0.04);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collapsible tool calls log
|
||||||
|
.fusion_tool_calls {
|
||||||
|
summary {
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
&::-webkit-details-marker { display: none; }
|
||||||
|
&::before {
|
||||||
|
content: "\f105"; // fa-angle-right
|
||||||
|
font-family: FontAwesome;
|
||||||
|
display: inline-block;
|
||||||
|
width: 0.8em;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&[open] > summary::before {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
color: var(--o-action-color, var(--bs-primary));
|
||||||
|
background: rgba(var(--bs-body-color-rgb), 0.04);
|
||||||
|
padding: 0.1em 0.3em;
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconciliation table styles
|
||||||
|
.fusion_recon_table {
|
||||||
|
border: 1px solid var(--o-border-color);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--o-view-background-color);
|
||||||
|
|
||||||
|
.table {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
|
||||||
|
thead th {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
background: rgba(var(--bs-body-color-rgb), 0.03);
|
||||||
|
border-bottom: 2px solid var(--o-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr {
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
&:hover { background: rgba(var(--bs-body-color-rgb), 0.04); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.fit-content { width: 1%; white-space: nowrap; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.fusion_apply_amount {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.15rem 0.35rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--o-border-color);
|
||||||
|
color: inherit;
|
||||||
|
text-align: right;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
background: var(--o-view-background-color);
|
||||||
|
border-color: var(--o-action-color, var(--bs-primary));
|
||||||
|
box-shadow: 0 0 0 0.15rem rgba(var(--bs-primary-rgb), 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fusion_recon_search {
|
||||||
|
background: rgba(var(--bs-body-color-rgb), 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fusion_search_results {
|
||||||
|
position: absolute;
|
||||||
|
left: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
top: 100%;
|
||||||
|
z-index: 10;
|
||||||
|
background: var(--o-view-background-color);
|
||||||
|
border: 1px solid var(--o-border-color);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
box-shadow: 0 4px 12px rgba(var(--bs-body-color-rgb), 0.15);
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
.list-group-item {
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--o-border-color);
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(var(--bs-primary-rgb), 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child { border-bottom: none; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fusion_match_total {
|
||||||
|
background: rgba(var(--bs-body-color-rgb), 0.02);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Interactive table styles
|
// Interactive table styles
|
||||||
@@ -162,3 +323,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// Animations
|
||||||
|
// ================================================================
|
||||||
|
@keyframes fusionFadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fusionPulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.7; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
.fusion_accounting_dashboard {
|
.fusion_accounting_dashboard {
|
||||||
// Fill the available Odoo content area (below navbar + menu bar)
|
|
||||||
// Use 100% of parent instead of 100vh to respect Odoo's own layout
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -11,101 +9,205 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main two-column layout — must fill remaining height
|
// ================================================================
|
||||||
|
// Main two-column layout
|
||||||
|
// ================================================================
|
||||||
.fusion_main_layout {
|
.fusion_main_layout {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
// This is the key: prevent the flex container from growing beyond
|
|
||||||
// the viewport, which would push the chat input off-screen
|
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Left panel: cards + needs attention (scrollable)
|
// Left panel — padding matches right panel so both columns align
|
||||||
.fusion_left_panel {
|
.fusion_left_panel {
|
||||||
width: 50%;
|
width: 50%;
|
||||||
min-width: 400px;
|
min-width: 400px;
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
padding: 0.75rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Health cards: 3 per row
|
// Right panel
|
||||||
.fusion_health_cards {
|
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
.fusion_health_card {
|
|
||||||
flex: 0 0 calc(33.333% - 6px);
|
|
||||||
min-width: 150px;
|
|
||||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
|
||||||
&:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 12px rgba(var(--bs-body-color-rgb), 0.1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Needs Attention: fill remaining left panel space
|
|
||||||
.fusion_attention_card {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 150px;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
.card-body {
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Needs Attention items
|
|
||||||
.fusion_attention_item {
|
|
||||||
transition: background 0.15s ease;
|
|
||||||
&:hover {
|
|
||||||
background: rgba(var(--bs-body-color-rgb), 0.04);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Right panel: chat takes all remaining width and height
|
|
||||||
.fusion_right_panel {
|
.fusion_right_panel {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 500px;
|
min-width: 500px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
// Critical: prevent overflow so chat input stays visible
|
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
padding: 0.75rem 0.75rem 0.75rem 0;
|
||||||
|
|
||||||
// Override chat panel to fill the container
|
|
||||||
.fusion_chat_panel {
|
.fusion_chat_panel {
|
||||||
// Fill the right panel completely
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
border-radius: 0;
|
border: 1px solid #dee2e6;
|
||||||
border: none;
|
border-radius: 0.75rem;
|
||||||
// Must not exceed container
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
height: auto !important;
|
height: auto !important;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
html[data-color-scheme="dark"] &,
|
||||||
|
body.o_dark & {
|
||||||
|
border-color: rgba(255, 255, 255, 0.12);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
.card-header {
|
.card-header {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
border-radius: 0.75rem 0.75rem 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fusion_chat_messages {
|
.fusion_chat_messages {
|
||||||
// Override base chat.scss values that break flex layout
|
|
||||||
max-height: none !important;
|
max-height: none !important;
|
||||||
min-height: 0 !important;
|
min-height: 0 !important;
|
||||||
// Grow to fill, but scrollable
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fusion_chat_input {
|
.fusion_chat_input {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
border-radius: 0 0 0.75rem 0.75rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// Health Cards — modern rounded design
|
||||||
|
// ================================================================
|
||||||
|
.fusion_health_cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fusion_health_card {
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
background: var(--o-view-background-color, #fff);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dark mode adjustments
|
||||||
|
html[data-color-scheme="dark"] &,
|
||||||
|
body.o_dark & {
|
||||||
|
border-color: rgba(255, 255, 255, 0.12);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fusion_card_icon {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fusion_card_title {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
color: var(--bs-secondary-color, #6c757d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fusion_card_metric {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
margin-bottom: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fusion_card_sub {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--bs-secondary-color, #6c757d);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status-based left border + icon colour
|
||||||
|
&.fusion_card_green {
|
||||||
|
border-left: 3px solid var(--bs-success);
|
||||||
|
.fusion_card_icon { background: rgba(var(--bs-success-rgb), 0.12); color: var(--bs-success); }
|
||||||
|
}
|
||||||
|
&.fusion_card_yellow {
|
||||||
|
border-left: 3px solid var(--bs-warning);
|
||||||
|
.fusion_card_icon { background: rgba(var(--bs-warning-rgb), 0.15); color: var(--bs-warning); }
|
||||||
|
}
|
||||||
|
&.fusion_card_red {
|
||||||
|
border-left: 3px solid var(--bs-danger);
|
||||||
|
.fusion_card_icon { background: rgba(var(--bs-danger-rgb), 0.12); color: var(--bs-danger); }
|
||||||
|
}
|
||||||
|
&.fusion_card_blue {
|
||||||
|
border-left: 3px solid var(--bs-info);
|
||||||
|
.fusion_card_icon { background: rgba(var(--bs-info-rgb), 0.12); color: var(--bs-info); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// Needs Attention panel
|
||||||
|
// ================================================================
|
||||||
|
.fusion_attention_panel {
|
||||||
|
background: var(--o-view-background-color, #fff);
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
min-height: 150px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
html[data-color-scheme="dark"] &,
|
||||||
|
body.o_dark & {
|
||||||
|
border-color: rgba(255, 255, 255, 0.12);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fusion_attention_item {
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
&:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-color-scheme="dark"] &,
|
||||||
|
body.o_dark & {
|
||||||
|
border-bottom-color: rgba(255, 255, 255, 0.06);
|
||||||
|
&:hover { background: rgba(255, 255, 255, 0.04); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fusion_attn_dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 5px;
|
||||||
|
|
||||||
|
&.fusion_attn_danger { background: var(--bs-danger); }
|
||||||
|
&.fusion_attn_warning { background: var(--bs-warning); }
|
||||||
|
&.fusion_attn_info { background: var(--bs-info); }
|
||||||
|
&.fusion_attn_muted { background: var(--bs-secondary); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also ensure the Odoo action container gives us full height
|
// Full height in Odoo's action container
|
||||||
.o_action_manager {
|
.o_action_manager {
|
||||||
.o_action.fusion_accounting_dashboard {
|
.o_action.fusion_accounting_dashboard {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -4,19 +4,21 @@
|
|||||||
<field name="name">fusion.accounting.match.history.list</field>
|
<field name="name">fusion.accounting.match.history.list</field>
|
||||||
<field name="model">fusion.accounting.match.history</field>
|
<field name="model">fusion.accounting.match.history</field>
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<list string="Match History">
|
<list string="Match History" default_order="proposed_at desc">
|
||||||
<field name="proposed_at"/>
|
<field name="proposed_at" string="Date"/>
|
||||||
<field name="tool_name"/>
|
<field name="session_id" string="Session"/>
|
||||||
|
<field name="tool_display_name" string="Tool"/>
|
||||||
|
<field name="tool_name" string="Tool (Code)" optional="hide"/>
|
||||||
<field name="decision" widget="badge"
|
<field name="decision" widget="badge"
|
||||||
decoration-success="decision == 'approved'"
|
decoration-success="decision in ('approved', 'auto')"
|
||||||
decoration-danger="decision == 'rejected'"
|
decoration-danger="decision == 'rejected'"
|
||||||
decoration-warning="decision == 'pending'"
|
decoration-warning="decision == 'pending'"/>
|
||||||
decoration-info="decision == 'auto'"/>
|
<field name="ai_confidence" string="Confidence" widget="progressbar"/>
|
||||||
<field name="ai_confidence" widget="progressbar"/>
|
<field name="amount" string="Amount"/>
|
||||||
<field name="amount"/>
|
<field name="partner_id" string="Partner"/>
|
||||||
<field name="partner_id"/>
|
<field name="ai_reasoning" string="Reasoning" optional="hide"/>
|
||||||
<field name="decided_by"/>
|
<field name="decided_by" string="Decided By" optional="hide"/>
|
||||||
<field name="decided_at"/>
|
<field name="decided_at" string="Decided At" optional="hide"/>
|
||||||
</list>
|
</list>
|
||||||
</field>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
@@ -33,32 +35,63 @@
|
|||||||
<button name="action_reject" string="Reject" type="object"
|
<button name="action_reject" string="Reject" type="object"
|
||||||
class="btn-danger" invisible="decision != 'pending'"
|
class="btn-danger" invisible="decision != 'pending'"
|
||||||
groups="fusion_accounting.group_fusion_accounting_manager"/>
|
groups="fusion_accounting.group_fusion_accounting_manager"/>
|
||||||
|
<field name="decision" widget="statusbar"
|
||||||
|
statusbar_visible="pending,approved,rejected,auto"/>
|
||||||
</header>
|
</header>
|
||||||
<sheet>
|
<sheet>
|
||||||
|
<div class="oe_title mb-3">
|
||||||
|
<h1>
|
||||||
|
<field name="tool_display_name" readonly="1"/>
|
||||||
|
</h1>
|
||||||
|
<div class="text-muted small">
|
||||||
|
Internal: <field name="tool_name" readonly="1" class="d-inline"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<group>
|
<group>
|
||||||
<group>
|
<group string="Request Details">
|
||||||
<field name="tool_name"/>
|
<field name="session_id"/>
|
||||||
<field name="decision"/>
|
<field name="proposed_at" string="When"/>
|
||||||
<field name="ai_confidence"/>
|
<field name="ai_confidence" widget="progressbar" string="Confidence"/>
|
||||||
<field name="amount"/>
|
<field name="amount"/>
|
||||||
<field name="partner_id"/>
|
<field name="partner_id"/>
|
||||||
|
<field name="rule_id" invisible="not rule_id"/>
|
||||||
</group>
|
</group>
|
||||||
<group>
|
<group string="Decision">
|
||||||
<field name="session_id"/>
|
|
||||||
<field name="rule_id"/>
|
|
||||||
<field name="proposed_at"/>
|
|
||||||
<field name="decided_at"/>
|
|
||||||
<field name="decided_by"/>
|
<field name="decided_by"/>
|
||||||
|
<field name="decided_at"/>
|
||||||
|
<field name="rejection_reason"
|
||||||
|
invisible="decision != 'rejected'"/>
|
||||||
</group>
|
</group>
|
||||||
</group>
|
</group>
|
||||||
<group string="AI Details">
|
|
||||||
<field name="ai_reasoning"/>
|
<notebook>
|
||||||
<field name="tool_params"/>
|
<page string="AI Reasoning" name="reasoning">
|
||||||
<field name="tool_result"/>
|
<field name="ai_reasoning" widget="text"
|
||||||
</group>
|
placeholder="No AI reasoning recorded for this tool call."
|
||||||
<group string="Correction" invisible="decision != 'rejected'">
|
nolabel="1"/>
|
||||||
<field name="rejection_reason"/>
|
</page>
|
||||||
<field name="correct_action"/>
|
<page string="Parameters" name="params">
|
||||||
|
<field name="tool_params_pretty" widget="text"
|
||||||
|
nolabel="1" readonly="1"/>
|
||||||
|
</page>
|
||||||
|
<page string="Result" name="result">
|
||||||
|
<field name="tool_result_pretty" widget="text"
|
||||||
|
nolabel="1" readonly="1"/>
|
||||||
|
</page>
|
||||||
|
<page string="Correction" name="correction"
|
||||||
|
invisible="decision != 'rejected'">
|
||||||
|
<group>
|
||||||
|
<field name="rejection_reason" string="Why was this rejected?"/>
|
||||||
|
<field name="correct_action" widget="text"
|
||||||
|
string="What should have been done instead?"/>
|
||||||
|
</group>
|
||||||
|
</page>
|
||||||
|
</notebook>
|
||||||
|
|
||||||
|
<group string="Linked Records" invisible="not bank_statement_line_id">
|
||||||
|
<field name="bank_statement_line_id"/>
|
||||||
|
<field name="move_line_ids" widget="many2many_tags"/>
|
||||||
</group>
|
</group>
|
||||||
</sheet>
|
</sheet>
|
||||||
</form>
|
</form>
|
||||||
@@ -72,13 +105,19 @@
|
|||||||
<search>
|
<search>
|
||||||
<field name="tool_name"/>
|
<field name="tool_name"/>
|
||||||
<field name="partner_id"/>
|
<field name="partner_id"/>
|
||||||
|
<field name="session_id"/>
|
||||||
<filter name="pending" string="Pending" domain="[('decision', '=', 'pending')]"/>
|
<filter name="pending" string="Pending" domain="[('decision', '=', 'pending')]"/>
|
||||||
<filter name="approved" string="Approved" domain="[('decision', '=', 'approved')]"/>
|
<filter name="approved" string="Approved" domain="[('decision', '=', 'approved')]"/>
|
||||||
<filter name="rejected" string="Rejected" domain="[('decision', '=', 'rejected')]"/>
|
<filter name="rejected" string="Rejected" domain="[('decision', '=', 'rejected')]"/>
|
||||||
|
<filter name="auto" string="Auto-Executed" domain="[('decision', '=', 'auto')]"/>
|
||||||
|
<separator/>
|
||||||
|
<filter name="today" string="Today" domain="[('proposed_at', '>=', (context_today()).strftime('%Y-%m-%d'))]"/>
|
||||||
|
<filter name="this_week" string="This Week" domain="[('proposed_at', '>=', (context_today() - datetime.timedelta(days=7)).strftime('%Y-%m-%d'))]"/>
|
||||||
<separator/>
|
<separator/>
|
||||||
<group>
|
<group>
|
||||||
<filter name="group_tool" string="Tool" domain="[]" context="{'group_by': 'tool_name'}"/>
|
<filter name="group_tool" string="Tool" domain="[]" context="{'group_by': 'tool_name'}"/>
|
||||||
<filter name="group_decision" string="Decision" domain="[]" context="{'group_by': 'decision'}"/>
|
<filter name="group_decision" string="Decision" domain="[]" context="{'group_by': 'decision'}"/>
|
||||||
|
<filter name="group_session" string="Session" domain="[]" context="{'group_by': 'session_id'}"/>
|
||||||
</group>
|
</group>
|
||||||
</search>
|
</search>
|
||||||
</field>
|
</field>
|
||||||
@@ -89,6 +128,7 @@
|
|||||||
<field name="res_model">fusion.accounting.match.history</field>
|
<field name="res_model">fusion.accounting.match.history</field>
|
||||||
<field name="view_mode">list,form</field>
|
<field name="view_mode">list,form</field>
|
||||||
<field name="search_view_id" ref="view_fusion_history_search"/>
|
<field name="search_view_id" ref="view_fusion_history_search"/>
|
||||||
|
<field name="context">{'search_default_today': 1}</field>
|
||||||
<field name="help" type="html">
|
<field name="help" type="html">
|
||||||
<p class="o_view_nocontent_smiling_face">No match history yet</p>
|
<p class="o_view_nocontent_smiling_face">No match history yet</p>
|
||||||
<p>AI tool calls and their outcomes will appear here.</p>
|
<p>AI tool calls and their outcomes will appear here.</p>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
'views/payment_poynt_templates.xml',
|
'views/payment_poynt_templates.xml',
|
||||||
'views/poynt_terminal_views.xml',
|
'views/poynt_terminal_views.xml',
|
||||||
'views/account_move_views.xml',
|
'views/account_move_views.xml',
|
||||||
|
'views/account_payment_views.xml',
|
||||||
'views/sale_order_views.xml',
|
'views/sale_order_views.xml',
|
||||||
'views/res_config_settings_views.xml',
|
'views/res_config_settings_views.xml',
|
||||||
'views/poynt_settlement_views.xml',
|
'views/poynt_settlement_views.xml',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
from . import account_move
|
from . import account_move
|
||||||
|
from . import account_payment
|
||||||
from . import payment_provider
|
from . import payment_provider
|
||||||
from . import payment_token
|
from . import payment_token
|
||||||
from . import payment_transaction
|
from . import payment_transaction
|
||||||
|
|||||||
42
fusion_poynt/models/account_payment.py
Normal file
42
fusion_poynt/models/account_payment.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from odoo import _, api, fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class AccountPayment(models.Model):
|
||||||
|
_inherit = 'account.payment'
|
||||||
|
|
||||||
|
poynt_settlement_line_ids = fields.One2many(
|
||||||
|
'poynt.settlement.line',
|
||||||
|
'existing_payment_id',
|
||||||
|
string="Settlement Lines",
|
||||||
|
)
|
||||||
|
poynt_settlement_count = fields.Integer(
|
||||||
|
string="Settlements",
|
||||||
|
compute='_compute_poynt_settlement_count',
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends('poynt_settlement_line_ids')
|
||||||
|
def _compute_poynt_settlement_count(self):
|
||||||
|
for payment in self:
|
||||||
|
payment.poynt_settlement_count = len(payment.poynt_settlement_line_ids)
|
||||||
|
|
||||||
|
def action_view_poynt_settlement(self):
|
||||||
|
"""Open the settlement batch linked to this payment."""
|
||||||
|
self.ensure_one()
|
||||||
|
batch_ids = self.poynt_settlement_line_ids.mapped('batch_id').ids
|
||||||
|
if len(batch_ids) == 1:
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'name': _("Settlement Batch"),
|
||||||
|
'res_model': 'poynt.settlement.batch',
|
||||||
|
'view_mode': 'form',
|
||||||
|
'res_id': batch_ids[0],
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'name': _("Settlement Batches"),
|
||||||
|
'res_model': 'poynt.settlement.batch',
|
||||||
|
'view_mode': 'list,form',
|
||||||
|
'domain': [('id', 'in', batch_ids)],
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import logging
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from odoo import _, api, fields, models
|
from odoo import _, api, fields, models
|
||||||
from odoo.exceptions import UserError, ValidationError
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -52,10 +52,10 @@ class PoyntSettlementBatch(models.Model):
|
|||||||
)
|
)
|
||||||
state = fields.Selection([
|
state = fields.Selection([
|
||||||
('draft', "Draft"),
|
('draft', "Draft"),
|
||||||
('matched', "Matched"),
|
('matched', "Matched to Deposit"),
|
||||||
('reconciled', "Reconciled"),
|
('reconciled', "Reconciled"),
|
||||||
('error', "Error"),
|
('error', "Error"),
|
||||||
], string="Status", required=True, default='draft', tracking=True)
|
], string="Status", required=True, default='draft')
|
||||||
|
|
||||||
currency_id = fields.Many2one(
|
currency_id = fields.Many2one(
|
||||||
'res.currency',
|
'res.currency',
|
||||||
@@ -93,10 +93,18 @@ class PoyntSettlementBatch(models.Model):
|
|||||||
store=True,
|
store=True,
|
||||||
)
|
)
|
||||||
matched_count = fields.Integer(
|
matched_count = fields.Integer(
|
||||||
string="Matched to Customers",
|
string="Matched to Existing Payments",
|
||||||
compute='_compute_totals',
|
compute='_compute_totals',
|
||||||
store=True,
|
store=True,
|
||||||
)
|
)
|
||||||
|
payment_count = fields.Integer(
|
||||||
|
string="Payments",
|
||||||
|
compute='_compute_smart_buttons',
|
||||||
|
)
|
||||||
|
invoice_count = fields.Integer(
|
||||||
|
string="Invoices",
|
||||||
|
compute='_compute_smart_buttons',
|
||||||
|
)
|
||||||
notes = fields.Text(string="Notes")
|
notes = fields.Text(string="Notes")
|
||||||
|
|
||||||
_sql_constraints = [
|
_sql_constraints = [
|
||||||
@@ -113,7 +121,7 @@ class PoyntSettlementBatch(models.Model):
|
|||||||
) or '/'
|
) or '/'
|
||||||
return super().create(vals_list)
|
return super().create(vals_list)
|
||||||
|
|
||||||
@api.depends('line_ids.amount', 'line_ids.action', 'line_ids.partner_id', 'elavon_deposit')
|
@api.depends('line_ids.amount', 'line_ids.action', 'line_ids.existing_payment_id', 'elavon_deposit')
|
||||||
def _compute_totals(self):
|
def _compute_totals(self):
|
||||||
for batch in self:
|
for batch in self:
|
||||||
sales = sum(
|
sales = sum(
|
||||||
@@ -127,7 +135,38 @@ class PoyntSettlementBatch(models.Model):
|
|||||||
batch.fee_amount = net - batch.elavon_deposit if batch.elavon_deposit else 0.0
|
batch.fee_amount = net - batch.elavon_deposit if batch.elavon_deposit else 0.0
|
||||||
batch.sale_count = len(batch.line_ids.filtered(lambda l: l.action == 'SALE'))
|
batch.sale_count = len(batch.line_ids.filtered(lambda l: l.action == 'SALE'))
|
||||||
batch.refund_count = len(batch.line_ids.filtered(lambda l: l.action == 'REFUND'))
|
batch.refund_count = len(batch.line_ids.filtered(lambda l: l.action == 'REFUND'))
|
||||||
batch.matched_count = len(batch.line_ids.filtered(lambda l: l.partner_id))
|
batch.matched_count = len(batch.line_ids.filtered(lambda l: l.existing_payment_id))
|
||||||
|
|
||||||
|
def _compute_smart_buttons(self):
|
||||||
|
for batch in self:
|
||||||
|
payments = batch.line_ids.mapped('existing_payment_id')
|
||||||
|
invoices = batch.line_ids.mapped('existing_invoice_id')
|
||||||
|
batch.payment_count = len(payments)
|
||||||
|
batch.invoice_count = len(invoices)
|
||||||
|
|
||||||
|
def action_view_payments(self):
|
||||||
|
"""Open linked payments in a list view."""
|
||||||
|
self.ensure_one()
|
||||||
|
payment_ids = self.line_ids.mapped('existing_payment_id').ids
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'name': _("Payments - %s", self.name),
|
||||||
|
'res_model': 'account.payment',
|
||||||
|
'view_mode': 'list,form',
|
||||||
|
'domain': [('id', 'in', payment_ids)],
|
||||||
|
}
|
||||||
|
|
||||||
|
def action_view_invoices(self):
|
||||||
|
"""Open linked invoices in a list view."""
|
||||||
|
self.ensure_one()
|
||||||
|
invoice_ids = self.line_ids.mapped('existing_invoice_id').ids
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'name': _("Invoices - %s", self.name),
|
||||||
|
'res_model': 'account.move',
|
||||||
|
'view_mode': 'list,form',
|
||||||
|
'domain': [('id', 'in', invoice_ids)],
|
||||||
|
}
|
||||||
|
|
||||||
# === BUSINESS METHODS === #
|
# === BUSINESS METHODS === #
|
||||||
|
|
||||||
@@ -165,7 +204,7 @@ class PoyntSettlementBatch(models.Model):
|
|||||||
|
|
||||||
card = txn.get('fundingSource', {}).get('card', {})
|
card = txn.get('fundingSource', {}).get('card', {})
|
||||||
|
|
||||||
# Convert ISO 8601 timestamp (2025-03-05T19:19:10Z) to Odoo format
|
# Convert ISO 8601 timestamp to Odoo format
|
||||||
created_at = txn.get('createdAt', '')
|
created_at = txn.get('createdAt', '')
|
||||||
if created_at:
|
if created_at:
|
||||||
created_at = created_at.replace('T', ' ').replace('Z', '')
|
created_at = created_at.replace('T', ' ').replace('Z', '')
|
||||||
@@ -198,90 +237,117 @@ class PoyntSettlementBatch(models.Model):
|
|||||||
if not self.line_ids:
|
if not self.line_ids:
|
||||||
raise UserError(_("No transaction lines to match. Fetch transactions first."))
|
raise UserError(_("No transaction lines to match. Fetch transactions first."))
|
||||||
|
|
||||||
# Look for Elavon deposit on the settlement date (or ±1 day for timing)
|
# Search for Elavon deposits near the settlement date
|
||||||
StmtLine = self.env['account.bank.statement.line']
|
# Use journal_id = 50 (Scotia Current) and SQL for the date
|
||||||
domain = [
|
# since date is a related field from account.move
|
||||||
('journal_id.name', 'ilike', 'Scotia'),
|
self.env.cr.execute("""
|
||||||
('date', '>=', self.settlement_date - timedelta(days=1)),
|
SELECT absl.id, am.date, absl.amount
|
||||||
('date', '<=', self.settlement_date + timedelta(days=1)),
|
FROM account_bank_statement_line absl
|
||||||
('amount', '>', 0),
|
JOIN account_move am ON am.id = absl.move_id
|
||||||
('payment_ref', 'ilike', 'ELAVON'),
|
WHERE absl.journal_id = 50
|
||||||
('is_reconciled', '=', False),
|
AND am.date >= %s
|
||||||
]
|
AND am.date <= %s
|
||||||
candidates = StmtLine.search(domain, order='date asc')
|
AND absl.amount > 0
|
||||||
|
AND absl.payment_ref ILIKE '%%ELAVON%%'
|
||||||
|
ORDER BY am.date
|
||||||
|
""", [
|
||||||
|
self.settlement_date - timedelta(days=1),
|
||||||
|
self.settlement_date + timedelta(days=1),
|
||||||
|
])
|
||||||
|
rows = self.env.cr.fetchall()
|
||||||
|
|
||||||
if not candidates:
|
if not rows:
|
||||||
self.notes = f"No unreconciled Elavon deposit found near {self.settlement_date}"
|
self.write({
|
||||||
return False
|
'notes': f"No Elavon deposit found near {self.settlement_date}",
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.client',
|
||||||
|
'tag': 'display_notification',
|
||||||
|
'params': {
|
||||||
|
'message': _("No Elavon deposit found near %s", self.settlement_date),
|
||||||
|
'type': 'warning',
|
||||||
|
'sticky': False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
# Try to find the closest match by amount
|
|
||||||
net_amount = self.poynt_total
|
net_amount = self.poynt_total
|
||||||
best_match = None
|
best_match = None
|
||||||
best_diff = float('inf')
|
best_diff = float('inf')
|
||||||
|
|
||||||
for line in candidates:
|
for row_id, row_date, row_amount in rows:
|
||||||
diff = abs(line.amount - net_amount)
|
diff = abs(float(row_amount) - net_amount)
|
||||||
# Allow up to 5% tolerance for processing fees
|
# Allow up to 5% tolerance for processing fees
|
||||||
if diff < best_diff and diff <= net_amount * 0.05:
|
if diff < best_diff and (net_amount == 0 or diff <= abs(net_amount) * 0.05):
|
||||||
best_diff = diff
|
best_diff = diff
|
||||||
best_match = line
|
best_match = (row_id, row_date, float(row_amount))
|
||||||
|
|
||||||
if best_match:
|
if best_match:
|
||||||
self.write({
|
self.write({
|
||||||
'bank_statement_line_id': best_match.id,
|
'bank_statement_line_id': best_match[0],
|
||||||
'elavon_deposit': best_match.amount,
|
'elavon_deposit': best_match[2],
|
||||||
'settlement_date': best_match.date,
|
'settlement_date': best_match[1],
|
||||||
'state': 'matched',
|
'state': 'matched',
|
||||||
})
|
})
|
||||||
_logger.info(
|
_logger.info(
|
||||||
"Poynt batch %s matched to bank line %s (deposit $%.2f, fees $%.2f)",
|
"Poynt batch %s matched to bank line %s (deposit $%.2f, fees $%.2f)",
|
||||||
self.name, best_match.id, best_match.amount, self.fee_amount,
|
self.name, best_match[0], best_match[2], self.fee_amount,
|
||||||
)
|
)
|
||||||
return True
|
return {
|
||||||
|
'type': 'ir.actions.client',
|
||||||
|
'tag': 'display_notification',
|
||||||
|
'params': {
|
||||||
|
'message': _("Matched to Elavon deposit of $%(amount).2f (fees: $%(fees).2f)",
|
||||||
|
amount=best_match[2], fees=self.fee_amount),
|
||||||
|
'type': 'success',
|
||||||
|
'sticky': False,
|
||||||
|
},
|
||||||
|
}
|
||||||
else:
|
else:
|
||||||
self.notes = (
|
closest = min(rows, key=lambda r: abs(float(r[2]) - net_amount))
|
||||||
f"No matching Elavon deposit found. "
|
self.write({
|
||||||
f"Poynt net: ${net_amount:.2f}, "
|
'notes': (
|
||||||
f"closest candidate: ${candidates[0].amount:.2f}"
|
f"No matching deposit. "
|
||||||
)
|
f"Poynt net: ${net_amount:.2f}, "
|
||||||
return False
|
f"closest: ${float(closest[2]):.2f} on {closest[1]}"
|
||||||
|
),
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.client',
|
||||||
|
'tag': 'display_notification',
|
||||||
|
'params': {
|
||||||
|
'message': _(
|
||||||
|
"No matching deposit found. Poynt net: $%(net).2f, "
|
||||||
|
"closest deposit: $%(closest).2f",
|
||||||
|
net=net_amount, closest=float(closest[2]),
|
||||||
|
),
|
||||||
|
'type': 'warning',
|
||||||
|
'sticky': False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
def action_match_customers(self):
|
def action_match_existing_payments(self):
|
||||||
"""Attempt to match settlement lines to Odoo customers and invoices."""
|
"""Match settlement lines to EXISTING payments already recorded by staff.
|
||||||
|
|
||||||
|
This does NOT create new payments. Staff already record payments when
|
||||||
|
customers pay at the terminal. This method links the Poynt transaction
|
||||||
|
to that existing payment for audit/reconciliation purposes.
|
||||||
|
"""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
matched = 0
|
matched = 0
|
||||||
for line in self.line_ids.filtered(lambda l: not l.partner_id and l.action == 'SALE'):
|
for line in self.line_ids.filtered(lambda l: not l.existing_payment_id and l.action == 'SALE'):
|
||||||
if line._match_to_customer():
|
if line._match_to_existing_payment():
|
||||||
matched += 1
|
matched += 1
|
||||||
|
|
||||||
_logger.info(
|
_logger.info(
|
||||||
"Poynt batch %s: matched %d/%d lines to customers",
|
"Poynt batch %s: matched %d/%d lines to existing payments",
|
||||||
self.name, matched, len(self.line_ids),
|
self.name, matched, len(self.line_ids.filtered(lambda l: l.action == 'SALE')),
|
||||||
)
|
|
||||||
return True
|
|
||||||
|
|
||||||
def action_create_payments(self):
|
|
||||||
"""Create account.payment records for matched settlement lines."""
|
|
||||||
self.ensure_one()
|
|
||||||
if self.state == 'reconciled':
|
|
||||||
raise UserError(_("This batch is already reconciled."))
|
|
||||||
|
|
||||||
payable_lines = self.line_ids.filtered(
|
|
||||||
lambda l: l.partner_id and l.action == 'SALE' and l.state in ('fetched', 'matched') and not l.payment_id
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if not payable_lines:
|
# Check if all SALE lines are matched
|
||||||
raise UserError(_("No matched lines available for payment creation."))
|
unmatched = self.line_ids.filtered(
|
||||||
|
lambda l: l.action == 'SALE' and not l.existing_payment_id and l.state != 'no_match'
|
||||||
for line in payable_lines:
|
|
||||||
line._create_customer_payment()
|
|
||||||
|
|
||||||
# Check if all lines are processed
|
|
||||||
all_paid = all(
|
|
||||||
l.state in ('paid', 'error') or l.action == 'REFUND'
|
|
||||||
for l in self.line_ids
|
|
||||||
)
|
)
|
||||||
if all_paid:
|
if not unmatched and self.state == 'matched':
|
||||||
self.state = 'reconciled'
|
self.state = 'reconciled'
|
||||||
|
|
||||||
return True
|
return True
|
||||||
@@ -318,10 +384,10 @@ class PoyntSettlementBatch(models.Model):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Handle weekend: if today is Monday, fetch Fri+Sat+Sun
|
# Handle weekend: if today is Monday, fetch Fri+Sat+Sun
|
||||||
weekday = yesterday.weekday() # 0=Monday, 6=Sunday
|
weekday = yesterday.weekday()
|
||||||
if weekday == 6: # Sunday → fetch Fri-Sun, deposit Monday
|
if weekday == 6: # Sunday → fetch Fri-Sun
|
||||||
txn_date_from = yesterday - timedelta(days=2) # Friday
|
txn_date_from = yesterday - timedelta(days=2)
|
||||||
elif weekday == 5: # Saturday → skip, will be batched with Sunday
|
elif weekday == 5: # Saturday → skip
|
||||||
_logger.info("Poynt settlement cron: Saturday — will batch with Sunday/Monday.")
|
_logger.info("Poynt settlement cron: Saturday — will batch with Sunday/Monday.")
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
@@ -334,7 +400,6 @@ class PoyntSettlementBatch(models.Model):
|
|||||||
})
|
})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Fetch all transactions for the date range
|
|
||||||
transactions = provider._poynt_fetch_settlement_transactions(
|
transactions = provider._poynt_fetch_settlement_transactions(
|
||||||
txn_date_from, yesterday,
|
txn_date_from, yesterday,
|
||||||
)
|
)
|
||||||
@@ -360,7 +425,6 @@ class PoyntSettlementBatch(models.Model):
|
|||||||
amount = amounts.get('transactionAmount', 0) / 100.0
|
amount = amounts.get('transactionAmount', 0) / 100.0
|
||||||
card = txn.get('fundingSource', {}).get('card', {})
|
card = txn.get('fundingSource', {}).get('card', {})
|
||||||
|
|
||||||
# Convert ISO 8601 timestamp to Odoo format
|
|
||||||
created_at = txn.get('createdAt', '')
|
created_at = txn.get('createdAt', '')
|
||||||
if created_at:
|
if created_at:
|
||||||
created_at = created_at.replace('T', ' ').replace('Z', '')
|
created_at = created_at.replace('T', ' ').replace('Z', '')
|
||||||
@@ -384,8 +448,8 @@ class PoyntSettlementBatch(models.Model):
|
|||||||
# Try to match to bank deposit
|
# Try to match to bank deposit
|
||||||
batch.action_match_deposit()
|
batch.action_match_deposit()
|
||||||
|
|
||||||
# Try to match customers
|
# Try to match to existing payments (NOT create new ones)
|
||||||
batch.action_match_customers()
|
batch.action_match_existing_payments()
|
||||||
|
|
||||||
_logger.info(
|
_logger.info(
|
||||||
"Poynt settlement cron: created batch %s with %d lines for %s→%s",
|
"Poynt settlement cron: created batch %s with %d lines for %s→%s",
|
||||||
@@ -427,23 +491,28 @@ class PoyntSettlementLine(models.Model):
|
|||||||
card_brand = fields.Char(string="Card Brand")
|
card_brand = fields.Char(string="Card Brand")
|
||||||
card_last4 = fields.Char(string="Card Last 4", size=4)
|
card_last4 = fields.Char(string="Card Last 4", size=4)
|
||||||
card_holder_name = fields.Char(string="Cardholder Name")
|
card_holder_name = fields.Char(string="Cardholder Name")
|
||||||
|
|
||||||
|
# Links to EXISTING records (staff-created, not settlement-created)
|
||||||
|
existing_payment_id = fields.Many2one(
|
||||||
|
'account.payment',
|
||||||
|
string="Existing Payment",
|
||||||
|
readonly=True,
|
||||||
|
ondelete='set null',
|
||||||
|
help="The payment already recorded by staff for this transaction.",
|
||||||
|
)
|
||||||
|
existing_invoice_id = fields.Many2one(
|
||||||
|
'account.move',
|
||||||
|
string="Linked Invoice",
|
||||||
|
domain="[('move_type', '=', 'out_invoice')]",
|
||||||
|
ondelete='set null',
|
||||||
|
help="The invoice this payment was applied to.",
|
||||||
|
)
|
||||||
partner_id = fields.Many2one(
|
partner_id = fields.Many2one(
|
||||||
'res.partner',
|
'res.partner',
|
||||||
string="Customer",
|
string="Customer",
|
||||||
ondelete='set null',
|
ondelete='set null',
|
||||||
)
|
)
|
||||||
invoice_id = fields.Many2one(
|
|
||||||
'account.move',
|
|
||||||
string="Matched Invoice",
|
|
||||||
domain="[('move_type', '=', 'out_invoice')]",
|
|
||||||
ondelete='set null',
|
|
||||||
)
|
|
||||||
payment_id = fields.Many2one(
|
|
||||||
'account.payment',
|
|
||||||
string="Payment",
|
|
||||||
readonly=True,
|
|
||||||
ondelete='set null',
|
|
||||||
)
|
|
||||||
action = fields.Selection([
|
action = fields.Selection([
|
||||||
('SALE', "Sale"),
|
('SALE', "Sale"),
|
||||||
('REFUND', "Refund"),
|
('REFUND', "Refund"),
|
||||||
@@ -451,13 +520,13 @@ class PoyntSettlementLine(models.Model):
|
|||||||
], string="Action", required=True)
|
], string="Action", required=True)
|
||||||
state = fields.Selection([
|
state = fields.Selection([
|
||||||
('fetched', "Fetched"),
|
('fetched', "Fetched"),
|
||||||
('matched', "Matched"),
|
('matched', "Matched to Payment"),
|
||||||
('paid', "Payment Created"),
|
('no_match', "No Existing Payment"),
|
||||||
('error', "Error"),
|
('error', "Error"),
|
||||||
], string="Status", required=True, default='fetched')
|
], string="Status", required=True, default='fetched')
|
||||||
match_method = fields.Char(
|
match_method = fields.Char(
|
||||||
string="Match Method",
|
string="Match Method",
|
||||||
help="How this line was matched to a customer (e.g., 'odoo_txn', 'card_token', 'invoice_amount', 'name').",
|
help="How this line was matched to an existing payment.",
|
||||||
)
|
)
|
||||||
notes = fields.Text(string="Notes")
|
notes = fields.Text(string="Notes")
|
||||||
|
|
||||||
@@ -466,167 +535,131 @@ class PoyntSettlementLine(models.Model):
|
|||||||
'This Poynt transaction has already been recorded.'),
|
'This Poynt transaction has already been recorded.'),
|
||||||
]
|
]
|
||||||
|
|
||||||
# === CUSTOMER MATCHING === #
|
# === MATCH TO EXISTING PAYMENTS === #
|
||||||
|
|
||||||
def _match_to_customer(self):
|
def _match_to_existing_payment(self):
|
||||||
"""Attempt to match this settlement line to an Odoo customer/invoice.
|
"""Match this Poynt transaction to an existing payment already in Odoo.
|
||||||
|
|
||||||
|
Staff record payments when customers pay at the terminal. This method
|
||||||
|
finds that existing payment — it does NOT create a new one.
|
||||||
|
|
||||||
Matching strategy (in priority order):
|
Matching strategy (in priority order):
|
||||||
1. Check poynt_transaction_id in payment.transaction (direct Odoo payment)
|
1. Poynt transaction ID in payment.transaction (direct Odoo integration)
|
||||||
2. Match by card_last4 against payment.token records
|
2. Poynt transaction UUID found in payment memo field
|
||||||
3. Match by amount against open invoices within ±2 days
|
3. Exact amount + cardholder name match on same date (±2 days)
|
||||||
4. Match by card_holder_name fuzzy search against res.partner
|
4. Exact amount match on same date (±2 days)
|
||||||
|
|
||||||
:return: True if matched, False otherwise.
|
:return: True if matched, False otherwise.
|
||||||
"""
|
"""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
if self.partner_id:
|
if self.existing_payment_id:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Strategy 1: Direct Odoo payment transaction
|
# Strategy 1: Direct Odoo payment transaction (Poynt-integrated payments)
|
||||||
PaymentTxn = self.env['payment.transaction']
|
PaymentTxn = self.env['payment.transaction']
|
||||||
odoo_txn = PaymentTxn.search([
|
odoo_txn = PaymentTxn.search([
|
||||||
('poynt_transaction_id', '=', self.poynt_transaction_id),
|
('poynt_transaction_id', '=', self.poynt_transaction_id),
|
||||||
], limit=1)
|
], limit=1)
|
||||||
if odoo_txn and odoo_txn.partner_id:
|
if odoo_txn and odoo_txn.payment_id:
|
||||||
self.write({
|
self.write({
|
||||||
|
'existing_payment_id': odoo_txn.payment_id.id,
|
||||||
'partner_id': odoo_txn.partner_id.id,
|
'partner_id': odoo_txn.partner_id.id,
|
||||||
'invoice_id': odoo_txn.invoice_ids[:1].id if odoo_txn.invoice_ids else False,
|
'existing_invoice_id': odoo_txn.invoice_ids[:1].id if odoo_txn.invoice_ids else False,
|
||||||
'match_method': 'odoo_txn',
|
'match_method': 'poynt_txn',
|
||||||
'state': 'matched',
|
'state': 'matched',
|
||||||
})
|
})
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Strategy 2: Card token match
|
# Strategy 2: Poynt transaction UUID in payment memo field
|
||||||
if self.card_last4:
|
# Staff sometimes record the UUID when entering payments manually
|
||||||
token = self.env['payment.token'].search([
|
if self.poynt_transaction_id:
|
||||||
('payment_details', 'ilike', self.card_last4),
|
memo_match = self.env['account.payment'].search([
|
||||||
('provider_id.code', '=', 'poynt'),
|
('memo', 'ilike', self.poynt_transaction_id),
|
||||||
|
('payment_type', '=', 'inbound'),
|
||||||
|
('state', 'in', ('posted', 'in_process')),
|
||||||
], limit=1)
|
], limit=1)
|
||||||
if token and token.partner_id:
|
if memo_match:
|
||||||
self.write({
|
self.write({
|
||||||
'partner_id': token.partner_id.id,
|
'existing_payment_id': memo_match.id,
|
||||||
'match_method': 'card_token',
|
'partner_id': memo_match.partner_id.id if memo_match.partner_id else False,
|
||||||
'state': 'matched',
|
'existing_invoice_id': self._find_invoice_for_payment(memo_match),
|
||||||
})
|
'match_method': 'memo_uuid',
|
||||||
# Try to find matching invoice
|
|
||||||
self._match_invoice()
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Strategy 3: Amount match against open invoices
|
|
||||||
if self.amount and self.transaction_date:
|
|
||||||
date = self.transaction_date.date() if self.transaction_date else fields.Date.today()
|
|
||||||
invoices = self.env['account.move'].search([
|
|
||||||
('move_type', '=', 'out_invoice'),
|
|
||||||
('state', '=', 'posted'),
|
|
||||||
('payment_state', 'in', ('not_paid', 'partial')),
|
|
||||||
('amount_residual', '=', self.amount),
|
|
||||||
('invoice_date', '>=', date - timedelta(days=7)),
|
|
||||||
('invoice_date', '<=', date + timedelta(days=2)),
|
|
||||||
], limit=1)
|
|
||||||
if invoices:
|
|
||||||
self.write({
|
|
||||||
'partner_id': invoices.partner_id.id,
|
|
||||||
'invoice_id': invoices.id,
|
|
||||||
'match_method': 'invoice_amount',
|
|
||||||
'state': 'matched',
|
'state': 'matched',
|
||||||
})
|
})
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Strategy 4: Cardholder name fuzzy match
|
# Determine the date range for searching
|
||||||
if self.card_holder_name:
|
if self.transaction_date:
|
||||||
name = self.card_holder_name.strip()
|
txn_date = self.transaction_date.date()
|
||||||
if len(name) >= 3:
|
else:
|
||||||
partners = self.env['res.partner'].search([
|
txn_date = self.batch_id.transaction_date
|
||||||
'|',
|
date_from = txn_date - timedelta(days=2)
|
||||||
('name', 'ilike', name),
|
date_to = txn_date + timedelta(days=2)
|
||||||
('name', 'ilike', name.split()[-1] if ' ' in name else name),
|
|
||||||
], limit=5)
|
|
||||||
if len(partners) == 1:
|
|
||||||
self.write({
|
|
||||||
'partner_id': partners.id,
|
|
||||||
'match_method': 'name',
|
|
||||||
'state': 'matched',
|
|
||||||
})
|
|
||||||
self._match_invoice()
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
# Strategy 3: Exact amount + same date range on account.payment
|
||||||
|
# These are payments staff manually recorded
|
||||||
|
payments = self.env['account.payment'].search([
|
||||||
|
('amount', '=', self.amount),
|
||||||
|
('payment_type', '=', 'inbound'),
|
||||||
|
('date', '>=', date_from),
|
||||||
|
('date', '<=', date_to),
|
||||||
|
('state', 'in', ('posted', 'in_process')),
|
||||||
|
# Exclude payments already matched to other settlement lines
|
||||||
|
('id', 'not in', self._get_already_matched_payment_ids()),
|
||||||
|
], order='date asc')
|
||||||
|
|
||||||
|
if payments:
|
||||||
|
# Prefer one with a partner that matches cardholder name
|
||||||
|
if self.card_holder_name:
|
||||||
|
name = self.card_holder_name.strip()
|
||||||
|
for pay in payments:
|
||||||
|
if pay.partner_id and name.lower() in (pay.partner_id.name or '').lower():
|
||||||
|
self.write({
|
||||||
|
'existing_payment_id': pay.id,
|
||||||
|
'partner_id': pay.partner_id.id,
|
||||||
|
'existing_invoice_id': self._find_invoice_for_payment(pay),
|
||||||
|
'match_method': 'amount_name',
|
||||||
|
'state': 'matched',
|
||||||
|
})
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Fall back to first matching payment
|
||||||
|
pay = payments[0]
|
||||||
|
self.write({
|
||||||
|
'existing_payment_id': pay.id,
|
||||||
|
'partner_id': pay.partner_id.id if pay.partner_id else False,
|
||||||
|
'existing_invoice_id': self._find_invoice_for_payment(pay),
|
||||||
|
'match_method': 'amount_date',
|
||||||
|
'state': 'matched',
|
||||||
|
})
|
||||||
|
return True
|
||||||
|
|
||||||
|
# No existing payment found — mark for review
|
||||||
|
self.write({
|
||||||
|
'state': 'no_match',
|
||||||
|
'notes': f"No existing payment found for ${self.amount:.2f} near {txn_date}",
|
||||||
|
})
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _match_invoice(self):
|
def _get_already_matched_payment_ids(self):
|
||||||
"""Try to find a matching open invoice for this line's partner and amount."""
|
"""Get payment IDs already matched to other lines in this batch."""
|
||||||
self.ensure_one()
|
return self.batch_id.line_ids.filtered(
|
||||||
if self.invoice_id or not self.partner_id:
|
lambda l: l.existing_payment_id and l.id != self.id
|
||||||
return
|
).mapped('existing_payment_id').ids
|
||||||
|
|
||||||
invoices = self.env['account.move'].search([
|
def _find_invoice_for_payment(self, payment):
|
||||||
('partner_id', '=', self.partner_id.id),
|
"""Find the invoice that a payment was applied to."""
|
||||||
('move_type', '=', 'out_invoice'),
|
if not payment.partner_id:
|
||||||
('state', '=', 'posted'),
|
|
||||||
('payment_state', 'in', ('not_paid', 'partial')),
|
|
||||||
('amount_residual', '=', self.amount),
|
|
||||||
], limit=1, order='invoice_date desc')
|
|
||||||
if invoices:
|
|
||||||
self.invoice_id = invoices.id
|
|
||||||
|
|
||||||
# === PAYMENT CREATION === #
|
|
||||||
|
|
||||||
def _create_customer_payment(self):
|
|
||||||
"""Create an account.payment for this matched settlement line."""
|
|
||||||
self.ensure_one()
|
|
||||||
if not self.partner_id:
|
|
||||||
self.write({'state': 'error', 'notes': 'No customer matched'})
|
|
||||||
return False
|
return False
|
||||||
if self.payment_id:
|
|
||||||
return True
|
|
||||||
|
|
||||||
try:
|
# Check reconciled invoices via the payment's move lines
|
||||||
# Use the provider's journal (Poynt payment journal)
|
receivable_lines = payment.move_id.line_ids.filtered(
|
||||||
journal = self.batch_id.provider_id.journal_id
|
lambda l: l.account_id.account_type == 'asset_receivable' and l.reconciled
|
||||||
if not journal:
|
)
|
||||||
# Fall back to first bank journal
|
for line in receivable_lines:
|
||||||
journal = self.env['account.journal'].search([
|
for partial in (line.matched_debit_ids | line.matched_credit_ids):
|
||||||
('type', '=', 'bank'),
|
counterpart = partial.debit_move_id if partial.credit_move_id == line else partial.credit_move_id
|
||||||
('company_id', '=', self.env.company.id),
|
if counterpart.move_id.move_type == 'out_invoice':
|
||||||
], limit=1)
|
return counterpart.move_id.id
|
||||||
|
|
||||||
payment_vals = {
|
return False
|
||||||
'partner_id': self.partner_id.id,
|
|
||||||
'amount': self.amount,
|
|
||||||
'currency_id': self.currency_id.id,
|
|
||||||
'journal_id': journal.id,
|
|
||||||
'payment_type': 'inbound',
|
|
||||||
'partner_type': 'customer',
|
|
||||||
'payment_method_line_id': journal.inbound_payment_method_line_ids[:1].id,
|
|
||||||
'memo': f"Poynt {self.card_brand or 'Card'} ****{self.card_last4 or '????'} - {self.batch_id.name}",
|
|
||||||
}
|
|
||||||
|
|
||||||
payment = self.env['account.payment'].create(payment_vals)
|
|
||||||
payment.action_post()
|
|
||||||
|
|
||||||
self.write({
|
|
||||||
'payment_id': payment.id,
|
|
||||||
'state': 'paid',
|
|
||||||
})
|
|
||||||
|
|
||||||
# Reconcile with invoice if matched
|
|
||||||
if self.invoice_id and self.invoice_id.payment_state in ('not_paid', 'partial'):
|
|
||||||
try:
|
|
||||||
(payment.move_id.line_ids + self.invoice_id.line_ids).filtered(
|
|
||||||
lambda l: l.account_id.account_type == 'asset_receivable' and not l.reconciled
|
|
||||||
).reconcile()
|
|
||||||
except Exception as e:
|
|
||||||
_logger.warning(
|
|
||||||
"Could not auto-reconcile payment %s with invoice %s: %s",
|
|
||||||
payment.name, self.invoice_id.name, e,
|
|
||||||
)
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.write({'state': 'error', 'notes': str(e)})
|
|
||||||
_logger.error(
|
|
||||||
"Failed to create payment for settlement line %s: %s",
|
|
||||||
self.poynt_transaction_id, e,
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|||||||
19
fusion_poynt/views/account_payment_views.xml
Normal file
19
fusion_poynt/views/account_payment_views.xml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="view_account_payment_form_inherit_poynt_settlement" model="ir.ui.view">
|
||||||
|
<field name="name">account.payment.form.inherit.poynt.settlement</field>
|
||||||
|
<field name="model">account.payment</field>
|
||||||
|
<field name="inherit_id" ref="account.view_account_payment_form"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//div[@name='button_box']" position="inside">
|
||||||
|
<button name="action_view_poynt_settlement" type="object"
|
||||||
|
class="oe_stat_button" icon="fa-credit-card"
|
||||||
|
invisible="poynt_settlement_count == 0">
|
||||||
|
<field name="poynt_settlement_count" string="Settlement" widget="statinfo"/>
|
||||||
|
</button>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -40,19 +40,27 @@
|
|||||||
<button name="action_match_deposit" type="object"
|
<button name="action_match_deposit" type="object"
|
||||||
string="Match Bank Deposit" class="btn-primary"
|
string="Match Bank Deposit" class="btn-primary"
|
||||||
invisible="state != 'draft' or not line_ids"/>
|
invisible="state != 'draft' or not line_ids"/>
|
||||||
<button name="action_match_customers" type="object"
|
<button name="action_match_existing_payments" type="object"
|
||||||
string="Match Customers" class="btn-secondary"
|
string="Match Existing Payments" class="btn-secondary"
|
||||||
invisible="state not in ('draft', 'matched')"/>
|
invisible="state not in ('draft', 'matched')"/>
|
||||||
<button name="action_create_payments" type="object"
|
|
||||||
string="Create Payments" class="btn-primary"
|
|
||||||
invisible="state not in ('matched',)"
|
|
||||||
confirm="This will create customer payment records for all matched lines. Continue?"/>
|
|
||||||
<button name="action_reset_to_draft" type="object"
|
<button name="action_reset_to_draft" type="object"
|
||||||
string="Reset to Draft" class="btn-secondary"
|
string="Reset to Draft" class="btn-secondary"
|
||||||
invisible="state in ('draft', 'reconciled')"/>
|
invisible="state in ('draft', 'reconciled')"/>
|
||||||
<field name="state" widget="statusbar" statusbar_visible="draft,matched,reconciled"/>
|
<field name="state" widget="statusbar" statusbar_visible="draft,matched,reconciled"/>
|
||||||
</header>
|
</header>
|
||||||
<sheet>
|
<sheet>
|
||||||
|
<div class="oe_button_box" name="button_box">
|
||||||
|
<button name="action_view_payments" type="object"
|
||||||
|
class="oe_stat_button" icon="fa-money"
|
||||||
|
invisible="payment_count == 0">
|
||||||
|
<field name="payment_count" string="Payments" widget="statinfo"/>
|
||||||
|
</button>
|
||||||
|
<button name="action_view_invoices" type="object"
|
||||||
|
class="oe_stat_button" icon="fa-pencil-square-o"
|
||||||
|
invisible="invoice_count == 0">
|
||||||
|
<field name="invoice_count" string="Invoices" widget="statinfo"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div class="oe_title">
|
<div class="oe_title">
|
||||||
<h1>
|
<h1>
|
||||||
<field name="name" readonly="1"/>
|
<field name="name" readonly="1"/>
|
||||||
@@ -83,8 +91,8 @@
|
|||||||
<page string="Transaction Lines" name="lines">
|
<page string="Transaction Lines" name="lines">
|
||||||
<field name="line_ids">
|
<field name="line_ids">
|
||||||
<list editable="bottom"
|
<list editable="bottom"
|
||||||
decoration-success="state == 'paid'"
|
decoration-success="state == 'matched'"
|
||||||
decoration-warning="state == 'matched'"
|
decoration-muted="state == 'no_match'"
|
||||||
decoration-danger="state == 'error'">
|
decoration-danger="state == 'error'">
|
||||||
<field name="transaction_date"/>
|
<field name="transaction_date"/>
|
||||||
<field name="action"/>
|
<field name="action"/>
|
||||||
@@ -93,12 +101,12 @@
|
|||||||
<field name="card_last4"/>
|
<field name="card_last4"/>
|
||||||
<field name="card_holder_name"/>
|
<field name="card_holder_name"/>
|
||||||
<field name="partner_id"/>
|
<field name="partner_id"/>
|
||||||
<field name="invoice_id"/>
|
<field name="existing_payment_id" string="Staff Payment"/>
|
||||||
<field name="payment_id"/>
|
<field name="existing_invoice_id" string="Invoice"/>
|
||||||
<field name="match_method"/>
|
<field name="match_method"/>
|
||||||
<field name="state" widget="badge"
|
<field name="state" widget="badge"
|
||||||
decoration-success="state == 'paid'"
|
decoration-success="state == 'matched'"
|
||||||
decoration-warning="state == 'matched'"
|
decoration-muted="state == 'no_match'"
|
||||||
decoration-danger="state == 'error'"/>
|
decoration-danger="state == 'error'"/>
|
||||||
<field name="currency_id" column_invisible="1"/>
|
<field name="currency_id" column_invisible="1"/>
|
||||||
</list>
|
</list>
|
||||||
@@ -158,7 +166,7 @@
|
|||||||
<field name="name">poynt.settlement.line.list</field>
|
<field name="name">poynt.settlement.line.list</field>
|
||||||
<field name="model">poynt.settlement.line</field>
|
<field name="model">poynt.settlement.line</field>
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<list decoration-success="state == 'paid'" decoration-warning="state == 'matched'" decoration-danger="state == 'error'">
|
<list decoration-success="state == 'matched'" decoration-muted="state == 'no_match'" decoration-danger="state == 'error'">
|
||||||
<field name="batch_id"/>
|
<field name="batch_id"/>
|
||||||
<field name="transaction_date"/>
|
<field name="transaction_date"/>
|
||||||
<field name="action"/>
|
<field name="action"/>
|
||||||
@@ -167,12 +175,12 @@
|
|||||||
<field name="card_last4"/>
|
<field name="card_last4"/>
|
||||||
<field name="card_holder_name"/>
|
<field name="card_holder_name"/>
|
||||||
<field name="partner_id"/>
|
<field name="partner_id"/>
|
||||||
<field name="invoice_id"/>
|
<field name="existing_payment_id" string="Staff Payment"/>
|
||||||
<field name="payment_id"/>
|
<field name="existing_invoice_id" string="Invoice"/>
|
||||||
<field name="match_method"/>
|
<field name="match_method"/>
|
||||||
<field name="state" widget="badge"
|
<field name="state" widget="badge"
|
||||||
decoration-success="state == 'paid'"
|
decoration-success="state == 'matched'"
|
||||||
decoration-warning="state == 'matched'"
|
decoration-muted="state == 'no_match'"
|
||||||
decoration-danger="state == 'error'"/>
|
decoration-danger="state == 'error'"/>
|
||||||
</list>
|
</list>
|
||||||
</field>
|
</field>
|
||||||
@@ -188,9 +196,9 @@
|
|||||||
<field name="card_last4"/>
|
<field name="card_last4"/>
|
||||||
<field name="partner_id"/>
|
<field name="partner_id"/>
|
||||||
<field name="poynt_transaction_id"/>
|
<field name="poynt_transaction_id"/>
|
||||||
<filter name="filter_unmatched" string="Unmatched" domain="[('partner_id', '=', False), ('action', '=', 'SALE')]"/>
|
<filter name="filter_unmatched" string="No Payment Found" domain="[('state', '=', 'no_match')]"/>
|
||||||
<filter name="filter_matched" string="Matched" domain="[('state', '=', 'matched')]"/>
|
<filter name="filter_matched" string="Matched to Payment" domain="[('state', '=', 'matched')]"/>
|
||||||
<filter name="filter_paid" string="Paid" domain="[('state', '=', 'paid')]"/>
|
<filter name="filter_fetched" string="Pending Match" domain="[('state', '=', 'fetched')]"/>
|
||||||
<filter name="filter_errors" string="Errors" domain="[('state', '=', 'error')]"/>
|
<filter name="filter_errors" string="Errors" domain="[('state', '=', 'error')]"/>
|
||||||
<separator/>
|
<separator/>
|
||||||
<filter name="group_batch" string="Batch" context="{'group_by': 'batch_id'}" domain="[]"/>
|
<filter name="group_batch" string="Batch" context="{'group_by': 'batch_id'}" domain="[]"/>
|
||||||
|
|||||||
Reference in New Issue
Block a user