changes
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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': """
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user