This commit is contained in:
gsinghpal
2026-04-04 15:37:16 -04:00
parent c66bdf5089
commit 3cc93b8783
36 changed files with 3278 additions and 548 deletions

View File

@@ -50,9 +50,9 @@ class FusionAccountingAdapterClaude(models.AbstractModel):
def _supports_extended_thinking(self, model):
return '4-6' in model or '4-5' in model or '4-1' in model or '4-0' in model
def call_with_tools(self, system_prompt, messages, tools=None):
def call_with_tools(self, system_prompt, messages, tools=None, model_override=None):
client = self._get_client()
model = self._get_model_name()
model = model_override or self._get_model_name()
api_messages = []
for msg in messages:

View File

@@ -52,9 +52,9 @@ class FusionAccountingAdapterOpenAI(models.AbstractModel):
def _is_reasoning_model(self, model):
return model.startswith('o1') or model.startswith('o3') or model.startswith('o4')
def call_with_tools(self, system_prompt, messages, tools=None):
def call_with_tools(self, system_prompt, messages, tools=None, model_override=None):
client = self._get_client()
model = self._get_model_name()
model = model_override or self._get_model_name()
is_reasoning = self._is_reasoning_model(model)
if is_reasoning:

View File

@@ -8,6 +8,17 @@ from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
# In-memory execution state for live status polling.
# Key: session_id, Value: {thinking, tool_calls, status}
# Cleared after each chat() call completes.
_execution_state = {}
def get_execution_state(session_id):
"""Get the current execution state for a session (called by polling endpoint)."""
return _execution_state.get(session_id, {'status': 'idle', 'thinking': '', 'tool_calls': []})
# Inter-account transfer pairs: (source_journal, cc_journal, cc_account_pattern)
# Source sends "MB-CREDIT CARD" (outgoing), CC receives "PAYMENT FROM" (incoming)
TRANSFER_PAIRS = [
@@ -25,12 +36,75 @@ class FusionAccountingAgent(models.AbstractModel):
ICP = self.env['ir.config_parameter'].sudo()
return ICP.get_param(f'fusion_accounting.{key}', default)
# Domains that need deeper reasoning → use Sonnet
COMPLEX_DOMAINS = {'audit', 'month_end', 'hst_management', 'payroll_management'}
# Keywords in user messages that suggest complex analysis → use Sonnet
COMPLEX_KEYWORDS = {
'audit', 'analyze', 'analyse', 'review all', 'full report', 'investigate',
'month-end', 'month end', 'close the books', 'hst filing', 'tax return',
'what went wrong', 'why is', 'explain the difference', 'compare',
}
def _get_adapter(self):
provider = self._get_config('ai_provider', 'claude')
if provider == 'claude':
return self.env['fusion.accounting.adapter.claude']
return self.env['fusion.accounting.adapter.openai']
def _route_model(self, session, user_message, has_image=False):
"""Smart model routing: Haiku for routine tool calling, Sonnet for complex analysis.
Returns (model_name, can_escalate) — can_escalate=True means Haiku is trying first
and we should check if it needs help."""
provider = session.ai_provider or self._get_config('ai_provider', 'claude')
if provider != 'claude':
return None, False
# Always use Sonnet for images (vision quality matters for OCR)
if has_image:
return 'claude-sonnet-4-6', False
# Use Sonnet for complex domains
if session.context_domain in self.COMPLEX_DOMAINS:
return 'claude-sonnet-4-6', False
# Use Sonnet if the message contains complex analysis keywords
msg_lower = (user_message or '').lower()
if any(kw in msg_lower for kw in self.COMPLEX_KEYWORDS):
return 'claude-sonnet-4-6', False
# Default: Haiku with escalation enabled
return 'claude-haiku-4-5', True
def _should_escalate(self, response, tool_calls_log, turn):
"""Check if Haiku's response suggests it needs Sonnet's help."""
text = (response.get('text') or '').lower()
# Haiku said it can't do something
uncertainty_phrases = [
"i'm not sure", "i cannot determine", "i don't have enough",
"unable to", "i'm unable", "this is complex", "beyond my",
"i need more context", "difficult to assess", "i apologize",
"i'm having trouble", "let me think about this differently",
]
if any(phrase in text for phrase in uncertainty_phrases):
return True
# Haiku made no tool calls on first turn when it probably should have
# (user asked a question but Haiku just gave text without using tools)
if turn == 0 and not response.get('tool_calls') and not text:
return True
# Haiku had multiple tool errors
error_count = sum(1 for tc in tool_calls_log if tc.get('status') == 'error')
if error_count >= 2:
return True
# Response is very short for a data question (Haiku might be confused)
if turn == 0 and not response.get('tool_calls') and len(text) < 50:
return True
return False
def _get_tool_registry(self):
return self.env['fusion.accounting.tool'].search([('active', '=', True)])
@@ -114,8 +188,8 @@ class FusionAccountingAgent(models.AbstractModel):
vals = {
'session_id': session.id if session else False,
'tool_name': tool_name,
'tool_params': json.dumps(params) if params else '{}',
'tool_result': json.dumps(result) if result else '{}',
'tool_params': json.dumps(params, indent=2, default=str) if params else '{}',
'tool_result': json.dumps(result, indent=2, default=str) if result else '{}',
'ai_reasoning': reasoning,
'ai_confidence': confidence,
'rule_id': rule.id if rule else False,
@@ -125,7 +199,7 @@ class FusionAccountingAgent(models.AbstractModel):
}
return self.env['fusion.accounting.match.history'].sudo().create(vals)
def chat(self, session_id, user_message, context=None):
def chat(self, session_id, user_message, context=None, image=None):
session = self.env['fusion.accounting.session'].browse(session_id)
if not session.exists():
raise UserError(_("Session not found."))
@@ -155,36 +229,106 @@ class FusionAccountingAgent(models.AbstractModel):
)
messages_json = json.loads(session.message_ids_json or '[]')
messages_json.append({'role': 'user', 'content': user_message})
# Build user message — may include image for vision
if image and isinstance(image, dict) and image.get('base64'):
user_content = []
if user_message:
user_content.append({'type': 'text', 'text': user_message})
user_content.append({
'type': 'image',
'source': {
'type': 'base64',
'media_type': image.get('media_type', 'image/png'),
'data': image['base64'],
},
})
messages_json.append({'role': 'user', 'content': user_content})
else:
messages_json.append({'role': 'user', 'content': user_message})
# Smart model routing: Haiku for routine, Sonnet for complex
has_image = bool(image and isinstance(image, dict) and image.get('base64'))
model_override, can_escalate = self._route_model(session, user_message, has_image=has_image)
escalated = False
if model_override:
_logger.info("Model routing: %s%s (escalation=%s)", session.name, model_override, can_escalate)
max_turns = max(int(self._get_config('max_tool_calls', '20')), 1)
total_tokens_in = 0
total_tokens_out = 0
response = {'text': '', 'tool_calls': None}
has_pending_tier3 = False
tool_calls_log = [] # Track tool calls for frontend display
reconciliation_data = None # Raw data from suggest_bank_line_matches
# Initialize live execution state for polling
_execution_state[session.id] = {
'status': 'thinking',
'thinking': '',
'tool_calls': [],
'turn': 0,
}
for turn in range(max_turns):
_execution_state[session.id]['status'] = 'calling_ai'
_execution_state[session.id]['turn'] = turn + 1
response = adapter.call_with_tools(
system_prompt=system_prompt,
messages=messages_json,
tools=tool_definitions,
model_override=model_override,
)
total_tokens_in += response.get('tokens_in', 0)
total_tokens_out += response.get('tokens_out', 0)
# Check if Haiku needs to escalate to Sonnet
if can_escalate and not escalated and self._should_escalate(response, tool_calls_log, turn):
_logger.info("Escalating %s from Haiku → Sonnet (turn %d)", session.name, turn)
model_override = 'claude-sonnet-4-6'
escalated = True
can_escalate = False
_execution_state[session.id]['status'] = 'escalating'
# Re-call with Sonnet
response = adapter.call_with_tools(
system_prompt=system_prompt,
messages=messages_json,
tools=tool_definitions,
model_override=model_override,
)
total_tokens_in += response.get('tokens_in', 0)
total_tokens_out += response.get('tokens_out', 0)
# Capture thinking text for live display
thinking = ''
for block in (response.get('raw_content') or []):
if hasattr(block, 'type') and block.type == 'thinking':
thinking = block.thinking
break
if thinking:
_execution_state[session.id]['thinking'] = thinking[:500] # Truncated for live display
if response.get('tool_calls'):
tool_results = []
_execution_state[session.id]['status'] = 'calling_tools'
for tc in response['tool_calls']:
tool_name = tc['name']
tool_params = tc.get('arguments', {})
tool_rec = tools.filtered(lambda t: t.name == tool_name)
tier = tool_rec.tier if tool_rec else '1'
# Update live state: show which tool is running
_execution_state[session.id]['tool_calls'].append({
'name': tool_name, 'status': 'running',
})
if tier == '3':
has_pending_tier3 = True
history_rec = self._log_match_history(
session, tool_name, tool_params, None,
reasoning=tc.get('reasoning', ''),
reasoning=thinking or '',
confidence=tc.get('confidence', 0.0),
tier='3',
)
@@ -196,17 +340,43 @@ class FusionAccountingAgent(models.AbstractModel):
'message': f'Action requires user approval. Match history ID: {history_rec.id}',
}),
})
tool_calls_log.append({
'name': tool_name,
'tier': tier,
'status': 'pending_approval',
'summary': self._build_tool_call_summary(tool_name, tool_params, None),
})
_execution_state[session.id]['tool_calls'][-1]['status'] = 'pending'
else:
t0 = time.time()
result = self._execute_tool(tool_name, tool_params, session.id)
elapsed = round((time.time() - t0) * 1000)
self._log_match_history(
session, tool_name, tool_params, result,
reasoning=tc.get('reasoning', ''),
reasoning=thinking or '',
tier=tier,
)
tool_results.append({
'tool_call_id': tc.get('id', ''),
'result': json.dumps(result) if not isinstance(result, str) else result,
})
tc_status = 'error' if isinstance(result, dict) and result.get('error') else 'ok'
tc_summary = self._build_tool_call_summary(tool_name, tool_params, result)
# Capture reconciliation data for direct frontend rendering
if tool_name == 'suggest_bank_line_matches' and tc_status == 'ok':
reconciliation_data = result
tool_calls_log.append({
'name': tool_name,
'tier': tier,
'status': tc_status,
'summary': tc_summary,
'duration_ms': elapsed,
})
# Update live state
_execution_state[session.id]['tool_calls'][-1].update({
'status': tc_status, 'summary': tc_summary, 'duration_ms': elapsed,
})
try:
self._check_rule_proposal(tool_name, tool_params, session)
except Exception:
@@ -225,6 +395,7 @@ class FusionAccountingAgent(models.AbstractModel):
system_prompt=system_prompt,
messages=messages_json,
tools=[],
model_override=model_override,
)
total_tokens_in += response.get('tokens_in', 0)
total_tokens_out += response.get('tokens_out', 0)
@@ -249,6 +420,7 @@ class FusionAccountingAgent(models.AbstractModel):
system_prompt=system_prompt,
messages=messages_json,
tools=[],
model_override=model_override,
)
total_tokens_in += response.get('tokens_in', 0)
total_tokens_out += response.get('tokens_out', 0)
@@ -264,7 +436,7 @@ class FusionAccountingAgent(models.AbstractModel):
'token_count_in': session.token_count_in + total_tokens_in,
'token_count_out': session.token_count_out + total_tokens_out,
'ai_provider': provider,
'ai_model': adapter._get_model_name(),
'ai_model': model_override or adapter._get_model_name(),
})
pending = self.env['fusion.accounting.match.history'].search([
@@ -272,19 +444,190 @@ class FusionAccountingAgent(models.AbstractModel):
('decision', '=', 'pending'),
])
return {
# Clear live execution state
_execution_state.pop(session.id, None)
# Add escalation marker to tool calls log if it happened
if escalated:
tool_calls_log.insert(0, {
'name': 'model_escalation',
'tier': '-',
'status': 'ok',
'summary': 'Escalated from Haiku to Sonnet for deeper analysis',
'duration_ms': 0,
})
result_payload = {
'text': response.get('text', ''),
'pending_approvals': [{
'id': p.id,
'tool_name': p.tool_name,
'params': p.tool_params,
'reasoning': p.ai_reasoning,
'confidence': p.ai_confidence,
'amount': p.amount,
} for p in pending],
'tool_calls_log': tool_calls_log,
'pending_approvals': [self._format_pending_approval(p) for p in pending],
'session_id': session.id,
'model_used': model_override or adapter._get_model_name(),
}
# Attach raw reconciliation data so frontend renders it directly
# (instead of relying on AI to format fusion-table JSON correctly)
if reconciliation_data:
result_payload['reconciliation_table'] = reconciliation_data
return result_payload
def _build_tool_call_summary(self, tool_name, params, result):
"""Build a one-line summary of what a tool call did, for the collapsed tool log."""
try:
# Result-based summaries (when we have output)
if result and isinstance(result, dict) and not result.get('error'):
count = result.get('count')
status = result.get('status')
if status == 'created':
name = result.get('name', '')
return f"Created {name}" if name else "Created successfully"
if status == 'matched':
return "Matched successfully"
if count is not None:
return f"Found {count} result{'s' if count != 1 else ''}"
if 'balance' in result:
return f"Balance: ${result['balance']:,.2f}"
if 'total' in result:
return f"Total: ${result['total']:,.2f}"
if 'entries' in result:
return f"Found {len(result['entries'])} entries"
if 'accounts' in result:
return f"Found {len(result['accounts'])} accounts"
if status:
return str(status)
if result and isinstance(result, dict) and result.get('error'):
err = str(result['error'])
return f"Error: {err[:80]}"
# Params-based summaries (for pending approvals, no result yet)
if params:
ref = params.get('ref', params.get('reference', params.get('name', '')))
amount = params.get('amount')
lines = params.get('lines', [])
if lines:
total = sum(l.get('debit', 0) for l in lines)
return f"{ref} — ${total:,.2f}" if ref else f"${total:,.2f} journal entry"
if ref and amount:
return f"{ref} — ${abs(amount):,.2f}"
if ref:
return str(ref)
return "Completed"
except Exception:
return "Completed"
def _format_pending_approval(self, history):
"""Build a rich approval payload so the UI can show exactly what's being approved."""
params = {}
try:
params = json.loads(history.tool_params or '{}')
except json.JSONDecodeError:
pass
# Extract amount from params — look in common locations
amount = history.amount or 0.0
if not amount:
# Try to compute from journal entry lines
lines = params.get('lines', [])
if lines:
amount = sum(l.get('debit', 0) for l in lines)
# Or from direct amount field
if not amount:
amount = abs(params.get('amount', 0))
# Build a human-readable summary of what this action will do
summary = self._build_approval_summary(history.tool_name, params)
return {
'id': history.id,
'tool_name': history.tool_name,
'params': history.tool_params,
'reasoning': history.ai_reasoning,
'confidence': history.ai_confidence,
'amount': amount,
'summary': summary,
}
def _resolve_account_label(self, account_id):
"""Resolve an account ID to 'code - name' for display."""
if not account_id:
return '?'
try:
acct = self.env['account.account'].browse(int(account_id))
if acct.exists():
return f"{acct.code} {acct.name}"
except Exception:
pass
return str(account_id)
def _build_approval_summary(self, tool_name, params):
"""Generate a short human-readable description of what a Tier 3 action will do."""
try:
if tool_name == 'create_payroll_journal_entry':
ref = params.get('ref', 'Payroll Entry')
date = params.get('date', '?')
lines = params.get('lines', [])
total = sum(l.get('debit', 0) for l in lines)
acct_names = []
for l in lines:
aid = l.get('account_id', '')
acct_label = self._resolve_account_label(aid)
if l.get('debit'):
acct_names.append(f"Dr {acct_label}: ${l['debit']:,.2f}")
elif l.get('credit'):
acct_names.append(f"Cr {acct_label}: ${l['credit']:,.2f}")
detail = ' / '.join(acct_names) if acct_names else ''
return f"{ref} on {date} — ${total:,.2f}\n{detail}"
elif tool_name == 'create_vendor_bill':
partner = params.get('partner_name', params.get('partner_id', '?'))
amount = params.get('amount', 0)
ref = params.get('ref', params.get('reference', ''))
date = params.get('date', '?')
return f"Vendor bill for {partner} — ${abs(amount):,.2f} on {date}" + (f" ({ref})" if ref else "")
elif tool_name == 'register_bill_payment':
bill_id = params.get('bill_id', '?')
amount = params.get('amount', 0)
journal = params.get('journal_id', '?')
return f"Pay bill #{bill_id} — ${abs(amount):,.2f} from journal {journal}"
elif tool_name == 'create_expense_entry':
ref = params.get('ref', params.get('memo', 'Expense'))
amount = params.get('amount', 0)
account = params.get('expense_account_id', '?')
return f"{ref} — ${abs(amount):,.2f} to account {account}"
elif tool_name == 'register_hst_payment':
amount = params.get('amount', 0)
date = params.get('date', '?')
return f"HST remittance — ${abs(amount):,.2f} on {date}"
elif tool_name in ('apply_payment', 'send_followup', 'create_payment_reminder'):
partner = params.get('partner_name', params.get('partner_id', '?'))
amount = params.get('amount', 0)
return f"{tool_name.replace('_', ' ').title()} for {partner}" + (f" — ${abs(amount):,.2f}" if amount else "")
elif tool_name == 'flag_entry':
move_id = params.get('move_id', '?')
reason = params.get('reason', '')
return f"Flag entry #{move_id}" + (f": {reason}" if reason else "")
else:
# Generic fallback: show key params
parts = []
for key in ('ref', 'reference', 'name', 'partner_name', 'date', 'move_id'):
if key in params:
parts.append(f"{key}: {params[key]}")
if 'amount' in params:
parts.append(f"${abs(params['amount']):,.2f}")
return ' | '.join(parts) if parts else json.dumps(params)[:120]
except Exception:
return str(params)[:120]
def approve_action(self, match_history_id):
history = self.env['fusion.accounting.match.history'].browse(match_history_id)
if not history.exists() or history.decision != 'pending':
@@ -504,3 +847,101 @@ class FusionAccountingAgent(models.AbstractModel):
)
_logger.info("Transfer reconcile complete: %d total reconciled", total_reconciled)
# ----------------------------------------------------------------
# One-time: Match payroll cheque bank lines against open payroll liability entries
# ----------------------------------------------------------------
@api.model
def _reconcile_payroll_cheques(self):
"""Reconcile payroll cheque bank lines using writeoff to Payroll Liabilities (2201).
Your payroll JEs post:
Dr Salaries / Dr ER CPP-EI / Dr CRA Taxes
Cr 2201 Payroll Liabilities (net pay = cheque amount)
When the cheque clears the bank, the bank line shows:
"Cheque 1773 : Cheque" = -$1,477.95
This method finds cheque bank lines that have a matching payroll liability
entry (same amount) and applies a reconcile model that writes off to account
433 (Payroll Liabilities). This debits 433 to clear the liability.
Non-payroll cheques (no matching entry on 433) are skipped.
"""
PAYROLL_LIABILITY_ACCT_ID = 433 # code 2201
SCOTIA_CURRENT_JOURNAL_ID = 50
AML = self.env['account.move.line'].sudo()
BSL = self.env['account.bank.statement.line'].sudo()
RecModel = self.env['account.reconcile.model'].sudo()
# Find the payroll cheque reconcile model (must be pre-created via XML or manually)
model = RecModel.search([
('name', 'ilike', 'Payroll Cheque'),
('company_id', '=', self.env.company.id),
], limit=1)
if not model:
_logger.warning("Payroll cheque reconcile: no 'Payroll Cheque' model found — create one manually")
return
# Find all unreconciled cheque lines on Scotia Current (negative = outgoing)
# Only process lines after lock date to avoid lock date errors
cheque_lines = BSL.search([
('journal_id', '=', SCOTIA_CURRENT_JOURNAL_ID),
('is_reconciled', '=', False),
('amount', '<', 0),
('payment_ref', 'ilike', 'cheque'),
('company_id', '=', self.env.company.id),
], order='move_id asc')
# Filter to post-lock-date lines only
lock_date = self.env.company.fiscalyear_lock_date
if lock_date:
cheque_lines = cheque_lines.filtered(lambda l: l.move_id.date > lock_date)
_logger.info("Payroll cheque reconcile: found %d unreconciled cheque lines (post lock date)", len(cheque_lines))
# Build set of all known payroll liability credit amounts
payroll_credit_amounts = set()
for aml in AML.search([
('account_id', '=', PAYROLL_LIABILITY_ACCT_ID),
('parent_state', '=', 'posted'),
('credit', '>', 0),
]):
payroll_credit_amounts.add(round(aml.credit, 2))
# Filter: only reconcile cheques that have a matching payroll liability entry
payroll_lines = cheque_lines.filtered(
lambda l: round(abs(l.amount), 2) in payroll_credit_amounts
)
_logger.info(
"Payroll cheque reconcile: %d payroll, %d non-payroll (skipped)",
len(payroll_lines), len(cheque_lines) - len(payroll_lines),
)
if not payroll_lines:
_logger.info("Payroll cheque reconcile: nothing to reconcile")
return
# Apply the reconcile model to payroll cheque lines
try:
model._apply_reconcile_models(payroll_lines)
self.env.cr.commit()
except Exception as e:
_logger.exception("Payroll cheque reconcile batch failed: %s", e)
self.env.cr.rollback()
return
# Count results
still_unreconciled = payroll_lines.filtered(lambda l: not l.is_reconciled)
reconciled = len(payroll_lines) - len(still_unreconciled)
for line in still_unreconciled[:10]:
_logger.info("Payroll cheque still unreconciled: %s $%.2f", line.payment_ref, abs(line.amount))
_logger.info(
"Payroll cheque reconcile complete: %d reconciled, %d still unreconciled",
reconciled, len(still_unreconciled),
)

