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