View File

@@ -8,6 +8,34 @@ You are helping with bank statement reconciliation. Key concepts:
- Fee differences (e.g., Elavon card processing fees) should be allocated to the fee account.
- Weekend batches may combine multiple days of card payments.
- Always verify amounts before proposing a match.
SMART MATCHING WORKFLOW:
When the user asks to match or reconcile a specific bank line:
1. Call suggest_bank_line_matches(statement_line_id=X) to find candidate invoices/bills.
2. Present the results as a reconciliation-mode fusion-table. IMPORTANT: pass the tool
result fields DIRECTLY into the fusion-table — do NOT reformat into cells arrays:
```fusion-table
{
"mode": "reconciliation",
"title": "Match: [ref] $[amount]",
"source_tool": "suggest_bank_line_matches",
"bank_line": <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': """
@@ -119,10 +147,31 @@ INVENTORY & COGS CONTEXT:
""",
'adp': """
ADP RECONCILIATION CONTEXT:
ADP (ASSISTIVE DEVICE PROGRAM) RECONCILIATION CONTEXT:
- ADP Receivable tracked on account 1101.
- ADP invoices have customer portion + ADP portion = total.
- Government deposits should match ADP invoices.
- Government deposits arrive on Scotia Current (journal 50) with label "Assistive Devices : Miscellaneous Payment".
- ADP partner in Odoo: "ADP (Assistive Device Program)" (id 3421).
ADP PAYMENT MATCHING WORKFLOW:
1. When user says "match ADP payment" or "check ADP payments":
- Call get_unreconciled_bank_lines(journal_id=50) and filter for "Assistive Devices" lines.
- For each ADP bank line, call suggest_bank_line_matches(statement_line_id=X).
- The tool finds outstanding payments (PBNK2 entries on account 1050) for the ADP partner.
- Present as reconciliation fusion-table.
2. When user uploads an ADP remittance advice image:
- Read the image. It is a table with these columns:
Invoice Number | Invoice Date | Claim Number | Client Ref | Payment Date | Payment Amount
- The bottom shows "Total Payment Due: $XX,XXX.XX" — this is the bank deposit amount.
- Extract every row: invoice number and payment amount.
- Find the bank line on Scotia Current matching the total amount.
- Call suggest_bank_line_matches for that bank line.
- The outstanding payments on 1050 should sum to the total.
3. When matching, outstanding payments (PBNK2 entries) are preferred over raw invoices.
Each PBNK2 entry represents a registered payment batch. Two or more PBNK2 entries
may combine to equal the bank deposit total.
""",
'reporting': """

View File

@@ -89,6 +89,48 @@ LINKING TO ODOO RECORDS:
- Bank statement lines: mention the date, reference, and amount clearly.
- When tool results include record IDs, always link them.
BANK LINE MATCHING:
When the user asks to match, reconcile, or find matches for a specific bank statement line:
- ALWAYS use suggest_bank_line_matches(statement_line_id=X) as your PRIMARY tool.
- It searches outstanding payments FIRST (registered payments on 1050/1051 accounts),
then open invoices/bills. Outstanding payments are the correct match — not raw invoices.
- Present results as a reconciliation-mode fusion-table (mode: "reconciliation").
- Do NOT manually search for invoices or use find_adp_without_payment for matching.
- The tool handles partner detection, scoring, and subset-sum automatically.
- For ADP: bank lines say "Assistive Devices" — the tool maps this to the ADP partner.
ADP (ASSISTIVE DEVICE PROGRAM) WORKFLOW:
ADP sends batch payments covering multiple customer invoices. The bank deposit label is
"Assistive Devices : Miscellaneous Payment". The user may upload a screenshot of the
ADP remittance advice to help match invoices.
When handling ADP payments:
1. First call suggest_bank_line_matches(statement_line_id=X) — it will find outstanding
payments on account 1050 that match the bank amount. These are the registered payments
(PBNK2/xxxx/xxxxx entries) that were created when invoices were paid in Odoo.
2. Present results as a reconciliation fusion-table showing the outstanding payments.
3. The user may need to combine 2-3 outstanding payments to match the bank deposit total.
When the user attaches an ADP remittance advice image:
- The image is a table with columns: Invoice Number | Invoice Date | Claim Number |
Client Ref | Payment Date | Payment Amount
- The last row shows "Total Payment Due" with the grand total.
- Extract ALL invoice numbers and their payment amounts from the image.
- Present a summary table of what you extracted for confirmation.
- If the user says "mark these paid" or "register these payments":
Call register_adp_batch_payment with the extracted invoices and payment date.
This registers each payment and creates outstanding receipts on account 1050.
Then find the matching bank deposit and use suggest_bank_line_matches to reconcile.
- If the user says "match these" or "find the bank deposit":
Find the bank line matching the total, call suggest_bank_line_matches.
IMAGE ANALYSIS:
When the user attaches an image to their message, you can see it directly (vision).
- Read all text, numbers, and tables from the image.
- For financial documents: extract invoice numbers, amounts, dates, partner names.
- For remittance advices: extract the line items and grand total.
- Always confirm what you extracted before taking action.
TOOL CALLING:
- Call tools by name with the required parameters.
- You may call multiple tools in sequence to gather data before proposing an action.

View File

@@ -65,27 +65,56 @@ def get_overdue_invoices(env, params):
def get_partner_balance(env, params):
partner_id = int(params['partner_id'])
partner = env['res.partner'].browse(partner_id)
if not partner.exists():
return {'error': 'Partner not found'}
amls = env['account.move.line'].search([
('partner_id', '=', partner_id),
"""Get AR and AP balance for a partner. Accepts partner_id or partner_name."""
partner = None
if params.get('partner_id'):
partner = env['res.partner'].browse(int(params['partner_id']))
elif params.get('partner_name'):
partner = env['res.partner'].search([
('name', 'ilike', params['partner_name']),
], limit=1)
if not partner or not partner.exists():
return {'error': f"Partner not found: {params.get('partner_name', params.get('partner_id', '?'))}"}
# AR balance (receivable)
ar_amls = env['account.move.line'].search([
('partner_id', '=', partner.id),
('account_id.account_type', '=', 'asset_receivable'),
('parent_state', '=', 'posted'),
('reconciled', '=', False),
('company_id', '=', env.company.id),
])
ar_balance = sum(aml.amount_residual for aml in ar_amls)
# AP balance (payable)
ap_amls = env['account.move.line'].search([
('partner_id', '=', partner.id),
('account_id.account_type', '=', 'liability_payable'),
('parent_state', '=', 'posted'),
('reconciled', '=', False),
('company_id', '=', env.company.id),
])
ap_balance = sum(aml.amount_residual for aml in ap_amls)
open_items = [{
'id': aml.id,
'move_name': aml.move_id.name,
'ref': aml.ref or '',
'date': str(aml.date),
'amount_residual': aml.amount_residual,
'type': 'receivable' if aml.account_id.account_type == 'asset_receivable' else 'payable',
'date_maturity': str(aml.date_maturity) if aml.date_maturity else '',
} for aml in (ar_amls | ap_amls)[:30]]
return {
'partner': partner.name,
'balance': sum(aml.amount_residual for aml in amls),
'open_items': [{
'id': aml.id,
'ref': aml.ref or aml.move_id.name,
'date': str(aml.date),
'amount_residual': aml.amount_residual,
'date_maturity': str(aml.date_maturity) if aml.date_maturity else '',
} for aml in amls],
'partner_id': partner.id,
'ar_balance': ar_balance,
'ap_balance': ap_balance,
'net_balance': ar_balance + ap_balance,
'they_owe_us': ar_balance if ar_balance > 0 else 0,
'we_owe_them': abs(ap_balance) if ap_balance < 0 else 0,
'open_items': open_items,
}

View File

@@ -7,7 +7,7 @@ _logger = logging.getLogger(__name__)
def get_adp_receivable_aging(env, params):
accounts = env['account.account'].search([
('code', '=like', '1101%'),
('company_id', '=', env.company.id),
('company_ids', 'in', env.company.id),
])
today = fields.Date.today()
amls = env['account.move.line'].search([
@@ -81,7 +81,7 @@ def get_adp_summary(env, params):
date_from = params.get('date_from')
date_to = params.get('date_to')
accounts = env['account.account'].search([
('code', '=like', '1101%'), ('company_id', '=', env.company.id),
('code', '=like', '1101%'), ('company_ids', 'in', env.company.id),
])
domain = [
('account_id', 'in', accounts.ids),
@@ -102,10 +102,136 @@ def get_adp_summary(env, params):
}
def register_adp_batch_payment(env, params):
"""Register payments for a batch of ADP invoices from a remittance advice.
Takes a list of invoice numbers with payment amounts and a payment date.
Registers a payment for each invoice via Odoo's payment wizard, which
creates outstanding receipt entries (PBNK2) on account 1050.
After calling this, use suggest_bank_line_matches on the bank deposit line
to match the outstanding receipts against the bank line.
"""
invoices_data = params.get('invoices', [])
payment_date = params.get('payment_date')
journal_id = int(params.get('journal_id', 50)) # Default Scotia Current
if not invoices_data:
return {'error': 'No invoices provided'}
if not payment_date:
return {'error': 'payment_date is required (YYYY-MM-DD)'}
ADP_PARTNER_ID = 3421 # ADP (Assistive Device Program)
results = []
total_paid = 0.0
errors = []
for inv_data in invoices_data:
inv_number = str(inv_data.get('invoice_number', '')).strip()
amount = float(inv_data.get('amount', 0))
if not inv_number or not amount:
errors.append(f"Skipped: missing invoice_number or amount in {inv_data}")
continue
# Find the invoice by name/number
invoice = env['account.move'].search([
('name', 'ilike', inv_number),
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
('company_id', '=', env.company.id),
], limit=1)
if not invoice:
# Try without leading zeros or with different format
invoice = env['account.move'].search([
('name', '=like', f'%{inv_number}'),
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
], limit=1)
if not invoice:
errors.append(f"Invoice {inv_number} not found")
continue
if invoice.payment_state == 'paid':
results.append({
'invoice': inv_number,
'status': 'already_paid',
'move_id': invoice.id,
})
continue
# Check if amount matches residual (allow partial)
if amount > invoice.amount_residual + 0.01:
errors.append(
f"Invoice {inv_number}: payment ${amount:.2f} exceeds "
f"residual ${invoice.amount_residual:.2f}"
)
continue
# Register payment via the payment wizard
try:
payment_vals = {
'payment_type': 'inbound',
'partner_type': 'customer',
'partner_id': invoice.partner_id.id or ADP_PARTNER_ID,
'amount': amount,
'date': payment_date,
'journal_id': journal_id,
'ref': f'ADP Remittance - {inv_number}',
}
# Use the payment register wizard
ctx = {
'active_model': 'account.move',
'active_ids': [invoice.id],
}
wizard = env['account.payment.register'].with_context(**ctx).create({
'payment_date': payment_date,
'amount': amount,
'journal_id': journal_id,
'payment_method_line_id': env['account.payment.method.line'].search([
('journal_id', '=', journal_id),
('payment_type', '=', 'inbound'),
], limit=1).id,
})
wizard.action_create_payments()
results.append({
'invoice': inv_number,
'status': 'paid',
'amount': amount,
'move_id': invoice.id,
'move_name': invoice.name,
})
total_paid += amount
except Exception as e:
_logger.warning("ADP payment failed for %s: %s", inv_number, e)
errors.append(f"Invoice {inv_number}: payment failed — {e}")
env.cr.commit()
return {
'status': 'completed',
'paid_count': len([r for r in results if r.get('status') == 'paid']),
'already_paid_count': len([r for r in results if r.get('status') == 'already_paid']),
'total_paid': total_paid,
'results': results,
'errors': errors,
'message': (
f"Registered payments for {len([r for r in results if r.get('status') == 'paid'])} invoices "
f"totalling ${total_paid:,.2f}. "
+ (f"{len(errors)} errors." if errors else "No errors.")
+ " Now use suggest_bank_line_matches to match the bank deposit."
),
}
TOOLS = {
'get_adp_receivable_aging': get_adp_receivable_aging,
'match_adp_payment_to_invoice': match_adp_payment_to_invoice,
'verify_adp_split': verify_adp_split,
'find_adp_without_payment': find_adp_without_payment,
'get_adp_summary': get_adp_summary,
'register_adp_batch_payment': register_adp_batch_payment,
}

View File

@@ -35,7 +35,7 @@ def get_unreconciled_receipts(env, params):
account_code = params.get('account_code', '1122')
accounts = env['account.account'].search([
('code', '=like', f'{account_code}%'),
('company_id', '=', env.company.id),
('company_ids', 'in', env.company.id),
])
domain = [
('account_id', 'in', accounts.ids),
@@ -484,6 +484,464 @@ def match_internal_transfers(env, params):
}
def find_unreconciled_cheques(env, params):
"""Find unreconciled cheque bank lines and classify as payroll vs non-payroll
by checking if the amount matches an existing payroll liability entry."""
PAYROLL_ACCT = 433 # 2201 Payroll Liabilities
journal_id = int(params.get('journal_id', 50)) # Default Scotia Current
limit = int(params.get('limit', 50))
AML = env['account.move.line'].sudo()
BSL = env['account.bank.statement.line'].sudo()
# Build set of known payroll liability amounts
payroll_amounts = set()
for aml in AML.search([
('account_id', '=', PAYROLL_ACCT),
('parent_state', '=', 'posted'),
('credit', '>', 0),
]):
payroll_amounts.add(round(aml.credit, 2))
cheque_lines = BSL.search([
('journal_id', '=', journal_id),
('is_reconciled', '=', False),
('payment_ref', 'ilike', 'cheque'),
('amount', '<', 0),
('company_id', '=', env.company.id),
], limit=limit, order='move_id desc')
payroll = []
non_payroll = []
for line in cheque_lines:
amt = round(abs(line.amount), 2)
entry = {
'id': line.id,
'date': str(line.move_id.date),
'ref': line.payment_ref or '',
'amount': amt,
'journal': line.journal_id.name,
}
if amt in payroll_amounts:
entry['type'] = 'payroll'
payroll.append(entry)
else:
entry['type'] = 'non_payroll'
non_payroll.append(entry)
return {
'count': len(cheque_lines),
'payroll_count': len(payroll),
'non_payroll_count': len(non_payroll),
'payroll': payroll,
'non_payroll': non_payroll,
}
def reconcile_payroll_cheques(env, params):
"""Reconcile payroll cheque bank lines by applying the Payroll Cheque Clearing
reconcile model. Only reconciles cheques whose amount matches an existing
payroll liability entry on account 2201. Non-payroll cheques are skipped.
Params:
journal_id (int): Bank journal ID (default 50 = Scotia Current)
line_ids (list): Optional list of specific bank line IDs to reconcile.
If not provided, reconciles all matching payroll cheques.
"""
PAYROLL_ACCT = 433
journal_id = int(params.get('journal_id', 50))
AML = env['account.move.line'].sudo()
BSL = env['account.bank.statement.line'].sudo()
RecModel = env['account.reconcile.model'].sudo()
model = RecModel.search([
('name', 'ilike', 'Payroll Cheque'),
('company_id', '=', env.company.id),
], limit=1)
if not model:
return {'error': 'No "Payroll Cheque Clearing" reconcile model found. Create one first.'}
# Get lines to process
if params.get('line_ids'):
cheque_lines = BSL.browse([int(x) for x in params['line_ids']])
cheque_lines = cheque_lines.filtered(lambda l: not l.is_reconciled)
else:
cheque_lines = BSL.search([
('journal_id', '=', journal_id),
('is_reconciled', '=', False),
('payment_ref', 'ilike', 'cheque'),
('amount', '<', 0),
('company_id', '=', env.company.id),
])
# Filter post-lock-date
lock = env.company.fiscalyear_lock_date
if lock:
cheque_lines = cheque_lines.filtered(lambda l: l.move_id.date > lock)
# Filter to payroll-only amounts
payroll_amounts = set()
for aml in AML.search([
('account_id', '=', PAYROLL_ACCT),
('parent_state', '=', 'posted'),
('credit', '>', 0),
]):
payroll_amounts.add(round(aml.credit, 2))
payroll_lines = cheque_lines.filtered(
lambda l: round(abs(l.amount), 2) in payroll_amounts
)
skipped = len(cheque_lines) - len(payroll_lines)
if not payroll_lines:
return {
'status': 'nothing_to_do',
'message': f'No payroll cheques to reconcile ({skipped} non-payroll cheques skipped)',
}
try:
model._apply_reconcile_models(payroll_lines)
env.cr.commit()
except Exception as e:
return {'error': f'Reconciliation failed: {e}'}
still = payroll_lines.filtered(lambda l: not l.is_reconciled)
reconciled = len(payroll_lines) - len(still)
return {
'status': 'completed',
'reconciled': reconciled,
'still_unreconciled': len(still),
'non_payroll_skipped': skipped,
'message': f'Reconciled {reconciled} payroll cheques. {skipped} non-payroll cheques skipped.',
}
def _extract_partner_from_ref(env, payment_ref):
"""Extract a partner from a bank line payment_ref using keyword matching."""
if not payment_ref:
return None
skip_words = {
'misc', 'payment', 'online', 'banking', 'pad', 'business', 'deposit',
'cheque', 'transfer', 'e-transfer', 'sent', 'autodeposit', 'credit',
'debit', 'memo', 'free', 'interac', 'from', 'the', 'and', 'for',
'miscellaneous', 'bill', 'correction', 'adjustment', 'other',
}
# Strip common suffixes like colons and split
clean_ref = payment_ref.replace(':', ' ').replace('-', ' ')
words = [w for w in clean_ref.split() if len(w) > 2 and w.lower() not in skip_words]
# Try progressively shorter phrases
for n in range(min(len(words), 4), 0, -1):
for i in range(len(words) - n + 1):
phrase = ' '.join(words[i:i+n])
partners = env['res.partner'].search([
('name', 'ilike', phrase),
('company_id', 'in', [env.company.id, False]),
], limit=3)
if partners:
return partners[0]
# Fallback: try each word individually with supplier/customer rank
for word in words:
if len(word) < 4:
continue
partners = env['res.partner'].search([
('name', 'ilike', word),
('company_id', 'in', [env.company.id, False]),
'|', ('customer_rank', '>', 0), ('supplier_rank', '>', 0),
], limit=3)
if partners:
return partners[0]
return None
def _find_best_subset(candidates, target, max_items=8):
"""Find the subset of candidates whose amounts sum closest to target.
Returns (aml_ids, total) for the best combination."""
items = candidates[:max_items]
if not items:
return [], 0.0
best_ids = []
best_total = 0.0
best_diff = abs(target)
n = len(items)
# Brute force all subsets (2^n, max 256)
for mask in range(1, 1 << n):
subset_ids = []
subset_total = 0.0
for j in range(n):
if mask & (1 << j):
subset_ids.append(items[j]['aml_id'])
subset_total += items[j]['amount_residual']
diff = abs(subset_total - target)
if diff < best_diff:
best_diff = diff
best_ids = subset_ids
best_total = subset_total
if diff < 0.01:
break # Exact match found
return best_ids, round(best_total, 2)
def suggest_bank_line_matches(env, params):
"""Find candidate journal items (invoices/bills) that could match a bank statement line.
Scores and ranks matches, finds best subset-sum combination.
Returns data for a reconciliation-mode fusion-table."""
line_id = int(params['statement_line_id'])
line = env['account.bank.statement.line'].browse(line_id)
if not line.exists():
return {'error': 'Bank statement line not found'}
if line.is_reconciled:
return {'error': 'Bank statement line is already reconciled'}
AML = env['account.move.line'].sudo()
bank_amount = abs(line.amount)
line_date = line.move_id.date
is_incoming = line.amount > 0 # positive = customer payment, negative = vendor payment
from datetime import timedelta as td
# Determine partner
partner = line.partner_id
if not partner:
partner = _extract_partner_from_ref(env, line.payment_ref)
# Base domain common to all searches
base_domain = [
('parent_state', '=', 'posted'),
('reconciled', '=', False),
('company_id', '=', env.company.id),
('display_type', 'not in', ('line_section', 'line_subsection', 'line_note')),
('statement_line_id', '=', False),
]
# --- PRIORITY 1: Outstanding payments/receipts on bank journal accounts ---
# These are registered payments waiting to be matched to bank lines.
# For incoming bank lines → look for outstanding receipts (credit on outstanding account)
# For outgoing bank lines → look for outstanding payments (debit on outstanding account)
outstanding_acct_ids = env['account.account'].search([
('name', 'ilike', 'outstanding'),
('company_ids', 'in', env.company.id),
]).ids
outstanding_amls = AML
if outstanding_acct_ids:
os_domain = base_domain + [('account_id', 'in', outstanding_acct_ids)]
if is_incoming:
os_domain.append(('amount_residual', '>', 0)) # Debit residual on outstanding receipts
else:
os_domain.append(('amount_residual', '<', 0)) # Credit residual on outstanding payments
if partner:
outstanding_amls = AML.search(os_domain + [('partner_id', '=', partner.id)], limit=30)
if not outstanding_amls:
outstanding_amls = AML.search(os_domain, limit=30)
else:
outstanding_amls = AML.search(os_domain, limit=30)
# --- PRIORITY 2: Open invoices/bills (receivable/payable accounts) ---
inv_domain = list(base_domain)
if is_incoming:
inv_domain.append(('account_id.account_type', '=', 'asset_receivable'))
inv_domain.append(('amount_residual', '>', 0))
else:
inv_domain.append(('account_id.account_type', '=', 'liability_payable'))
inv_domain.append(('amount_residual', '<', 0))
inv_domain.append(('date', '>=', str(line_date - td(days=90))))
inv_domain.append(('date', '<=', str(line_date + td(days=30))))
invoice_amls = AML
if partner:
invoice_amls = AML.search(inv_domain + [('partner_id', '=', partner.id)], limit=30)
if not invoice_amls:
invoice_amls = AML.search(inv_domain, limit=30)
else:
invoice_amls = AML.search(inv_domain, limit=30)
# Merge: outstanding payments first (priority), then invoices/bills
combined = outstanding_amls | invoice_amls
# Score and format candidates
outstanding_ids = set(outstanding_amls.ids) if outstanding_amls else set()
candidates = []
seen_ids = set()
for aml in combined:
if aml.id in seen_ids:
continue
seen_ids.add(aml.id)
residual = abs(aml.amount_residual)
score = 0
reasons = []
is_payment = aml.id in outstanding_ids
# Source type: payment entries get a boost (preferred match)
if is_payment:
score += 15
reasons.append('payment')
# Amount scoring
if abs(residual - bank_amount) < 0.01:
score += 40
reasons.append('exact amount')
elif residual <= bank_amount * 1.05:
score += 20
reasons.append('close amount')
# Partner scoring
if partner and aml.partner_id.id == partner.id:
score += 25
reasons.append('partner')
elif partner and aml.partner_id and partner.name and aml.partner_id.name:
p1_words = set(partner.name.upper().split())
p2_words = set(aml.partner_id.name.upper().split())
if p1_words & p2_words:
score += 10
reasons.append('partial partner')
# Date proximity scoring
days_apart = abs((aml.date - line_date).days)
if days_apart <= 3:
score += 15
reasons.append(f'{days_apart}d')
elif days_apart <= 7:
score += 10
elif days_apart <= 14:
score += 5
# Reference matching
if line.payment_ref and aml.move_id.ref:
if any(w.upper() in (aml.move_id.ref or '').upper()
for w in line.payment_ref.split() if len(w) > 3):
score += 10
reasons.append('ref match')
# Determine entry type label
entry_type = 'payment' if is_payment else 'invoice'
if aml.move_id.move_type == 'in_invoice':
entry_type = 'bill'
elif aml.move_id.move_type == 'out_invoice':
entry_type = 'invoice'
elif aml.move_id.move_type in ('in_refund', 'out_refund'):
entry_type = 'credit note'
elif aml.payment_id:
entry_type = 'payment'
candidates.append({
'aml_id': aml.id,
'move_id': aml.move_id.id,
'name': aml.move_id.name or '',
'ref': aml.move_id.ref or '',
'partner': aml.partner_id.name if aml.partner_id else '',
'partner_id': aml.partner_id.id if aml.partner_id else None,
'date': str(aml.date),
'amount_total': abs(aml.balance),
'amount_residual': residual,
'account': aml.account_id.code if hasattr(aml.account_id, 'code') else '',
'type': entry_type,
'score': score,
'reasons': ', '.join(reasons) if reasons else '',
})
# Sort by score descending
candidates.sort(key=lambda c: -c['score'])
# Find best subset-sum combination
best_combo_ids, best_combo_total = _find_best_subset(candidates, bank_amount)
# Mark which candidates are in the best combination
for c in candidates:
c['in_best_combo'] = c['aml_id'] in best_combo_ids
return {
'bank_line': {
'id': line.id,
'date': str(line_date),
'ref': line.payment_ref or '',
'amount': line.amount,
'abs_amount': bank_amount,
'journal': line.journal_id.name,
'partner': partner.name if partner else '',
'partner_id': partner.id if partner else None,
'direction': 'incoming' if is_incoming else 'outgoing',
},
'candidates': candidates[:20],
'best_combination': best_combo_ids,
'best_combination_total': best_combo_total,
'is_exact_match': abs(best_combo_total - bank_amount) < 0.01,
'count': len(candidates),
}
def search_matching_entries(env, params):
"""Search open journal items by query (invoice/bill number, amount, or partner name).
Used by the reconciliation table search bar via direct RPC."""
query = (params.get('query') or '').strip()
line_id = params.get('statement_line_id')
if not query:
return {'candidates': []}
AML = env['account.move.line'].sudo()
# Search across receivable, payable, AND outstanding accounts
outstanding_acct_ids = env['account.account'].search([
('name', 'ilike', 'outstanding'),
('company_ids', 'in', env.company.id),
]).ids
domain = [
('parent_state', '=', 'posted'),
('reconciled', '=', False),
('company_id', '=', env.company.id),
('display_type', 'not in', ('line_section', 'line_subsection', 'line_note')),
'|',
('account_id.account_type', 'in', ('asset_receivable', 'liability_payable')),
('account_id', 'in', outstanding_acct_ids),
]
# Try as amount first
try:
amount = float(query.replace('$', '').replace(',', ''))
amount_domain = domain + [
'|',
'&', ('amount_residual', '>=', amount - 0.50), ('amount_residual', '<=', amount + 0.50),
'&', ('amount_residual', '>=', -amount - 0.50), ('amount_residual', '<=', -amount + 0.50),
]
amls = AML.search(amount_domain, limit=15)
if amls:
return {'candidates': _format_aml_candidates(amls)}
except ValueError:
pass
# Search by move name (invoice/bill number)
name_amls = AML.search(domain + [('move_id.name', 'ilike', query)], limit=15)
if name_amls:
return {'candidates': _format_aml_candidates(name_amls)}
# Search by move ref
ref_amls = AML.search(domain + [('move_id.ref', 'ilike', query)], limit=15)
if ref_amls:
return {'candidates': _format_aml_candidates(ref_amls)}
# Search by partner name
partner_amls = AML.search(domain + [('partner_id.name', 'ilike', query)], limit=15)
return {'candidates': _format_aml_candidates(partner_amls)}
def _format_aml_candidates(amls):
"""Format AMLs as candidate dicts for the reconciliation table."""
return [{
'aml_id': aml.id,
'move_id': aml.move_id.id,
'name': aml.move_id.name or '',
'ref': aml.move_id.ref or '',
'partner': aml.partner_id.name if aml.partner_id else '',
'partner_id': aml.partner_id.id if aml.partner_id else None,
'date': str(aml.date),
'amount_total': abs(aml.balance),
'amount_residual': abs(aml.amount_residual),
'account': aml.account_id.code if hasattr(aml.account_id, 'code') else '',
'score': 0,
'reasons': 'manual search',
'in_best_combo': False,
} for aml in amls]
TOOLS = {
'get_unreconciled_bank_lines': get_unreconciled_bank_lines,
'get_unreconciled_receipts': get_unreconciled_receipts,
@@ -496,4 +954,8 @@ TOOLS = {
'get_bank_line_details': get_bank_line_details,
'check_recurring_pattern': check_recurring_pattern,
'match_internal_transfers': match_internal_transfers,
'find_unreconciled_cheques': find_unreconciled_cheques,
'reconcile_payroll_cheques': reconcile_payroll_cheques,
'suggest_bank_line_matches': suggest_bank_line_matches,
'search_matching_entries': search_matching_entries,
}

View File

@@ -19,10 +19,10 @@ def calculate_hst_balance(env, params):
# (shared chart of accounts). Use try/except to handle both cases.
try:
collected_accounts = env['account.account'].search([
('code', '=like', '2005%'), ('company_id', '=', env.company.id),
('code', '=like', '2005%'), ('company_ids', 'in', env.company.id),
])
itc_accounts = env['account.account'].search([
('code', '=like', '2006%'), ('company_id', '=', env.company.id),
('code', '=like', '2006%'), ('company_ids', 'in', env.company.id),
])
except Exception:
collected_accounts = env['account.account'].search([
@@ -216,7 +216,7 @@ def create_expense_entry(env, params):
# Fallback to AP account
credit_account = env['account.account'].search([
('account_type', '=', 'liability_payable'),
('company_id', '=', env.company.id),
('company_ids', 'in', env.company.id),
], limit=1)
if not credit_account:

View File

@@ -7,7 +7,7 @@ _logger = logging.getLogger(__name__)
def get_stock_valuation(env, params):
accounts = env['account.account'].search([
('code', '=like', '1069%'),
('company_id', '=', env.company.id),
('company_ids', 'in', env.company.id),
])
result = []
for acct in accounts:
@@ -22,7 +22,7 @@ def get_stock_valuation(env, params):
def get_price_differences(env, params):
accounts = env['account.account'].search([
('code', '=like', '5010%'),
('company_id', '=', env.company.id),
('company_ids', 'in', env.company.id),
])
domain = [
('account_id', 'in', accounts.ids),

View File

@@ -108,7 +108,7 @@ def find_wrong_account_entries(env, params):
tax_accounts = env['account.account'].search([
('account_type', 'in', ('liability_current', 'asset_current')),
('code', '=like', '2005%'),
('company_id', '=', env.company.id),
('company_ids', 'in', env.company.id),
])
if tax_accounts:
revenue_on_tax = env['account.move.line'].search(
@@ -171,7 +171,7 @@ def find_draft_entries(env, params):
def find_unreconciled_suspense(env, params):
suspense_accounts = env['account.account'].search([
('code', '=like', '999%'),
('company_id', '=', env.company.id),
('company_ids', 'in', env.company.id),
])
issues = []
for acct in suspense_accounts:

View File

@@ -35,7 +35,7 @@ def get_close_checklist(env, params):
def get_unreconciled_counts(env, params):
accounts = env['account.account'].search([
('reconcile', '=', True),
('company_id', '=', env.company.id),
('company_ids', 'in', env.company.id),
])
result = []
for acct in accounts:
@@ -77,7 +77,7 @@ def get_accrual_status(env, params):
for code in accrual_codes:
accounts = env['account.account'].search([
('code', '=like', f'{code}%'),
('company_id', '=', env.company.id),
('company_ids', 'in', env.company.id),
])
for acct in accounts:
balance = sum(env['account.move.line'].search([

View File

@@ -66,7 +66,7 @@ def verify_source_deductions(env, params):
def get_cra_remittance_status(env, params):
cra_accounts = env['account.account'].search([
('name', 'ilike', 'CRA'),
('company_id', '=', env.company.id),
('company_ids', 'in', env.company.id),
])
result = []
for acct in cra_accounts:
@@ -130,21 +130,72 @@ def parse_payroll_summary(env, params):
}
def _resolve_account_id(env, val):
"""Resolve an account code or ID to a valid account ID.
Accepts: integer ID, string ID, or account code string like '2201'."""
if not val:
return False
val_str = str(val).strip()
# Try as a direct ID first
try:
acct = env['account.account'].browse(int(val_str))
if acct.exists():
return acct.id
except (ValueError, TypeError):
pass
# Try as an account code
acct = env['account.account'].search([
('code', '=', val_str),
('company_ids', 'in', env.company.id),
], limit=1)
if acct:
return acct.id
return False
def create_payroll_journal_entry(env, params):
journal_id = int(params['journal_id'])
date = params['date']
ref = params.get('ref', 'Payroll Entry')
lines_data = params['lines']
move_vals = {
'journal_id': journal_id,
'date': date,
'ref': params.get('ref', 'Payroll Entry'),
'line_ids': [(0, 0, {
'account_id': int(line['account_id']),
# Duplicate check: same journal + date + ref + similar amount
total_debit = sum(float(l.get('debit', 0)) for l in lines_data)
existing = env['account.move'].search([
('journal_id', '=', journal_id),
('date', '=', date),
('ref', 'ilike', ref[:30]),
('state', 'in', ('draft', 'posted')),
], limit=1)
if existing:
return {
'status': 'duplicate',
'error': f'Entry already exists: {existing.name} (ref: {existing.ref}) on {existing.date} '
f'for ${existing.amount_total:,.2f}. Skipping to avoid duplicate.',
'existing_move_id': existing.id,
'existing_name': existing.name,
}
# Resolve account codes to IDs
resolved_lines = []
for line in lines_data:
account_id = _resolve_account_id(env, line['account_id'])
if not account_id:
return {'error': f"Account not found: {line['account_id']}. "
f"Provide a valid account code (e.g. '2201') or database ID."}
resolved_lines.append((0, 0, {
'account_id': account_id,
'name': line.get('name', 'Payroll'),
'debit': float(line.get('debit', 0)),
'credit': float(line.get('credit', 0)),
'partner_id': int(line['partner_id']) if line.get('partner_id') else False,
}) for line in lines_data],
}))
move_vals = {
'journal_id': journal_id,
'date': date,
'ref': ref,
'line_ids': resolved_lines,
}
move = env['account.move'].create(move_vals)
return {'status': 'created', 'move_id': move.id, 'name': move.name}

View File

@@ -106,6 +106,171 @@ def export_report(env, params):
return {'error': f'Export failed: {str(e)}'}
def get_invoicing_summary(env, params):
"""Get invoicing summary — total invoiced by month, by partner, or for a date range.
Supports: monthly breakdown for a year, current month totals, or filtered by partner."""
from datetime import date, timedelta
import calendar
year = int(params.get('year', date.today().year))
partner_name = params.get('partner_name')
date_from = params.get('date_from')
date_to = params.get('date_to')
domain = [
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
('company_id', '=', env.company.id),
]
if partner_name:
partner = env['res.partner'].search([('name', 'ilike', partner_name)], limit=1)
if partner:
domain.append(('partner_id', '=', partner.id))
else:
return {'error': f'Partner not found: {partner_name}'}
if date_from and date_to:
domain += [('date', '>=', date_from), ('date', '<=', date_to)]
invoices = env['account.move'].search(domain, order='date desc')
total = sum(inv.amount_total for inv in invoices)
return {
'period': f'{date_from} to {date_to}',
'count': len(invoices),
'total': total,
'invoices': [{
'id': inv.id, 'name': inv.name, 'partner': inv.partner_id.name,
'date': str(inv.date), 'amount': inv.amount_total,
'payment_state': inv.payment_state,
} for inv in invoices[:30]],
}
# Monthly breakdown for the year
months = []
grand_total = 0
for month in range(1, 13):
m_start = f'{year}-{month:02d}-01'
last_day = calendar.monthrange(year, month)[1]
m_end = f'{year}-{month:02d}-{last_day}'
m_domain = domain + [('date', '>=', m_start), ('date', '<=', m_end)]
invoices = env['account.move'].search(m_domain)
total = sum(inv.amount_total for inv in invoices)
grand_total += total
months.append({
'month': f'{year}-{month:02d}',
'month_name': calendar.month_name[month],
'count': len(invoices),
'total': round(total, 2),
})
return {
'year': year,
'grand_total': round(grand_total, 2),
'months': months,
'partner': partner_name or 'All',
}
def get_billing_summary(env, params):
"""Get billing (vendor bills) summary — total billed by month or date range."""
from datetime import date
import calendar
year = int(params.get('year', date.today().year))
partner_name = params.get('partner_name')
date_from = params.get('date_from')
date_to = params.get('date_to')
domain = [
('move_type', '=', 'in_invoice'),
('state', '=', 'posted'),
('company_id', '=', env.company.id),
]
if partner_name:
partner = env['res.partner'].search([('name', 'ilike', partner_name)], limit=1)
if partner:
domain.append(('partner_id', '=', partner.id))
else:
return {'error': f'Partner not found: {partner_name}'}
if date_from and date_to:
domain += [('date', '>=', date_from), ('date', '<=', date_to)]
bills = env['account.move'].search(domain, order='date desc')
total = sum(b.amount_total for b in bills)
return {
'period': f'{date_from} to {date_to}',
'count': len(bills),
'total': total,
'bills': [{
'id': b.id, 'name': b.name, 'partner': b.partner_id.name,
'date': str(b.date), 'amount': b.amount_total,
'payment_state': b.payment_state,
} for b in bills[:30]],
}
# Monthly breakdown
months = []
grand_total = 0
for month in range(1, 13):
m_start = f'{year}-{month:02d}-01'
last_day = calendar.monthrange(year, month)[1]
m_end = f'{year}-{month:02d}-{last_day}'
m_domain = domain + [('date', '>=', m_start), ('date', '<=', m_end)]
bills = env['account.move'].search(m_domain)
total = sum(b.amount_total for b in bills)
grand_total += total
months.append({
'month': f'{year}-{month:02d}',
'month_name': calendar.month_name[month],
'count': len(bills),
'total': round(total, 2),
})
return {
'year': year,
'grand_total': round(grand_total, 2),
'months': months,
'partner': partner_name or 'All',
}
def get_collections_summary(env, params):
"""Get payment collections summary — how much was collected (received) in a period."""
date_from = params.get('date_from')
date_to = params.get('date_to')
if not date_from or not date_to:
from datetime import date
today = date.today()
date_from = date_from or f'{today.year}-{today.month:02d}-01'
date_to = date_to or str(today)
payments = env['account.payment'].search([
('payment_type', '=', 'inbound'),
('state', '=', 'posted'),
('date', '>=', date_from),
('date', '<=', date_to),
('company_id', '=', env.company.id),
], order='date desc')
total = sum(p.amount for p in payments)
by_partner = {}
for p in payments:
pname = p.partner_id.name if p.partner_id else 'Unknown'
by_partner.setdefault(pname, {'count': 0, 'total': 0})
by_partner[pname]['count'] += 1
by_partner[pname]['total'] += p.amount
top_partners = sorted(by_partner.items(), key=lambda x: -x[1]['total'])[:15]
return {
'period': f'{date_from} to {date_to}',
'total_collected': round(total, 2),
'payment_count': len(payments),
'by_partner': [{'partner': k, 'count': v['count'], 'total': round(v['total'], 2)} for k, v in top_partners],
}
TOOLS = {
'get_profit_loss': get_profit_loss,
'get_balance_sheet': get_balance_sheet,
@@ -114,4 +279,7 @@ TOOLS = {
'compare_periods': compare_periods,
'answer_financial_question': answer_financial_question,
'export_report': export_report,
'get_invoicing_summary': get_invoicing_summary,
'get_billing_summary': get_billing_summary,
'get_collections_summary': get_collections_summary,
}