refactor(fusion_accounting): move AI module code into fusion_accounting_ai sub-module
git mv preserves history. fusion_accounting/ retains only __manifest__.py, __init__.py, CLAUDE.md, and docs/ — the meta-module shell. All Python, data, views, security, services, static, tests, wizards, report move to fusion_accounting_ai/. Manifest data list updated; security.xml move to _core deferred to Task 12. Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
from . import adapters
|
||||
from . import tools
|
||||
from . import prompts
|
||||
from . import agent
|
||||
from . import scoring
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
from . import claude
|
||||
from . import openai_adapter
|
||||
|
||||
141
fusion_accounting_ai/services/adapters/claude.py
Normal file
141
fusion_accounting_ai/services/adapters/claude.py
Normal file
@@ -0,0 +1,141 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from odoo import models, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
import anthropic as anthropic_sdk
|
||||
except ImportError:
|
||||
anthropic_sdk = None
|
||||
|
||||
|
||||
class FusionAccountingAdapterClaude(models.AbstractModel):
|
||||
_name = 'fusion.accounting.adapter.claude'
|
||||
_description = 'Claude AI Adapter'
|
||||
|
||||
def _get_client(self):
|
||||
if anthropic_sdk is None:
|
||||
raise UserError(_("The 'anthropic' Python package is not installed."))
|
||||
try:
|
||||
key = self.env['fusion.api.service'].get_api_key(
|
||||
provider_type='anthropic',
|
||||
consumer='fusion_accounting',
|
||||
feature='chat_with_tools',
|
||||
)
|
||||
except Exception:
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
key = ICP.get_param('fusion_accounting.anthropic_api_key', '')
|
||||
if not key:
|
||||
raise UserError(_("No Anthropic API key configured."))
|
||||
return anthropic_sdk.Anthropic(api_key=key)
|
||||
|
||||
def _get_model_name(self):
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
return ICP.get_param('fusion_accounting.claude_model', 'claude-sonnet-4-6')
|
||||
|
||||
def _format_tools(self, tools):
|
||||
formatted = []
|
||||
for tool in tools:
|
||||
t = {
|
||||
'name': tool['name'],
|
||||
'description': tool['description'],
|
||||
'input_schema': tool.get('parameters', {'type': 'object', 'properties': {}}),
|
||||
}
|
||||
formatted.append(t)
|
||||
return formatted
|
||||
|
||||
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, model_override=None):
|
||||
client = self._get_client()
|
||||
model = model_override or self._get_model_name()
|
||||
|
||||
api_messages = []
|
||||
for msg in messages:
|
||||
if msg['role'] in ('user', 'assistant'):
|
||||
api_messages.append(msg)
|
||||
|
||||
kwargs = {
|
||||
'model': model,
|
||||
'max_tokens': 16384,
|
||||
'system': system_prompt,
|
||||
'messages': api_messages,
|
||||
}
|
||||
if tools:
|
||||
kwargs['tools'] = self._format_tools(tools)
|
||||
|
||||
if self._supports_extended_thinking(model) and tools:
|
||||
kwargs['thinking'] = {
|
||||
'type': 'enabled',
|
||||
'budget_tokens': 8192,
|
||||
}
|
||||
|
||||
try:
|
||||
response = client.messages.create(**kwargs)
|
||||
except anthropic_sdk.BadRequestError as e:
|
||||
if 'thinking' in str(e).lower():
|
||||
kwargs.pop('thinking', None)
|
||||
response = client.messages.create(**kwargs)
|
||||
else:
|
||||
raise UserError(_("Claude API error: %s", str(e)))
|
||||
except Exception as e:
|
||||
_logger.error("Claude API error: %s", e)
|
||||
raise UserError(_("Claude API error: %s", str(e)))
|
||||
|
||||
text_parts = []
|
||||
tool_calls = []
|
||||
thinking_text = []
|
||||
for block in response.content:
|
||||
if block.type == 'text':
|
||||
text_parts.append(block.text)
|
||||
elif block.type == 'tool_use':
|
||||
tool_calls.append({
|
||||
'id': block.id,
|
||||
'name': block.name,
|
||||
'arguments': block.input,
|
||||
})
|
||||
elif block.type == 'thinking':
|
||||
thinking_text.append(block.thinking)
|
||||
|
||||
if thinking_text:
|
||||
_logger.debug("Claude thinking: %s", thinking_text[0][:500])
|
||||
|
||||
return {
|
||||
'text': '\n'.join(text_parts),
|
||||
'tool_calls': tool_calls if tool_calls else None,
|
||||
'tokens_in': response.usage.input_tokens,
|
||||
'tokens_out': response.usage.output_tokens,
|
||||
'stop_reason': response.stop_reason,
|
||||
'raw_content': response.content,
|
||||
}
|
||||
|
||||
def append_tool_results(self, messages, ai_response, tool_results):
|
||||
assistant_content = []
|
||||
for block in ai_response.get('raw_content', []):
|
||||
if hasattr(block, 'type'):
|
||||
if block.type == 'text':
|
||||
assistant_content.append({'type': 'text', 'text': block.text})
|
||||
elif block.type == 'tool_use':
|
||||
assistant_content.append({
|
||||
'type': 'tool_use',
|
||||
'id': block.id,
|
||||
'name': block.name,
|
||||
'input': block.input,
|
||||
})
|
||||
|
||||
messages.append({'role': 'assistant', 'content': assistant_content})
|
||||
|
||||
tool_result_content = []
|
||||
for tr in tool_results:
|
||||
tool_result_content.append({
|
||||
'type': 'tool_result',
|
||||
'tool_use_id': tr['tool_call_id'],
|
||||
'content': tr['result'],
|
||||
})
|
||||
messages.append({'role': 'user', 'content': tool_result_content})
|
||||
|
||||
return messages
|
||||
137
fusion_accounting_ai/services/adapters/openai_adapter.py
Normal file
137
fusion_accounting_ai/services/adapters/openai_adapter.py
Normal file
@@ -0,0 +1,137 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from odoo import models, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from openai import OpenAI
|
||||
except ImportError:
|
||||
OpenAI = None
|
||||
|
||||
|
||||
class FusionAccountingAdapterOpenAI(models.AbstractModel):
|
||||
_name = 'fusion.accounting.adapter.openai'
|
||||
_description = 'OpenAI AI Adapter'
|
||||
|
||||
def _get_client(self):
|
||||
if OpenAI is None:
|
||||
raise UserError(_("The 'openai' Python package is not installed."))
|
||||
try:
|
||||
key = self.env['fusion.api.service'].get_api_key(
|
||||
provider_type='openai',
|
||||
consumer='fusion_accounting',
|
||||
feature='chat_with_tools',
|
||||
)
|
||||
except Exception:
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
key = ICP.get_param('fusion_accounting.openai_api_key', '')
|
||||
if not key:
|
||||
raise UserError(_("No OpenAI API key configured."))
|
||||
return OpenAI(api_key=key)
|
||||
|
||||
def _get_model_name(self):
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
return ICP.get_param('fusion_accounting.openai_model', 'gpt-5.4-mini')
|
||||
|
||||
def _format_tools(self, tools):
|
||||
formatted = []
|
||||
for tool in tools:
|
||||
formatted.append({
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': tool['name'],
|
||||
'description': tool['description'],
|
||||
'parameters': tool.get('parameters', {'type': 'object', 'properties': {}}),
|
||||
},
|
||||
})
|
||||
return formatted
|
||||
|
||||
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, model_override=None):
|
||||
client = self._get_client()
|
||||
model = model_override or self._get_model_name()
|
||||
is_reasoning = self._is_reasoning_model(model)
|
||||
|
||||
if is_reasoning:
|
||||
api_messages = [{'role': 'developer', 'content': system_prompt}]
|
||||
else:
|
||||
api_messages = [{'role': 'system', 'content': system_prompt}]
|
||||
for msg in messages:
|
||||
if msg['role'] in ('user', 'assistant', 'tool'):
|
||||
api_messages.append(msg)
|
||||
|
||||
kwargs = {
|
||||
'model': model,
|
||||
'messages': api_messages,
|
||||
}
|
||||
if is_reasoning:
|
||||
kwargs['max_completion_tokens'] = 16384
|
||||
kwargs['reasoning_effort'] = 'medium'
|
||||
else:
|
||||
kwargs['max_tokens'] = 4096
|
||||
|
||||
if tools:
|
||||
kwargs['tools'] = self._format_tools(tools)
|
||||
|
||||
try:
|
||||
response = client.chat.completions.create(**kwargs)
|
||||
except Exception as e:
|
||||
_logger.error("OpenAI API error: %s", e)
|
||||
raise UserError(_("OpenAI API error: %s", str(e)))
|
||||
|
||||
choice = response.choices[0]
|
||||
message = choice.message
|
||||
|
||||
tool_calls = []
|
||||
if message.tool_calls:
|
||||
for tc in message.tool_calls:
|
||||
try:
|
||||
args = json.loads(tc.function.arguments)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
_logger.warning("Malformed tool args from OpenAI: %s", tc.function.arguments)
|
||||
args = {}
|
||||
tool_calls.append({
|
||||
'id': tc.id,
|
||||
'name': tc.function.name,
|
||||
'arguments': args,
|
||||
})
|
||||
|
||||
return {
|
||||
'text': message.content or '',
|
||||
'tool_calls': tool_calls if tool_calls else None,
|
||||
'tokens_in': response.usage.prompt_tokens,
|
||||
'tokens_out': response.usage.completion_tokens,
|
||||
'stop_reason': choice.finish_reason,
|
||||
'raw_message': message,
|
||||
}
|
||||
|
||||
def append_tool_results(self, messages, ai_response, tool_results):
|
||||
raw_msg = ai_response.get('raw_message')
|
||||
assistant_msg = {'role': 'assistant', 'content': raw_msg.content or ''}
|
||||
if raw_msg.tool_calls:
|
||||
assistant_msg['tool_calls'] = [
|
||||
{
|
||||
'id': tc.id,
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': tc.function.name,
|
||||
'arguments': tc.function.arguments,
|
||||
},
|
||||
}
|
||||
for tc in raw_msg.tool_calls
|
||||
]
|
||||
messages.append(assistant_msg)
|
||||
|
||||
for tr in tool_results:
|
||||
messages.append({
|
||||
'role': 'tool',
|
||||
'tool_call_id': tr['tool_call_id'],
|
||||
'content': tr['result'],
|
||||
})
|
||||
|
||||
return messages
|
||||
947
fusion_accounting_ai/services/agent.py
Normal file
947
fusion_accounting_ai/services/agent.py
Normal file
@@ -0,0 +1,947 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
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 = [
|
||||
# (source_journal_id, cc_journal_id, outstanding_account_id)
|
||||
(50, 51, 493), # Scotia Current → Passport Visa, Outstanding Receipts - All Banks
|
||||
(53, 28, 493), # RBC Chequing → RBC Visa, Outstanding Receipts - All Banks
|
||||
]
|
||||
|
||||
|
||||
class FusionAccountingAgent(models.AbstractModel):
|
||||
_name = 'fusion.accounting.agent'
|
||||
_description = 'Fusion Accounting AI Agent Orchestrator'
|
||||
|
||||
def _get_config(self, key, default=None):
|
||||
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)])
|
||||
|
||||
def _get_tools_for_user(self, user=None):
|
||||
user = user or self.env.user
|
||||
tools = self._get_tool_registry()
|
||||
filtered = self.env['fusion.accounting.tool']
|
||||
for tool in tools:
|
||||
if not tool.required_groups:
|
||||
filtered |= tool
|
||||
continue
|
||||
group_xmlids = [g.strip() for g in tool.required_groups.split(',') if g.strip()]
|
||||
if all(user.has_group(g) for g in group_xmlids):
|
||||
filtered |= tool
|
||||
return filtered
|
||||
|
||||
def _build_tool_definitions(self, tools):
|
||||
definitions = []
|
||||
for tool in tools:
|
||||
# A2: Include tier info in description so AI knows which tools need approval
|
||||
tier_label = {'1': 'Read-only', '2': 'Auto-approved', '3': 'Requires user approval'}.get(tool.tier, '')
|
||||
desc = tool.description or ''
|
||||
if tier_label:
|
||||
desc = f"[Tier {tool.tier}: {tier_label}] {desc}"
|
||||
defn = {
|
||||
'name': tool.name,
|
||||
'description': desc,
|
||||
}
|
||||
if tool.parameters_schema:
|
||||
try:
|
||||
defn['parameters'] = json.loads(tool.parameters_schema)
|
||||
except json.JSONDecodeError:
|
||||
defn['parameters'] = {'type': 'object', 'properties': {}}
|
||||
else:
|
||||
defn['parameters'] = {'type': 'object', 'properties': {}}
|
||||
definitions.append(defn)
|
||||
return definitions
|
||||
|
||||
def _load_rules(self, domain=None):
|
||||
rule_domain = [('active', '=', True), ('company_id', '=', self.env.company.id)]
|
||||
if domain:
|
||||
rule_domain.append(('rule_type', '=', domain))
|
||||
rules = self.env['fusion.accounting.rule'].search(rule_domain, order='sequence')
|
||||
admin_rules = rules.filtered(lambda r: r.created_by == 'admin')
|
||||
ai_auto = rules.filtered(lambda r: r.created_by == 'ai' and r.approval_tier == 'auto')
|
||||
ai_pending = rules.filtered(lambda r: r.created_by == 'ai' and r.approval_tier == 'needs_approval')
|
||||
return admin_rules | ai_auto | ai_pending
|
||||
|
||||
def _load_match_history(self, domain=None, limit=None):
|
||||
limit = limit or int(self._get_config('history_in_prompt', '50'))
|
||||
history_domain = [('company_id', '=', self.env.company.id)]
|
||||
if domain:
|
||||
history_domain.append(('tool_name', 'ilike', domain))
|
||||
return self.env['fusion.accounting.match.history'].search(
|
||||
history_domain, limit=limit, order='proposed_at desc',
|
||||
)
|
||||
|
||||
def _build_system_prompt(self, rules, history, context=None, domain=None):
|
||||
from .prompts.system_prompt import build_system_prompt
|
||||
from .prompts.domain_prompts import get_domain_prompt
|
||||
base = build_system_prompt(rules, history, context)
|
||||
if domain:
|
||||
domain_prompt = get_domain_prompt(domain)
|
||||
if domain_prompt:
|
||||
base = f"{base}\n\n{domain_prompt}"
|
||||
return base
|
||||
|
||||
def _execute_tool(self, tool_name, params, session_id=None):
|
||||
from .tools import TOOL_DISPATCH
|
||||
if tool_name not in TOOL_DISPATCH:
|
||||
return {'error': f'Unknown tool: {tool_name}'}
|
||||
try:
|
||||
result = TOOL_DISPATCH[tool_name](self.env, params)
|
||||
return result
|
||||
except Exception as e:
|
||||
_logger.error("Tool execution error (%s): %s", tool_name, e)
|
||||
return {'error': str(e)}
|
||||
|
||||
def _log_match_history(self, session, tool_name, params, result, reasoning='',
|
||||
confidence=0.0, rule=None, tier='1'):
|
||||
vals = {
|
||||
'session_id': session.id if session else False,
|
||||
'tool_name': tool_name,
|
||||
'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,
|
||||
'proposed_at': fields.Datetime.now(),
|
||||
'decision': 'auto' if tier in ('1', '2') else 'pending',
|
||||
'company_id': self.env.company.id,
|
||||
}
|
||||
return self.env['fusion.accounting.match.history'].sudo().create(vals)
|
||||
|
||||
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."))
|
||||
|
||||
adapter = self._get_adapter()
|
||||
provider = self._get_config('ai_provider', 'claude')
|
||||
|
||||
# Pin provider to session to prevent cross-adapter message contamination (C5)
|
||||
if session.ai_provider and session.ai_provider != provider:
|
||||
_logger.warning(
|
||||
"Session %s was started with %s but current provider is %s. "
|
||||
"Keeping original provider to avoid message format conflicts.",
|
||||
session.name, session.ai_provider, provider,
|
||||
)
|
||||
provider = session.ai_provider
|
||||
if provider == 'claude':
|
||||
adapter = self.env['fusion.accounting.adapter.claude']
|
||||
else:
|
||||
adapter = self.env['fusion.accounting.adapter.openai']
|
||||
|
||||
tools = self._get_tools_for_user()
|
||||
tool_definitions = self._build_tool_definitions(tools)
|
||||
rules = self._load_rules()
|
||||
history = self._load_match_history()
|
||||
system_prompt = self._build_system_prompt(
|
||||
rules, history, context, domain=session.context_domain,
|
||||
)
|
||||
|
||||
messages_json = json.loads(session.message_ids_json or '[]')
|
||||
|
||||
# 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=thinking or '',
|
||||
confidence=tc.get('confidence', 0.0),
|
||||
tier='3',
|
||||
)
|
||||
tool_results.append({
|
||||
'tool_call_id': tc.get('id', ''),
|
||||
'result': json.dumps({
|
||||
'status': 'pending_approval',
|
||||
'match_history_id': history_rec.id,
|
||||
'message': f'Action requires user approval. Match history ID: {history_rec.id}',
|
||||
}),
|
||||
})
|
||||
tool_calls_log.append({
|
||||
'name': tool_name,
|
||||
'tier': tier,
|
||||
'status': 'pending_approval',
|
||||
'summary': self._build_tool_call_summary(tool_name, tool_params, None),
|
||||
})
|
||||
_execution_state[session.id]['tool_calls'][-1]['status'] = 'pending'
|
||||
else:
|
||||
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=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:
|
||||
_logger.exception("Error in _check_rule_proposal for tool %s", tool_name)
|
||||
|
||||
messages_json = adapter.append_tool_results(
|
||||
messages_json, response, tool_results,
|
||||
)
|
||||
session.write({'tool_call_count': session.tool_call_count + len(tool_results)})
|
||||
|
||||
# C2: Short-circuit loop when Tier 3 actions are pending —
|
||||
# force a final text response so the AI can present approval cards
|
||||
if has_pending_tier3:
|
||||
try:
|
||||
response = adapter.call_with_tools(
|
||||
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)
|
||||
messages_json.append({
|
||||
'role': 'assistant',
|
||||
'content': response.get('text', 'I have proposed actions that require your approval.'),
|
||||
})
|
||||
except Exception:
|
||||
messages_json.append({
|
||||
'role': 'assistant',
|
||||
'content': 'I have proposed actions that require your approval. Please review the pending items above.',
|
||||
})
|
||||
break
|
||||
else:
|
||||
assistant_text = response.get('text', '')
|
||||
messages_json.append({'role': 'assistant', 'content': assistant_text})
|
||||
break
|
||||
else:
|
||||
# Loop exhausted — force a final text response without tools
|
||||
try:
|
||||
response = adapter.call_with_tools(
|
||||
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)
|
||||
messages_json.append({
|
||||
'role': 'assistant',
|
||||
'content': response.get('text', 'I reached the maximum number of steps. Please continue the conversation.'),
|
||||
})
|
||||
except Exception:
|
||||
_logger.warning("Failed to get final summary after max tool calls")
|
||||
|
||||
session.write({
|
||||
'message_ids_json': json.dumps(messages_json),
|
||||
'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': model_override or adapter._get_model_name(),
|
||||
})
|
||||
|
||||
pending = self.env['fusion.accounting.match.history'].search([
|
||||
('session_id', '=', session.id),
|
||||
('decision', '=', 'pending'),
|
||||
])
|
||||
|
||||
# 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', ''),
|
||||
'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':
|
||||
raise UserError(_("Action not found or already decided."))
|
||||
|
||||
params = json.loads(history.tool_params or '{}')
|
||||
result = self._execute_tool(history.tool_name, params, history.session_id.id)
|
||||
|
||||
history.write({
|
||||
'decision': 'approved',
|
||||
'decided_at': fields.Datetime.now(),
|
||||
'decided_by': self.env.user.id,
|
||||
'tool_result': json.dumps(result) if not isinstance(result, str) else result,
|
||||
})
|
||||
if history.rule_id:
|
||||
history.rule_id.sudo()._record_decision(approved=True)
|
||||
|
||||
# C1: Update session messages_json so next chat turn has coherent history
|
||||
self._update_session_after_decision(history, result)
|
||||
|
||||
# M8: Trigger promotion check after approval
|
||||
try:
|
||||
self.env['fusion.accounting.scoring'].check_promotions()
|
||||
except Exception:
|
||||
_logger.exception("Error checking promotions after approval")
|
||||
|
||||
return result
|
||||
|
||||
def _check_rule_proposal(self, tool_name, params, session):
|
||||
"""Detect repeated patterns and propose new rules when 3+ identical matches."""
|
||||
recent = self.env['fusion.accounting.match.history'].search([
|
||||
('tool_name', '=', tool_name),
|
||||
('decision', 'in', ('approved', 'auto')),
|
||||
('company_id', '=', self.env.company.id),
|
||||
], limit=20, order='proposed_at desc')
|
||||
|
||||
if len(recent) < 3:
|
||||
return
|
||||
|
||||
from collections import Counter
|
||||
param_keys = []
|
||||
for h in recent:
|
||||
try:
|
||||
p = json.loads(h.tool_params or '{}')
|
||||
key_parts = []
|
||||
for k in sorted(p.keys()):
|
||||
if k not in ('limit', 'date_from', 'date_to'):
|
||||
key_parts.append(f'{k}={json.dumps(p[k], sort_keys=True)}')
|
||||
if key_parts:
|
||||
param_keys.append('|'.join(key_parts))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
counts = Counter(param_keys)
|
||||
for pattern, count in counts.most_common(3):
|
||||
if count < 3:
|
||||
break
|
||||
existing = self.env['fusion.accounting.rule'].search([
|
||||
('match_logic', 'ilike', pattern[:50]),
|
||||
('company_id', '=', self.env.company.id),
|
||||
], limit=1)
|
||||
if existing:
|
||||
continue
|
||||
|
||||
self.env['fusion.accounting.rule'].create({
|
||||
'name': f'AI Pattern: {tool_name} ({pattern[:40]})',
|
||||
'rule_type': 'match',
|
||||
'description': f'Automatically detected pattern from {count} approved uses of {tool_name}.',
|
||||
'match_logic': f'When using {tool_name} with parameters matching: {pattern}',
|
||||
'created_by': 'ai',
|
||||
'approval_tier': 'needs_approval',
|
||||
'company_id': self.env.company.id,
|
||||
})
|
||||
_logger.info("AI proposed rule for pattern: %s (%d matches)", tool_name, count)
|
||||
|
||||
def reject_action(self, match_history_id, reason=''):
|
||||
history = self.env['fusion.accounting.match.history'].browse(match_history_id)
|
||||
if not history.exists() or history.decision != 'pending':
|
||||
raise UserError(_("Action not found or already decided."))
|
||||
|
||||
history.write({
|
||||
'decision': 'rejected',
|
||||
'decided_at': fields.Datetime.now(),
|
||||
'decided_by': self.env.user.id,
|
||||
'rejection_reason': reason,
|
||||
})
|
||||
if history.rule_id:
|
||||
history.rule_id.sudo()._record_decision(approved=False)
|
||||
|
||||
# C1: Update session messages_json so next chat turn has coherent history
|
||||
reject_result = {'status': 'rejected', 'reason': reason}
|
||||
self._update_session_after_decision(history, reject_result)
|
||||
|
||||
return reject_result
|
||||
|
||||
def _update_session_after_decision(self, history, result):
|
||||
"""Update session messages_json to replace pending_approval placeholder
|
||||
with actual tool result, preventing dangling tool_use blocks."""
|
||||
session = history.session_id
|
||||
if not session or not session.message_ids_json:
|
||||
return
|
||||
try:
|
||||
messages = json.loads(session.message_ids_json)
|
||||
result_str = json.dumps(result) if not isinstance(result, str) else result
|
||||
updated = False
|
||||
for msg in messages:
|
||||
if msg.get('role') != 'user':
|
||||
continue
|
||||
content = msg.get('content')
|
||||
if isinstance(content, list):
|
||||
for block in content:
|
||||
if (isinstance(block, dict) and block.get('type') == 'tool_result'
|
||||
and 'pending_approval' in str(block.get('content', ''))):
|
||||
# Check if this is the matching tool_result block
|
||||
if str(history.id) in str(block.get('content', '')):
|
||||
block['content'] = result_str
|
||||
updated = True
|
||||
break
|
||||
if updated:
|
||||
break
|
||||
if updated:
|
||||
session.write({'message_ids_json': json.dumps(messages)})
|
||||
except Exception:
|
||||
_logger.warning("Failed to update session messages after decision for history %s", history.id)
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# Cron: Auto-Reconcile Inter-Account Transfers
|
||||
# ----------------------------------------------------------------
|
||||
@api.model
|
||||
def _cron_reconcile_transfers(self):
|
||||
"""Automatically reconcile inter-account credit card payments.
|
||||
|
||||
When a payment is made from a bank account (e.g. Scotia Current) to a
|
||||
credit card (e.g. Scotia Passport Visa), two bank statement lines appear:
|
||||
- Source side: "MB-CREDIT CARD" (negative) — reconciled by model 38/35
|
||||
- CC side: "PAYMENT FROM *7814" (positive) — needs matching
|
||||
|
||||
The source-side reconciliation creates outstanding entries on account 493.
|
||||
This cron matches the CC-side lines against those outstanding entries by
|
||||
exact amount and closest date (within 3 days).
|
||||
"""
|
||||
AML = self.env['account.move.line'].sudo()
|
||||
BSL = self.env['account.bank.statement.line'].sudo()
|
||||
company_partner_id = self.env.company.partner_id.id
|
||||
|
||||
total_reconciled = 0
|
||||
|
||||
for source_jid, cc_jid, outstanding_acct_id in TRANSFER_PAIRS:
|
||||
# Find all unreconciled INCOMING lines on the credit card journal
|
||||
cc_lines = BSL.search([
|
||||
('journal_id', '=', cc_jid),
|
||||
('is_reconciled', '=', False),
|
||||
('amount', '>', 0), # Incoming payments only
|
||||
('company_id', '=', self.env.company.id),
|
||||
])
|
||||
if not cc_lines:
|
||||
continue
|
||||
|
||||
journal_name = cc_lines[0].journal_id.name
|
||||
_logger.info(
|
||||
"Transfer reconcile: %s — %d incoming unreconciled lines",
|
||||
journal_name, len(cc_lines),
|
||||
)
|
||||
|
||||
reconciled = 0
|
||||
skipped = 0
|
||||
|
||||
for line in cc_lines:
|
||||
line_date = line.move_id.date
|
||||
amount = line.amount
|
||||
|
||||
# Find outstanding entries with exact matching amount
|
||||
candidates = AML.search([
|
||||
('account_id', '=', outstanding_acct_id),
|
||||
('partner_id', '=', company_partner_id),
|
||||
('reconciled', '=', False),
|
||||
('amount_residual', '=', amount),
|
||||
])
|
||||
|
||||
if not candidates:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# Pick the candidate closest in date (within 3 days)
|
||||
best = None
|
||||
best_gap = 999
|
||||
for c in candidates:
|
||||
gap = abs((c.date - line_date).days)
|
||||
if gap < best_gap:
|
||||
best_gap = gap
|
||||
best = c
|
||||
|
||||
if best_gap > 7:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# Set partner and reconcile
|
||||
try:
|
||||
line.partner_id = company_partner_id
|
||||
line.set_line_bank_statement_line(best.ids)
|
||||
reconciled += 1
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
"Transfer reconcile failed: line %s (%s, $%.2f): %s",
|
||||
line.id, line.payment_ref, amount, e,
|
||||
)
|
||||
|
||||
# Commit every 50 lines to avoid long transactions
|
||||
if reconciled % 50 == 0 and reconciled > 0:
|
||||
self.env.cr.commit()
|
||||
|
||||
self.env.cr.commit()
|
||||
total_reconciled += reconciled
|
||||
_logger.info(
|
||||
"Transfer reconcile: %s — reconciled %d, skipped %d",
|
||||
journal_name, reconciled, skipped,
|
||||
)
|
||||
|
||||
_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),
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
from . import system_prompt
|
||||
from . import domain_prompts
|
||||
|
||||
237
fusion_accounting_ai/services/prompts/domain_prompts.py
Normal file
237
fusion_accounting_ai/services/prompts/domain_prompts.py
Normal file
@@ -0,0 +1,237 @@
|
||||
DOMAIN_PROMPTS = {
|
||||
'bank_reconciliation': """
|
||||
BANK RECONCILIATION CONTEXT:
|
||||
You are helping with bank statement reconciliation. Key concepts:
|
||||
- Bank statement lines (account.bank.statement.line) represent transactions from the bank feed.
|
||||
- Each line needs to be matched to one or more journal items (account.move.line).
|
||||
- Matching is done via set_line_bank_statement_line(move_line_ids).
|
||||
- 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': """
|
||||
HST/GST MANAGEMENT CONTEXT:
|
||||
You are helping with Canadian HST/GST tax management.
|
||||
- HST Collected is tracked on account 2005 (credit balance = liability).
|
||||
- Input Tax Credits (ITCs) are on account 2006 (debit balance = asset).
|
||||
- Net HST = Collected - ITCs. Positive means owing to CRA.
|
||||
- Quarterly filing periods. Check for missing tax on invoices/bills.
|
||||
- All vendor bills should have ITCs unless explicitly exempt.
|
||||
- HST Purchase tax ID is 20 (13%). No Tax Purchase ID is 32 (0%).
|
||||
|
||||
HST FILING WORKFLOW (4 phases — follow this order):
|
||||
|
||||
PHASE 1 — REPORTS: Run all at once:
|
||||
calculate_hst_balance, get_tax_report, find_missing_itc_bills,
|
||||
find_missing_tax_invoices, audit_tax_compliance.
|
||||
Present summary with HST position (owing vs refund).
|
||||
|
||||
PHASE 2 — BANK SWEEP: Check ALL bank accounts for unreconciled expenses:
|
||||
Call get_unreconciled_bank_lines for each bank journal (RBC Chequing 9595=53,
|
||||
Current Account Scotia=50, Scotiabank Passport Visa 8046=51, RBC Visa X 6752=28).
|
||||
Present ALL unreconciled expense lines (negative amounts) as a fusion-table
|
||||
with your recommendation per row.
|
||||
|
||||
PHASE 3 — PER-LINE PROCESSING: For each flagged expense line:
|
||||
0. FIRST: check_recurring_pattern(line_id=X) — if match found, follow action_note
|
||||
instructions EXACTLY (account, HST, partner, reconcile model). No user input needed
|
||||
for recurring payments. If a reconcile_model_id is returned, use apply_reconcile_model.
|
||||
1. get_bank_line_details — check if a vendor bill already exists for same amount/date
|
||||
2. find_similar_bank_lines — check history AND vendor_tax_pattern for coding/tax pattern
|
||||
3. CRITICAL: Check vendor_tax_pattern.is_po_vendor flag:
|
||||
- If is_po_vendor=true: This vendor's bills come from Purchase Orders. Do NOT create
|
||||
a new bill. Instead, use get_unpaid_bills to find the existing bill and propose
|
||||
match_bank_line_to_payments to match the bank payment to that bill.
|
||||
- If is_po_vendor=false: Proceed with bill creation workflow below.
|
||||
4. If bill already exists → propose match_bank_line_to_payments
|
||||
5. If no bill but history match → propose create_vendor_bill with same coding pattern
|
||||
6. If no bill and no history → ask user: "Does this expense include HST?"
|
||||
7. search_partners — find the vendor by keyword from the bank description
|
||||
8. Once confirmed → create_vendor_bill + register_bill_payment (Tier 3, needs approval)
|
||||
9. Alternative: user can choose "Direct GL" → create_expense_entry (Tier 3)
|
||||
For expenses that obviously have no HST (bank fees, interest charges, insurance),
|
||||
proactively recommend "No HST" and explain why.
|
||||
|
||||
PO-TRACKED VENDORS (do NOT create bills for these — bills come from Purchase Orders):
|
||||
When find_similar_bank_lines returns is_po_vendor=true or the vendor_tax_pattern
|
||||
note starts with "PO-TRACKED VENDOR", the bill already exists or will be created
|
||||
from a PO. Your job is ONLY to find the existing unpaid bill and match the bank
|
||||
payment to it. If no unpaid bill exists, flag it for the user: "This is a PO vendor
|
||||
but no matching bill was found — the PO may not have been billed yet."
|
||||
|
||||
PHASE 4 — VERIFICATION: Re-run calculate_hst_balance and get_tax_report
|
||||
to show the updated HST position after all expenses are recorded.
|
||||
|
||||
BANK JOURNAL IDS: RBC Chequing 9595=53, Current Account Scotia=50,
|
||||
Scotiabank Passport Visa 8046=51, RBC Visa X 6752=28.
|
||||
MISC JOURNAL: ID=3 (for direct GL expense entries).
|
||||
""",
|
||||
|
||||
'accounts_receivable': """
|
||||
ACCOUNTS RECEIVABLE CONTEXT:
|
||||
- AR aging: current, 1-30, 31-60, 61-90, 90+ days overdue.
|
||||
- Follow-up actions escalate by aging bucket.
|
||||
- Payments should be matched to specific invoices.
|
||||
- Unmatched payments sit on the Outstanding Receipts account (1122).
|
||||
""",
|
||||
|
||||
'accounts_payable': """
|
||||
ACCOUNTS PAYABLE CONTEXT:
|
||||
- AP aging mirrors AR: current through 90+ days.
|
||||
- Watch for duplicate bills (same vendor + amount + date).
|
||||
- Bills should match purchase orders when applicable.
|
||||
- Tax on bills should match the vendor's fiscal position.
|
||||
""",
|
||||
|
||||
'journal_review': """
|
||||
JOURNAL REVIEW CONTEXT:
|
||||
- Check for wrong-direction balances (e.g., expense account with credit balance).
|
||||
- Detect duplicate entries (same partner + amount + date + journal).
|
||||
- Flag entries on unlikely accounts (revenue on a tax account, etc.).
|
||||
- Sequence gaps may indicate deleted entries.
|
||||
- Draft entries older than 30 days should be reviewed.
|
||||
""",
|
||||
|
||||
'month_end': """
|
||||
MONTH-END CLOSE CONTEXT:
|
||||
- Aggregate all domain checks into a close checklist.
|
||||
- Verify all bank reconciliations are current.
|
||||
- Check accrual account balances (vacation, sick leave, etc.).
|
||||
- Verify no entries exist after lock date.
|
||||
- Run hash integrity check.
|
||||
- Produce period trial balance summary.
|
||||
""",
|
||||
|
||||
'payroll_verification': """
|
||||
PAYROLL VERIFICATION CONTEXT:
|
||||
- Cross-reference payroll journal entries to bank statement cheques.
|
||||
- Verify CPP, EI, and income tax deductions against CRA rate tables.
|
||||
- Check CRA remittance account balance vs payments made.
|
||||
""",
|
||||
|
||||
'inventory': """
|
||||
INVENTORY & COGS CONTEXT:
|
||||
- Stock In Hand tracked on account 1069.
|
||||
- Price differences on account 5010 (PO price vs bill price).
|
||||
- COGS ratio by product category helps spot anomalies.
|
||||
- Large inventory adjustments need review.
|
||||
""",
|
||||
|
||||
'adp': """
|
||||
ADP (ASSISTIVE DEVICE PROGRAM) RECONCILIATION CONTEXT:
|
||||
- ADP Receivable tracked on account 1101.
|
||||
- ADP invoices have customer portion + ADP portion = total.
|
||||
- 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': """
|
||||
FINANCIAL REPORTING CONTEXT:
|
||||
- Reports use Odoo's account.report engine.
|
||||
- Available: P&L, Balance Sheet, Trial Balance, Cash Flow.
|
||||
- Period comparison available for trend analysis.
|
||||
- Export to PDF or XLSX for external distribution.
|
||||
""",
|
||||
|
||||
'audit': """
|
||||
AUDIT & INTEGRITY CONTEXT:
|
||||
- Run comprehensive checks on posted entries.
|
||||
- Verify hash chain integrity on journals.
|
||||
- Check sequence continuity.
|
||||
- Flag entries with chatter messages for review tracking.
|
||||
- Audit status per account: todo / reviewed / supervised / anomaly.
|
||||
""",
|
||||
|
||||
'payroll_management': """
|
||||
PAYROLL MANAGEMENT CONTEXT:
|
||||
- Parse pasted payroll summaries from QBO or fusion_payroll.
|
||||
- Create payroll journal entries with proper debit/credit lines.
|
||||
- Match payroll cheques to bank statement lines.
|
||||
- Calculate CRA obligations (CPP employer + employee, EI, income tax).
|
||||
- Prepare CRA remittance payment entries.
|
||||
""",
|
||||
}
|
||||
|
||||
|
||||
# A3/A5: Aliases so common domain variations still match a prompt
|
||||
DOMAIN_ALIASES = {
|
||||
'bank': 'bank_reconciliation',
|
||||
'bank_recon': 'bank_reconciliation',
|
||||
'hst': 'hst_management',
|
||||
'gst': 'hst_management',
|
||||
'tax': 'hst_management',
|
||||
'ar': 'accounts_receivable',
|
||||
'receivable': 'accounts_receivable',
|
||||
'ap': 'accounts_payable',
|
||||
'payable': 'accounts_payable',
|
||||
'journal': 'journal_review',
|
||||
'close': 'month_end',
|
||||
'month_end_close': 'month_end',
|
||||
'payroll': 'payroll_management',
|
||||
'payroll_verify': 'payroll_verification',
|
||||
'stock': 'inventory',
|
||||
'cogs': 'inventory',
|
||||
'report': 'reporting',
|
||||
'reports': 'reporting',
|
||||
'financial': 'reporting',
|
||||
}
|
||||
|
||||
|
||||
def get_domain_prompt(domain):
|
||||
if not domain:
|
||||
return ''
|
||||
# Try exact match first, then aliases
|
||||
prompt = DOMAIN_PROMPTS.get(domain, '')
|
||||
if not prompt:
|
||||
resolved = DOMAIN_ALIASES.get(domain, domain)
|
||||
prompt = DOMAIN_PROMPTS.get(resolved, '')
|
||||
return prompt
|
||||
188
fusion_accounting_ai/services/prompts/system_prompt.py
Normal file
188
fusion_accounting_ai/services/prompts/system_prompt.py
Normal file
@@ -0,0 +1,188 @@
|
||||
import json
|
||||
|
||||
|
||||
def build_system_prompt(rules, history, context=None):
|
||||
parts = [
|
||||
CORE_SYSTEM_PROMPT,
|
||||
_build_rules_section(rules),
|
||||
_build_history_section(history),
|
||||
]
|
||||
if context:
|
||||
parts.append(_build_context_section(context))
|
||||
return '\n\n'.join(p for p in parts if p)
|
||||
|
||||
|
||||
CORE_SYSTEM_PROMPT = """You are Fusion AI, an expert accounting co-pilot embedded in Odoo 19.
|
||||
You assist with bank reconciliation, HST/GST management, AR/AP analysis, journal review,
|
||||
month-end close, payroll, inventory, ADP reconciliation, financial reporting, and auditing.
|
||||
|
||||
BEHAVIOUR:
|
||||
- Use tools to query and act on Odoo data. Never invent financial figures.
|
||||
- For Tier 1 tools: execute immediately and report results.
|
||||
- For Tier 2 tools: execute and log. Inform the user what was done.
|
||||
- For Tier 3 tools: propose the action with clear reasoning. The user must approve.
|
||||
- When proposing a Tier 3 action, explain: what you want to do, why, the amounts involved, and your confidence level.
|
||||
- Apply Fusion Rules (below) before general reasoning.
|
||||
- Reference match history for patterns the user has approved/rejected before.
|
||||
- Use Canadian English. Format monetary amounts with $ and two decimals.
|
||||
- When you encounter ambiguity, ask clarifying questions rather than guessing.
|
||||
|
||||
RESPONSE FORMATTING:
|
||||
- Use rich Markdown formatting in your responses. The chat renders Markdown as HTML.
|
||||
- Use **bold** for account names, amounts, and key terms.
|
||||
- Use ## and ### headers to organize sections in longer responses.
|
||||
- Use bullet lists (- item) for findings, issues, and action items.
|
||||
- Use numbered lists (1. item) for sequential steps or ranked items.
|
||||
- Use `code` for account codes, reference numbers, and technical IDs.
|
||||
- Use --- horizontal rules to separate sections in long reports.
|
||||
|
||||
INTERACTIVE TABLES (fusion-table) — MANDATORY FOR ACTIONABLE DATA:
|
||||
IMPORTANT: When a tool returns a list of records that the user could act on, you MUST use
|
||||
a ```fusion-table block instead of a Markdown table. This is REQUIRED — never use plain
|
||||
Markdown tables for actionable data. The fusion-table renders an interactive widget with
|
||||
checkboxes, your AI recommendations per row, user input fields, and bulk action buttons.
|
||||
|
||||
YOU MUST USE fusion-table FOR: missing ITCs/tax (find_missing_itc_bills, find_missing_tax_invoices),
|
||||
duplicate entries (find_duplicate_bills, find_duplicate_entries), overdue invoices (get_overdue_invoices),
|
||||
unreconciled lines (get_unreconciled_bank_lines, get_unreconciled_receipts, get_unmatched_payments,
|
||||
find_unreconciled_suspense), draft entries (find_draft_entries), wrong balances
|
||||
(find_wrong_direction_balances), sequence gaps (find_sequence_gaps), wrong accounts
|
||||
(find_wrong_account_entries), unpaid bills (get_unpaid_bills), and any other list where
|
||||
the user needs to review, dismiss, flag, or create rules for individual rows.
|
||||
|
||||
USE REGULAR MARKDOWN TABLES ONLY FOR: P&L (get_profit_loss), balance sheet (get_balance_sheet),
|
||||
trial balance (get_trial_balance), cash flow (get_cash_flow), period summaries, tax reports,
|
||||
and any purely informational/read-only data where there is nothing to act on per row.
|
||||
|
||||
Format: wrap a JSON object in a ```fusion-table fenced code block:
|
||||
|
||||
```fusion-table
|
||||
{
|
||||
"mode": "interactive",
|
||||
"title": "Descriptive Title",
|
||||
"columns": ["Col1", "Col2", "Col3"],
|
||||
"rows": [
|
||||
{"id": 123, "cells": ["val1", "val2", "val3"], "recommendation": {"action": "dismiss", "reason": "Brief explanation"}},
|
||||
{"id": 456, "cells": ["val1", "val2", "val3"], "recommendation": {"action": "flag", "reason": "Brief explanation"}}
|
||||
],
|
||||
"actions": ["dismiss", "flag", "create_rule"],
|
||||
"source_tool": "tool_name_that_produced_this"
|
||||
}
|
||||
```
|
||||
|
||||
- "mode": "interactive" (actionable) or "readonly" (informational but structured)
|
||||
- "id": the Odoo record ID (account.move id, account.bank.statement.line id, etc.)
|
||||
- "recommendation.action": one of "dismiss", "flag", "create_rule"
|
||||
- "recommendation.reason": short explanation of why you recommend this action
|
||||
- "actions": which bulk action buttons to show
|
||||
- "source_tool": the tool name that produced the data
|
||||
- You MUST provide a recommendation for each row when using interactive mode.
|
||||
- Format monetary amounts as "$X,XXX.XX" in cells.
|
||||
- Always include the record ID so actions can target the correct Odoo record.
|
||||
- Add a brief text summary before or after the fusion-table block for context.
|
||||
|
||||
LINKING TO ODOO RECORDS:
|
||||
- When referencing specific records, include clickable Odoo links.
|
||||
- Journal entries: [INV/2026/00123](/odoo/accounting/123) where 123 is the move ID.
|
||||
- Partners: [Customer Name](/odoo/contacts/456) where 456 is the partner ID.
|
||||
- Accounts: reference by code in bold, e.g. **1001 - Cash**.
|
||||
- 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.
|
||||
- Do not exceed the maximum tool calls per turn.
|
||||
- When presenting tool results, format them richly with tables, bold amounts, and links.
|
||||
"""
|
||||
|
||||
|
||||
def _build_rules_section(rules):
|
||||
if not rules:
|
||||
return ''
|
||||
lines = ['ACTIVE FUSION RULES:']
|
||||
for rule in rules:
|
||||
priority = 'ADMIN' if rule.created_by == 'admin' else 'AI'
|
||||
tier = 'auto' if rule.approval_tier == 'auto' else 'needs-approval'
|
||||
conf_str = f', confidence={rule.confidence_score:.0%}, uses={rule.total_uses}' if rule.total_uses > 0 else ''
|
||||
lines.append(
|
||||
f'- [{priority}/{tier}{conf_str}] {rule.name} ({rule.rule_type}): '
|
||||
f'{rule.description or rule.match_logic or "No description"}'
|
||||
)
|
||||
if rule.match_logic:
|
||||
logic_text = rule.match_logic[:500] # Prevent prompt bloat
|
||||
lines.append(f' Match logic: {logic_text}')
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def _build_history_section(history):
|
||||
if not history:
|
||||
return ''
|
||||
lines = ['RECENT MATCH HISTORY (learn from these patterns):']
|
||||
# A4: Don't hard-cap at 50 — the caller (_load_match_history) already
|
||||
# respects the history_in_prompt config setting
|
||||
for h in history:
|
||||
status = h.decision
|
||||
reason = ''
|
||||
if h.rejection_reason:
|
||||
reason = f' (reason: {h.rejection_reason})'
|
||||
lines.append(
|
||||
f'- {h.tool_name}: {status}{reason} '
|
||||
f'[confidence={h.ai_confidence:.0%}]'
|
||||
)
|
||||
if h.ai_reasoning:
|
||||
lines.append(f' Reasoning: {h.ai_reasoning[:200]}')
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def _build_context_section(context):
|
||||
if not context:
|
||||
return ''
|
||||
if isinstance(context, dict):
|
||||
parts = ['CURRENT CONTEXT:']
|
||||
for k, v in context.items():
|
||||
parts.append(f'- {k}: {v}')
|
||||
return '\n'.join(parts)
|
||||
return f'CURRENT CONTEXT: {context}'
|
||||
61
fusion_accounting_ai/services/scoring.py
Normal file
61
fusion_accounting_ai/services/scoring.py
Normal file
@@ -0,0 +1,61 @@
|
||||
import logging
|
||||
|
||||
from odoo import models, api
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionAccountingScoring(models.AbstractModel):
|
||||
_name = 'fusion.accounting.scoring'
|
||||
_description = 'Fusion Accounting Confidence Scoring'
|
||||
|
||||
def calculate_confidence(self, tool_name, scenario_key=None):
|
||||
domain = [('tool_name', '=', tool_name)]
|
||||
if scenario_key:
|
||||
domain.append(('tool_params', 'ilike', scenario_key))
|
||||
history = self.env['fusion.accounting.match.history'].search(domain)
|
||||
if not history:
|
||||
return 0.0
|
||||
decided = history.filtered(lambda h: h.decision in ('approved', 'rejected'))
|
||||
if not decided:
|
||||
return 0.0
|
||||
approved = len(decided.filtered(lambda h: h.decision == 'approved'))
|
||||
return approved / len(decided)
|
||||
|
||||
def check_promotions(self):
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
threshold = float(ICP.get_param('fusion_accounting.tier3_threshold', '0.95'))
|
||||
min_sample = int(ICP.get_param('fusion_accounting.tier3_min_sample', '30'))
|
||||
|
||||
rules = self.env['fusion.accounting.rule'].search([
|
||||
('active', '=', True),
|
||||
('approval_tier', '=', 'needs_approval'),
|
||||
])
|
||||
promoted = self.env['fusion.accounting.rule']
|
||||
for rule in rules:
|
||||
if rule.total_uses >= min_sample and rule.confidence_score >= threshold:
|
||||
rule.approval_tier = 'auto'
|
||||
promoted |= rule
|
||||
_logger.info(
|
||||
"Promoted rule '%s' to auto (confidence=%.2f, sample=%d)",
|
||||
rule.name, rule.confidence_score, rule.total_uses,
|
||||
)
|
||||
return promoted
|
||||
|
||||
def get_tool_stats(self, tool_name=None):
|
||||
domain = []
|
||||
if tool_name:
|
||||
domain.append(('tool_name', '=', tool_name))
|
||||
history = self.env['fusion.accounting.match.history'].search(domain)
|
||||
|
||||
stats = {}
|
||||
for h in history:
|
||||
if h.tool_name not in stats:
|
||||
stats[h.tool_name] = {
|
||||
'total': 0, 'approved': 0, 'rejected': 0,
|
||||
'pending': 0, 'auto': 0,
|
||||
}
|
||||
stats[h.tool_name]['total'] += 1
|
||||
if h.decision in stats[h.tool_name]:
|
||||
stats[h.tool_name][h.decision] += 1
|
||||
return stats
|
||||
@@ -0,0 +1,19 @@
|
||||
from .bank_reconciliation import TOOLS as BANK_RECON_TOOLS
|
||||
from .hst_management import TOOLS as HST_TOOLS
|
||||
from .accounts_receivable import TOOLS as AR_TOOLS
|
||||
from .accounts_payable import TOOLS as AP_TOOLS
|
||||
from .journal_review import TOOLS as JOURNAL_TOOLS
|
||||
from .month_end import TOOLS as MONTH_END_TOOLS
|
||||
from .payroll import TOOLS as PAYROLL_TOOLS
|
||||
from .inventory import TOOLS as INVENTORY_TOOLS
|
||||
from .adp import TOOLS as ADP_TOOLS
|
||||
from .reporting import TOOLS as REPORTING_TOOLS
|
||||
from .audit import TOOLS as AUDIT_TOOLS
|
||||
|
||||
TOOL_DISPATCH = {}
|
||||
for tools_dict in [
|
||||
BANK_RECON_TOOLS, HST_TOOLS, AR_TOOLS, AP_TOOLS, JOURNAL_TOOLS,
|
||||
MONTH_END_TOOLS, PAYROLL_TOOLS, INVENTORY_TOOLS, ADP_TOOLS,
|
||||
REPORTING_TOOLS, AUDIT_TOOLS,
|
||||
]:
|
||||
TOOL_DISPATCH.update(tools_dict)
|
||||
|
||||
406
fusion_accounting_ai/services/tools/accounts_payable.py
Normal file
406
fusion_accounting_ai/services/tools/accounts_payable.py
Normal file
@@ -0,0 +1,406 @@
|
||||
import logging
|
||||
from odoo import fields
|
||||
from datetime import timedelta
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_ap_aging(env, params):
|
||||
today = fields.Date.today()
|
||||
domain = [
|
||||
('account_id.account_type', '=', 'liability_payable'),
|
||||
('parent_state', '=', 'posted'),
|
||||
('reconciled', '=', False),
|
||||
('company_id', '=', env.company.id),
|
||||
]
|
||||
amls = env['account.move.line'].search(domain)
|
||||
|
||||
buckets = {'current': 0, '1_30': 0, '31_60': 0, '61_90': 0, '90_plus': 0}
|
||||
for aml in amls:
|
||||
amt = abs(aml.amount_residual)
|
||||
if not aml.date_maturity or aml.date_maturity >= today:
|
||||
buckets['current'] += amt
|
||||
else:
|
||||
days = (today - aml.date_maturity).days
|
||||
if days <= 30:
|
||||
buckets['1_30'] += amt
|
||||
elif days <= 60:
|
||||
buckets['31_60'] += amt
|
||||
elif days <= 90:
|
||||
buckets['61_90'] += amt
|
||||
else:
|
||||
buckets['90_plus'] += amt
|
||||
|
||||
return {'total': sum(buckets.values()), 'buckets': buckets, 'line_count': len(amls)}
|
||||
|
||||
|
||||
def find_duplicate_bills(env, params):
|
||||
window_days = int(params.get('window_days', 7))
|
||||
bills = env['account.move'].search([
|
||||
('move_type', 'in', ('in_invoice', 'in_refund')),
|
||||
('state', '=', 'posted'),
|
||||
('company_id', '=', env.company.id),
|
||||
], order='partner_id, amount_total, date')
|
||||
|
||||
duplicates = []
|
||||
prev = None
|
||||
for bill in bills:
|
||||
if prev and (
|
||||
prev.partner_id == bill.partner_id
|
||||
and abs(prev.amount_total - bill.amount_total) < 0.01
|
||||
and abs((prev.date - bill.date).days) <= window_days
|
||||
):
|
||||
duplicates.append({
|
||||
'bill_1': {'id': prev.id, 'name': prev.name, 'date': str(prev.date)},
|
||||
'bill_2': {'id': bill.id, 'name': bill.name, 'date': str(bill.date)},
|
||||
'partner': bill.partner_id.name,
|
||||
'amount': bill.amount_total,
|
||||
})
|
||||
prev = bill
|
||||
|
||||
return {'count': len(duplicates), 'duplicates': duplicates[:20]}
|
||||
|
||||
|
||||
def match_bill_to_po(env, params):
|
||||
bill_id = int(params['bill_id'])
|
||||
bill = env['account.move'].browse(bill_id)
|
||||
if not bill.exists():
|
||||
return {'error': 'Bill not found'}
|
||||
matches = []
|
||||
for line in bill.invoice_line_ids:
|
||||
if line.purchase_line_id:
|
||||
matches.append({
|
||||
'bill_line': line.name or '',
|
||||
'po': line.purchase_line_id.order_id.name,
|
||||
'po_line': line.purchase_line_id.name,
|
||||
'po_qty': line.purchase_line_id.product_qty,
|
||||
'bill_qty': line.quantity,
|
||||
'match': abs(line.quantity - line.purchase_line_id.product_qty) < 0.01,
|
||||
})
|
||||
return {'bill': bill.name, 'matches': matches, 'unmatched_lines': len(bill.invoice_line_ids) - len(matches)}
|
||||
|
||||
|
||||
def get_unpaid_bills(env, params):
|
||||
domain = [
|
||||
('move_type', 'in', ('in_invoice', 'in_refund')),
|
||||
('state', '=', 'posted'),
|
||||
('payment_state', 'in', ('not_paid', 'partial')),
|
||||
('company_id', '=', env.company.id),
|
||||
]
|
||||
if params.get('partner_id'):
|
||||
domain.append(('partner_id', '=', int(params['partner_id'])))
|
||||
bills = env['account.move'].search(domain, order='invoice_date_due asc', limit=int(params.get('limit', 50)))
|
||||
return {
|
||||
'count': len(bills),
|
||||
'total': sum(b.amount_residual for b in bills),
|
||||
'bills': [{
|
||||
'id': b.id, 'name': b.name,
|
||||
'partner': b.partner_id.name if b.partner_id else '',
|
||||
'amount_total': b.amount_total,
|
||||
'amount_residual': b.amount_residual,
|
||||
'date_due': str(b.invoice_date_due) if b.invoice_date_due else '',
|
||||
} for b in bills],
|
||||
}
|
||||
|
||||
|
||||
def verify_bill_taxes(env, params):
|
||||
bill_id = int(params['bill_id'])
|
||||
bill = env['account.move'].browse(bill_id)
|
||||
if not bill.exists():
|
||||
return {'error': 'Bill not found'}
|
||||
issues = []
|
||||
for line in bill.invoice_line_ids:
|
||||
if line.product_id and not line.tax_ids:
|
||||
issues.append({
|
||||
'line': line.name or line.product_id.name,
|
||||
'issue': 'No tax applied to product line',
|
||||
})
|
||||
return {'bill': bill.name, 'issues': issues, 'clean': len(issues) == 0}
|
||||
|
||||
|
||||
def get_payment_schedule(env, params):
|
||||
days_ahead = int(params.get('days_ahead', 30))
|
||||
cutoff = fields.Date.today() + timedelta(days=days_ahead)
|
||||
bills = env['account.move'].search([
|
||||
('move_type', '=', 'in_invoice'),
|
||||
('state', '=', 'posted'),
|
||||
('payment_state', 'in', ('not_paid', 'partial')),
|
||||
('invoice_date_due', '<=', cutoff),
|
||||
('company_id', '=', env.company.id),
|
||||
], order='invoice_date_due asc')
|
||||
return {
|
||||
'period': f'Next {days_ahead} days',
|
||||
'total': sum(b.amount_residual for b in bills),
|
||||
'bills': [{
|
||||
'id': b.id, 'name': b.name,
|
||||
'partner': b.partner_id.name if b.partner_id else '',
|
||||
'amount_residual': b.amount_residual,
|
||||
'date_due': str(b.invoice_date_due) if b.invoice_date_due else '',
|
||||
} for b in bills[:50]],
|
||||
}
|
||||
|
||||
|
||||
def search_partners(env, params):
|
||||
"""Search for partners/vendors by name keyword."""
|
||||
keyword = params.get('keyword', '')
|
||||
if not keyword or len(keyword) < 2:
|
||||
return {'error': 'Keyword must be at least 2 characters'}
|
||||
domain = [('name', 'ilike', keyword), ('company_id', 'in', [env.company.id, False])]
|
||||
if params.get('supplier_only'):
|
||||
domain.append(('supplier_rank', '>', 0))
|
||||
partners = env['res.partner'].search(domain, limit=int(params.get('limit', 20)))
|
||||
return {
|
||||
'count': len(partners),
|
||||
'partners': [{
|
||||
'id': p.id,
|
||||
'name': p.name,
|
||||
'supplier_rank': p.supplier_rank,
|
||||
'customer_rank': p.customer_rank,
|
||||
'vat': p.vat or '',
|
||||
'email': p.email or '',
|
||||
'phone': p.phone or '',
|
||||
} for p in partners],
|
||||
}
|
||||
|
||||
|
||||
def find_similar_bank_lines(env, params):
|
||||
"""Find past reconciled bank lines with similar description to suggest coding patterns.
|
||||
Also checks vendor bill tax patterns if a partner is identified."""
|
||||
keyword = params.get('keyword', '')
|
||||
if not keyword or len(keyword) < 3:
|
||||
return {'error': 'Keyword must be at least 3 characters'}
|
||||
# Find reconciled bank lines with matching payment_ref
|
||||
lines = env['account.bank.statement.line'].search([
|
||||
('is_reconciled', '=', True),
|
||||
('payment_ref', 'ilike', keyword),
|
||||
('company_id', '=', env.company.id),
|
||||
], order='date desc', limit=int(params.get('limit', 10)))
|
||||
|
||||
matches = []
|
||||
found_partner_id = None
|
||||
for line in lines:
|
||||
move = line.move_id
|
||||
if not move:
|
||||
continue
|
||||
expense_info = {'account_code': '', 'account_name': '', 'tax_applied': False, 'tax_amount': 0.0}
|
||||
for ml in move.line_ids:
|
||||
if ml.account_id.account_type in ('expense', 'expense_direct_cost', 'expense_depreciation'):
|
||||
expense_info['account_code'] = ml.account_id.code
|
||||
expense_info['account_name'] = ml.account_id.name
|
||||
expense_info['tax_applied'] = bool(ml.tax_ids)
|
||||
expense_info['tax_amount'] = sum(t.amount for t in ml.tax_ids) if ml.tax_ids else 0.0
|
||||
break
|
||||
if line.partner_id and not found_partner_id:
|
||||
found_partner_id = line.partner_id.id
|
||||
matches.append({
|
||||
'id': line.id,
|
||||
'date': str(line.date),
|
||||
'payment_ref': line.payment_ref or '',
|
||||
'amount': line.amount,
|
||||
'partner': line.partner_id.name if line.partner_id else '',
|
||||
'partner_id': line.partner_id.id if line.partner_id else None,
|
||||
'expense_account': expense_info['account_code'],
|
||||
'expense_account_name': expense_info['account_name'],
|
||||
'tax_applied': expense_info['tax_applied'],
|
||||
'tax_rate': expense_info['tax_amount'],
|
||||
})
|
||||
|
||||
result = {
|
||||
'keyword': keyword,
|
||||
'count': len(matches),
|
||||
'matches': matches,
|
||||
'suggestion': matches[0] if matches else None,
|
||||
}
|
||||
|
||||
# Check vendor tax profile cache first (fast), fall back to live query
|
||||
partner_id = found_partner_id or (int(params['partner_id']) if params.get('partner_id') else None)
|
||||
if partner_id:
|
||||
profile = env['fusion.vendor.tax.profile'].search([
|
||||
('partner_id', '=', partner_id),
|
||||
('company_id', '=', env.company.id),
|
||||
], limit=1)
|
||||
if profile:
|
||||
result['vendor_tax_pattern'] = {
|
||||
'source': 'cached_profile',
|
||||
'total_bills': profile.total_bills,
|
||||
'bills_with_tax': profile.bills_with_hst,
|
||||
'bills_no_tax': profile.bills_zero_rated,
|
||||
'avg_tax_pct': profile.avg_tax_pct,
|
||||
'tax_classification': profile.tax_classification,
|
||||
'tax_note': profile.tax_note,
|
||||
'primary_account_id': profile.primary_account_id.id if profile.primary_account_id else None,
|
||||
'primary_account_code': profile.primary_account_code or '',
|
||||
'is_foreign': profile.is_foreign,
|
||||
'is_po_vendor': profile.is_po_vendor,
|
||||
'po_count': profile.po_count,
|
||||
}
|
||||
else:
|
||||
# No cached profile — live query for new/small vendors
|
||||
bills = env['account.move'].search([
|
||||
('move_type', '=', 'in_invoice'), ('state', '=', 'posted'),
|
||||
('partner_id', '=', partner_id),
|
||||
], order='date desc', limit=10)
|
||||
tax_stats = {'source': 'live_query', 'total_bills': len(bills),
|
||||
'bills_with_tax': 0, 'bills_no_tax': 0,
|
||||
'avg_tax_pct': 0.0, 'tax_note': ''}
|
||||
tax_pcts = []
|
||||
for bill in bills:
|
||||
if bill.amount_tax > 0.01:
|
||||
tax_stats['bills_with_tax'] += 1
|
||||
if bill.amount_untaxed > 0:
|
||||
tax_pcts.append(round(bill.amount_tax / bill.amount_untaxed * 100, 2))
|
||||
else:
|
||||
tax_stats['bills_no_tax'] += 1
|
||||
if tax_pcts:
|
||||
tax_stats['avg_tax_pct'] = round(sum(tax_pcts) / len(tax_pcts), 2)
|
||||
if tax_stats['total_bills'] > 0:
|
||||
if tax_stats['bills_no_tax'] == tax_stats['total_bills']:
|
||||
tax_stats['tax_note'] = 'This vendor NEVER charges HST. All bills are zero-rated.'
|
||||
elif tax_stats['avg_tax_pct'] < 2.0 and tax_stats['bills_with_tax'] > 0:
|
||||
tax_stats['tax_note'] = (
|
||||
f'HST only on shipping (avg {tax_stats["avg_tax_pct"]}%). '
|
||||
f'Do NOT apply HST to full amount.'
|
||||
)
|
||||
elif tax_stats['avg_tax_pct'] >= 12.0:
|
||||
tax_stats['tax_note'] = f'Consistently charges HST at ~{tax_stats["avg_tax_pct"]}%.'
|
||||
result['vendor_tax_pattern'] = tax_stats
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def create_vendor_bill(env, params):
|
||||
"""[Tier 3] Create a vendor bill (account.move with move_type='in_invoice').
|
||||
Requires user approval before execution."""
|
||||
partner_id = int(params['partner_id'])
|
||||
invoice_date = params.get('invoice_date', str(fields.Date.today()))
|
||||
bill_lines = params.get('lines', [])
|
||||
if not bill_lines:
|
||||
return {'error': 'At least one invoice line is required'}
|
||||
|
||||
partner = env['res.partner'].browse(partner_id)
|
||||
if not partner.exists():
|
||||
return {'error': f'Partner not found: {partner_id}'}
|
||||
|
||||
invoice_line_vals = []
|
||||
for line in bill_lines:
|
||||
line_vals = {
|
||||
'name': line.get('description', 'Expense'),
|
||||
'price_unit': float(line.get('price_unit', 0)),
|
||||
'quantity': float(line.get('quantity', 1)),
|
||||
}
|
||||
if line.get('account_id'):
|
||||
line_vals['account_id'] = int(line['account_id'])
|
||||
if line.get('tax_ids'):
|
||||
line_vals['tax_ids'] = [(6, 0, [int(t) for t in line['tax_ids']])]
|
||||
invoice_line_vals.append((0, 0, line_vals))
|
||||
|
||||
try:
|
||||
bill = env['account.move'].create({
|
||||
'move_type': 'in_invoice',
|
||||
'partner_id': partner_id,
|
||||
'invoice_date': invoice_date,
|
||||
'date': invoice_date,
|
||||
'invoice_line_ids': invoice_line_vals,
|
||||
'company_id': env.company.id,
|
||||
})
|
||||
|
||||
if params.get('post', False):
|
||||
bill.action_post()
|
||||
|
||||
return {
|
||||
'status': 'created',
|
||||
'bill_id': bill.id,
|
||||
'bill_name': bill.name,
|
||||
'partner': partner.name,
|
||||
'amount_total': bill.amount_total,
|
||||
'state': bill.state,
|
||||
}
|
||||
except Exception as e:
|
||||
_logger.error("Failed to create vendor bill: %s", e)
|
||||
return {'error': str(e)}
|
||||
|
||||
|
||||
def register_bill_payment(env, params):
|
||||
"""[Tier 3] Register payment on a posted vendor bill and optionally reconcile to bank line.
|
||||
Requires user approval before execution."""
|
||||
bill_id = int(params['bill_id'])
|
||||
journal_id = int(params['journal_id'])
|
||||
bill = env['account.move'].browse(bill_id)
|
||||
if not bill.exists() or bill.state != 'posted':
|
||||
return {'error': 'Bill not found or not posted'}
|
||||
|
||||
payment_date = params.get('payment_date', str(fields.Date.today()))
|
||||
|
||||
try:
|
||||
# Use the payment register wizard
|
||||
ctx = {
|
||||
'active_model': 'account.move',
|
||||
'active_ids': [bill_id],
|
||||
}
|
||||
wizard = env['account.payment.register'].with_context(**ctx).create({
|
||||
'journal_id': journal_id,
|
||||
'payment_date': payment_date,
|
||||
})
|
||||
# Optionally set amount if provided (otherwise defaults to bill amount)
|
||||
if params.get('amount'):
|
||||
wizard.amount = float(params['amount'])
|
||||
|
||||
payments = wizard.action_create_payments()
|
||||
|
||||
# Find the created payment
|
||||
payment = None
|
||||
if isinstance(payments, dict) and payments.get('res_id'):
|
||||
payment = env['account.payment'].browse(payments['res_id'])
|
||||
elif isinstance(payments, dict) and payments.get('domain'):
|
||||
payment = env['account.payment'].search(payments['domain'], limit=1)
|
||||
else:
|
||||
# Fallback: find the latest payment for this bill
|
||||
payment = env['account.payment'].search([
|
||||
('partner_id', '=', bill.partner_id.id),
|
||||
], order='create_date desc', limit=1)
|
||||
|
||||
result = {
|
||||
'status': 'paid',
|
||||
'bill_id': bill_id,
|
||||
'bill_name': bill.name,
|
||||
'payment_state': bill.payment_state,
|
||||
}
|
||||
if payment:
|
||||
result['payment_id'] = payment.id
|
||||
result['payment_name'] = payment.name
|
||||
|
||||
# Optionally reconcile to a bank statement line
|
||||
if params.get('statement_line_id') and payment:
|
||||
try:
|
||||
st_line = env['account.bank.statement.line'].browse(int(params['statement_line_id']))
|
||||
if st_line.exists() and not st_line.is_reconciled:
|
||||
# Find the payment's move lines on the bank's outstanding account
|
||||
pay_move_lines = payment.move_id.line_ids.filtered(
|
||||
lambda l: l.account_id.reconcile and not l.reconciled
|
||||
)
|
||||
if pay_move_lines:
|
||||
st_line.set_line_bank_statement_line(pay_move_lines.ids)
|
||||
result['reconciled'] = True
|
||||
result['statement_line_id'] = st_line.id
|
||||
except Exception as e:
|
||||
_logger.warning("Payment created but bank reconciliation failed: %s", e)
|
||||
result['reconcile_error'] = str(e)
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
_logger.error("Failed to register payment: %s", e)
|
||||
return {'error': str(e)}
|
||||
|
||||
|
||||
TOOLS = {
|
||||
'get_ap_aging': get_ap_aging,
|
||||
'find_duplicate_bills': find_duplicate_bills,
|
||||
'match_bill_to_po': match_bill_to_po,
|
||||
'get_unpaid_bills': get_unpaid_bills,
|
||||
'verify_bill_taxes': verify_bill_taxes,
|
||||
'get_payment_schedule': get_payment_schedule,
|
||||
'search_partners': search_partners,
|
||||
'find_similar_bank_lines': find_similar_bank_lines,
|
||||
'create_vendor_bill': create_vendor_bill,
|
||||
'register_bill_payment': register_bill_payment,
|
||||
}
|
||||
194
fusion_accounting_ai/services/tools/accounts_receivable.py
Normal file
194
fusion_accounting_ai/services/tools/accounts_receivable.py
Normal file
@@ -0,0 +1,194 @@
|
||||
import logging
|
||||
from odoo import fields
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_ar_aging(env, params):
|
||||
today = fields.Date.today()
|
||||
domain = [
|
||||
('account_id.account_type', '=', 'asset_receivable'),
|
||||
('parent_state', '=', 'posted'),
|
||||
('reconciled', '=', False),
|
||||
('company_id', '=', env.company.id),
|
||||
]
|
||||
amls = env['account.move.line'].search(domain)
|
||||
|
||||
buckets = {'current': 0, '1_30': 0, '31_60': 0, '61_90': 0, '90_plus': 0}
|
||||
for aml in amls:
|
||||
if not aml.date_maturity or aml.date_maturity >= today:
|
||||
buckets['current'] += aml.amount_residual
|
||||
else:
|
||||
days = (today - aml.date_maturity).days
|
||||
if days <= 30:
|
||||
buckets['1_30'] += aml.amount_residual
|
||||
elif days <= 60:
|
||||
buckets['31_60'] += aml.amount_residual
|
||||
elif days <= 90:
|
||||
buckets['61_90'] += aml.amount_residual
|
||||
else:
|
||||
buckets['90_plus'] += aml.amount_residual
|
||||
|
||||
return {
|
||||
'total': sum(buckets.values()),
|
||||
'buckets': buckets,
|
||||
'line_count': len(amls),
|
||||
}
|
||||
|
||||
|
||||
def get_overdue_invoices(env, params):
|
||||
today = fields.Date.today()
|
||||
days_overdue = int(params.get('min_days_overdue', 1))
|
||||
from datetime import timedelta
|
||||
cutoff = today - timedelta(days=days_overdue)
|
||||
invoices = env['account.move'].search([
|
||||
('move_type', '=', 'out_invoice'),
|
||||
('state', '=', 'posted'),
|
||||
('payment_state', 'in', ('not_paid', 'partial')),
|
||||
('invoice_date_due', '<', cutoff),
|
||||
('company_id', '=', env.company.id),
|
||||
], order='invoice_date_due asc', limit=int(params.get('limit', 50)))
|
||||
return {
|
||||
'count': len(invoices),
|
||||
'invoices': [{
|
||||
'id': inv.id,
|
||||
'name': inv.name,
|
||||
'partner': inv.partner_id.name if inv.partner_id else '',
|
||||
'email': inv.partner_id.email or '' if inv.partner_id else '',
|
||||
'phone': inv.partner_id.phone or '' if inv.partner_id else '',
|
||||
'amount_total': inv.amount_total,
|
||||
'amount_residual': inv.amount_residual,
|
||||
'date_due': str(inv.invoice_date_due),
|
||||
'days_overdue': (today - inv.invoice_date_due).days,
|
||||
} for inv in invoices],
|
||||
}
|
||||
|
||||
|
||||
def get_partner_balance(env, params):
|
||||
"""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,
|
||||
'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,
|
||||
}
|
||||
|
||||
|
||||
def send_followup(env, params):
|
||||
partner_id = int(params['partner_id'])
|
||||
partner = env['res.partner'].browse(partner_id)
|
||||
if not partner.exists():
|
||||
return {'error': 'Partner not found'}
|
||||
options = {
|
||||
'partner_id': partner_id,
|
||||
'email': params.get('send_email', False),
|
||||
'print': params.get('print_letter', False),
|
||||
'sms': False,
|
||||
}
|
||||
if params.get('email_subject'):
|
||||
options['email_subject'] = params['email_subject']
|
||||
if params.get('body'):
|
||||
options['body'] = params['body']
|
||||
result = partner.execute_followup(options)
|
||||
return {'status': 'sent', 'partner': partner.name, 'result': str(result) if result else 'done'}
|
||||
|
||||
|
||||
def get_followup_report(env, params):
|
||||
partner_id = int(params['partner_id'])
|
||||
partner = env['res.partner'].browse(partner_id)
|
||||
if not partner.exists():
|
||||
return {'error': 'Partner not found'}
|
||||
try:
|
||||
report = env['account.followup.report']
|
||||
html = report._get_followup_report_html(partner)
|
||||
return {'partner': partner.name, 'html': html}
|
||||
except Exception as e:
|
||||
return {'error': str(e)}
|
||||
|
||||
|
||||
def reconcile_payment_to_invoice(env, params):
|
||||
move_line_ids = [int(x) for x in params['move_line_ids']]
|
||||
amls = env['account.move.line'].browse(move_line_ids)
|
||||
if len(amls) < 2:
|
||||
return {'error': 'Need at least 2 journal items to reconcile'}
|
||||
amls.reconcile()
|
||||
return {
|
||||
'status': 'reconciled',
|
||||
'move_line_ids': move_line_ids,
|
||||
}
|
||||
|
||||
|
||||
def get_unmatched_payments(env, params):
|
||||
domain = [
|
||||
('account_id.account_type', '=', 'asset_receivable'),
|
||||
('parent_state', '=', 'posted'),
|
||||
('reconciled', '=', False),
|
||||
('move_id.payment_id', '!=', False),
|
||||
('company_id', '=', env.company.id),
|
||||
]
|
||||
amls = env['account.move.line'].search(domain, order='date desc')
|
||||
return {
|
||||
'count': len(amls),
|
||||
'payments': [{
|
||||
'id': aml.id,
|
||||
'date': str(aml.date),
|
||||
'ref': aml.ref or aml.move_id.name,
|
||||
'partner': aml.partner_id.name if aml.partner_id else '',
|
||||
'amount': abs(aml.amount_residual),
|
||||
} for aml in amls[:50]],
|
||||
}
|
||||
|
||||
|
||||
TOOLS = {
|
||||
'get_ar_aging': get_ar_aging,
|
||||
'get_overdue_invoices': get_overdue_invoices,
|
||||
'get_partner_balance': get_partner_balance,
|
||||
'send_followup': send_followup,
|
||||
'get_followup_report': get_followup_report,
|
||||
'reconcile_payment_to_invoice': reconcile_payment_to_invoice,
|
||||
'get_unmatched_payments': get_unmatched_payments,
|
||||
}
|
||||
237
fusion_accounting_ai/services/tools/adp.py
Normal file
237
fusion_accounting_ai/services/tools/adp.py
Normal file
@@ -0,0 +1,237 @@
|
||||
import logging
|
||||
from odoo import fields
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_adp_receivable_aging(env, params):
|
||||
accounts = env['account.account'].search([
|
||||
('code', '=like', '1101%'),
|
||||
('company_ids', 'in', env.company.id),
|
||||
])
|
||||
today = fields.Date.today()
|
||||
amls = env['account.move.line'].search([
|
||||
('account_id', 'in', accounts.ids),
|
||||
('reconciled', '=', False),
|
||||
('parent_state', '=', 'posted'),
|
||||
])
|
||||
buckets = {'current': 0, '1_30': 0, '31_60': 0, '61_90': 0, '90_plus': 0}
|
||||
for aml in amls:
|
||||
amt = abs(aml.amount_residual)
|
||||
if not aml.date_maturity or aml.date_maturity >= today:
|
||||
buckets['current'] += amt
|
||||
else:
|
||||
days = (today - aml.date_maturity).days
|
||||
if days <= 30:
|
||||
buckets['1_30'] += amt
|
||||
elif days <= 60:
|
||||
buckets['31_60'] += amt
|
||||
elif days <= 90:
|
||||
buckets['61_90'] += amt
|
||||
else:
|
||||
buckets['90_plus'] += amt
|
||||
return {'total': sum(buckets.values()), 'buckets': buckets}
|
||||
|
||||
|
||||
def match_adp_payment_to_invoice(env, params):
|
||||
move_line_ids = [int(x) for x in params['move_line_ids']]
|
||||
amls = env['account.move.line'].browse(move_line_ids).exists()
|
||||
if len(amls) < 2:
|
||||
return {'error': 'Need at least 2 existing journal items to reconcile'}
|
||||
amls.reconcile()
|
||||
return {'status': 'matched', 'move_line_ids': amls.ids}
|
||||
|
||||
|
||||
def verify_adp_split(env, params):
|
||||
invoice_id = int(params['invoice_id'])
|
||||
invoice = env['account.move'].browse(invoice_id)
|
||||
if not invoice.exists():
|
||||
return {'error': 'Invoice not found'}
|
||||
lines = invoice.invoice_line_ids
|
||||
total = invoice.amount_untaxed
|
||||
return {
|
||||
'invoice': invoice.name,
|
||||
'total_untaxed': total,
|
||||
'total_with_tax': invoice.amount_total,
|
||||
'lines': [{'name': l.name, 'subtotal': l.price_subtotal, 'total': l.price_total} for l in lines],
|
||||
'balanced': abs(sum(l.price_subtotal for l in lines) - total) < 0.01,
|
||||
}
|
||||
|
||||
|
||||
def find_adp_without_payment(env, params):
|
||||
adp_partner = env['res.partner'].search([('name', 'ilike', 'ADP')], limit=1)
|
||||
if not adp_partner:
|
||||
return {'status': 'info', 'message': 'No ADP partner found in the system.'}
|
||||
invoices = env['account.move'].search([
|
||||
('partner_id', '=', adp_partner.id),
|
||||
('move_type', '=', 'out_invoice'),
|
||||
('state', '=', 'posted'),
|
||||
('payment_state', 'in', ('not_paid', 'partial')),
|
||||
])
|
||||
return {
|
||||
'count': len(invoices),
|
||||
'invoices': [{
|
||||
'id': inv.id, 'name': inv.name,
|
||||
'amount': inv.amount_residual, 'date': str(inv.date),
|
||||
} for inv in invoices[:20]],
|
||||
}
|
||||
|
||||
|
||||
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_ids', 'in', env.company.id),
|
||||
])
|
||||
domain = [
|
||||
('account_id', 'in', accounts.ids),
|
||||
('parent_state', '=', 'posted'),
|
||||
]
|
||||
if date_from:
|
||||
domain.append(('date', '>=', date_from))
|
||||
if date_to:
|
||||
domain.append(('date', '<=', date_to))
|
||||
lines = env['account.move.line'].search(domain)
|
||||
total_debit = sum(l.debit for l in lines)
|
||||
total_credit = sum(l.credit for l in lines)
|
||||
return {
|
||||
'period': f'{date_from or "all"} to {date_to or "now"}',
|
||||
'billed': total_debit,
|
||||
'collected': total_credit,
|
||||
'outstanding': total_debit - total_credit,
|
||||
}
|
||||
|
||||
|
||||
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,
|
||||
}
|
||||
164
fusion_accounting_ai/services/tools/audit.py
Normal file
164
fusion_accounting_ai/services/tools/audit.py
Normal file
@@ -0,0 +1,164 @@
|
||||
import logging
|
||||
from odoo import fields
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def audit_posted_entry(env, params):
|
||||
move_id = int(params['move_id'])
|
||||
move = env['account.move'].browse(move_id)
|
||||
if not move.exists():
|
||||
return {'error': 'Entry not found'}
|
||||
issues = []
|
||||
total_debit = sum(l.debit for l in move.line_ids)
|
||||
total_credit = sum(l.credit for l in move.line_ids)
|
||||
if abs(total_debit - total_credit) > 0.01:
|
||||
issues.append({'severity': 'critical', 'issue': f'Unbalanced entry: debit={total_debit}, credit={total_credit}'})
|
||||
for line in move.line_ids:
|
||||
if not line.account_id:
|
||||
issues.append({'severity': 'critical', 'issue': f'Line missing account: {line.name}'})
|
||||
if not move.line_ids:
|
||||
issues.append({'severity': 'warning', 'issue': 'Entry has no lines'})
|
||||
return {
|
||||
'move': move.name, 'date': str(move.date),
|
||||
'issues': issues, 'clean': len(issues) == 0,
|
||||
}
|
||||
|
||||
|
||||
def audit_account_balances(env, params):
|
||||
from .journal_review import find_wrong_direction_balances
|
||||
return find_wrong_direction_balances(env, params)
|
||||
|
||||
|
||||
def audit_tax_compliance(env, params):
|
||||
from .hst_management import find_missing_tax_invoices, find_missing_itc_bills
|
||||
invoices = find_missing_tax_invoices(env, params)
|
||||
bills = find_missing_itc_bills(env, params)
|
||||
return {
|
||||
'missing_tax_invoices': invoices.get('missing_tax_count', 0),
|
||||
'missing_itc_bills': bills.get('missing_itc_count', 0),
|
||||
'total_issues': invoices.get('missing_tax_count', 0) + bills.get('missing_itc_count', 0),
|
||||
}
|
||||
|
||||
|
||||
def audit_reconciliation_integrity(env, params):
|
||||
from .journal_review import verify_reconciliation_integrity
|
||||
return verify_reconciliation_integrity(env, params)
|
||||
|
||||
|
||||
def check_hash_chain(env, params):
|
||||
from .month_end import run_hash_integrity_check
|
||||
return run_hash_integrity_check(env, params)
|
||||
|
||||
|
||||
def check_sequence_gaps(env, params):
|
||||
from .journal_review import find_sequence_gaps
|
||||
return find_sequence_gaps(env, params)
|
||||
|
||||
|
||||
def flag_entry(env, params):
|
||||
move_id = int(params['move_id'])
|
||||
flag = params.get('flag', 'Review Required')
|
||||
recommendation = params.get('recommendation', '')
|
||||
move = env['account.move'].browse(move_id)
|
||||
if not move.exists():
|
||||
return {'error': 'Entry not found'}
|
||||
body = f'<strong>🏴 {flag}</strong><br/>{recommendation}'
|
||||
move.message_post(body=body, message_type='comment', subtype_xmlid='mail.mt_note')
|
||||
return {'status': 'flagged', 'move': move.name, 'flag': flag}
|
||||
|
||||
|
||||
def get_audit_status(env, params):
|
||||
try:
|
||||
AuditStatus = env['account.audit.account.status']
|
||||
except KeyError:
|
||||
return {'error': 'Audit status model (account.audit.account.status) is not available. The account_audit Enterprise module may not be installed.'}
|
||||
statuses = AuditStatus.search([])
|
||||
return {
|
||||
'statuses': [{
|
||||
'id': s.id,
|
||||
'account': s.account_id.name,
|
||||
'status': s.status,
|
||||
'audit': s.audit_id.display_name if s.audit_id else '',
|
||||
} for s in statuses[:50]],
|
||||
}
|
||||
|
||||
|
||||
def set_audit_status(env, params):
|
||||
try:
|
||||
AuditStatus = env['account.audit.account.status']
|
||||
except KeyError:
|
||||
return {'error': 'Audit status model (account.audit.account.status) is not available. The account_audit Enterprise module may not be installed.'}
|
||||
status_id = int(params['status_id'])
|
||||
new_status = params['status']
|
||||
rec = AuditStatus.browse(status_id)
|
||||
if not rec.exists():
|
||||
return {'error': 'Audit status record not found'}
|
||||
rec.status = new_status
|
||||
return {'status': 'updated', 'id': status_id, 'new_status': new_status}
|
||||
|
||||
|
||||
def get_audit_trail(env, params):
|
||||
move_id = int(params['move_id'])
|
||||
move = env['account.move'].browse(move_id)
|
||||
if not move.exists():
|
||||
return {'error': 'Entry not found'}
|
||||
messages = env['mail.message'].search([
|
||||
('model', '=', 'account.move'),
|
||||
('res_id', '=', move_id),
|
||||
], order='date desc', limit=20)
|
||||
return {
|
||||
'move': move.name,
|
||||
'messages': [{
|
||||
'date': str(m.date),
|
||||
'author': m.author_id.name if m.author_id else '',
|
||||
'body': m.body or '',
|
||||
'type': m.message_type,
|
||||
} for m in messages],
|
||||
}
|
||||
|
||||
|
||||
def run_full_audit(env, params):
|
||||
results = {}
|
||||
results['account_balances'] = audit_account_balances(env, params)
|
||||
results['tax_compliance'] = audit_tax_compliance(env, params)
|
||||
results['reconciliation'] = audit_reconciliation_integrity(env, params)
|
||||
results['hash_chain'] = check_hash_chain(env, params)
|
||||
results['sequence_gaps'] = check_sequence_gaps(env, params)
|
||||
|
||||
total_issues = 0
|
||||
for key, val in results.items():
|
||||
total_issues += val.get('count', 0) + val.get('total_issues', 0)
|
||||
|
||||
score = max(0, 100 - total_issues * 5)
|
||||
return {
|
||||
'score': min(100, score),
|
||||
'total_issues': total_issues,
|
||||
'details': results,
|
||||
}
|
||||
|
||||
|
||||
def get_audit_report(env, params):
|
||||
audit = run_full_audit(env, params)
|
||||
report_lines = [f"Audit Score: {audit['score']}/100", f"Total Issues: {audit['total_issues']}", '']
|
||||
for domain, detail in audit.get('details', {}).items():
|
||||
report_lines.append(f"--- {domain.replace('_', ' ').title()} ---")
|
||||
count = detail.get('count', detail.get('total_issues', 0))
|
||||
report_lines.append(f" Issues: {count}")
|
||||
return {'report': '\n'.join(report_lines), 'score': audit['score']}
|
||||
|
||||
|
||||
TOOLS = {
|
||||
'audit_posted_entry': audit_posted_entry,
|
||||
'audit_account_balances': audit_account_balances,
|
||||
'audit_tax_compliance': audit_tax_compliance,
|
||||
'audit_reconciliation_integrity': audit_reconciliation_integrity,
|
||||
'check_hash_chain': check_hash_chain,
|
||||
'check_sequence_gaps': check_sequence_gaps,
|
||||
'flag_entry': flag_entry,
|
||||
'get_audit_status': get_audit_status,
|
||||
'set_audit_status': set_audit_status,
|
||||
'get_audit_trail': get_audit_trail,
|
||||
'run_full_audit': run_full_audit,
|
||||
'get_audit_report': get_audit_report,
|
||||
}
|
||||
961
fusion_accounting_ai/services/tools/bank_reconciliation.py
Normal file
961
fusion_accounting_ai/services/tools/bank_reconciliation.py
Normal file
@@ -0,0 +1,961 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from odoo import fields
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_unreconciled_bank_lines(env, params):
|
||||
domain = [('is_reconciled', '=', False), ('company_id', '=', env.company.id)]
|
||||
if params.get('journal_id'):
|
||||
domain.append(('journal_id', '=', int(params['journal_id'])))
|
||||
if params.get('date_from'):
|
||||
domain.append(('date', '>=', params['date_from']))
|
||||
if params.get('date_to'):
|
||||
domain.append(('date', '<=', params['date_to']))
|
||||
if params.get('min_amount'):
|
||||
domain.append(('amount', '>=', float(params['min_amount'])))
|
||||
limit = int(params.get('limit', 50))
|
||||
lines = env['account.bank.statement.line'].search(domain, limit=limit, order='date desc')
|
||||
return {
|
||||
'count': len(lines),
|
||||
'total_amount': sum(abs(l.amount) for l in lines),
|
||||
'lines': [{
|
||||
'id': l.id,
|
||||
'date': str(l.date),
|
||||
'payment_ref': l.payment_ref or '',
|
||||
'partner_name': l.partner_name or (l.partner_id.name if l.partner_id else ''),
|
||||
'amount': l.amount,
|
||||
'journal': l.journal_id.name,
|
||||
} for l in lines],
|
||||
}
|
||||
|
||||
|
||||
def get_unreconciled_receipts(env, params):
|
||||
account_code = params.get('account_code', '1122')
|
||||
accounts = env['account.account'].search([
|
||||
('code', '=like', f'{account_code}%'),
|
||||
('company_ids', 'in', env.company.id),
|
||||
])
|
||||
domain = [
|
||||
('account_id', 'in', accounts.ids),
|
||||
('reconciled', '=', False),
|
||||
('parent_state', '=', 'posted'),
|
||||
('company_id', '=', env.company.id),
|
||||
]
|
||||
lines = env['account.move.line'].search(domain, order='date desc')
|
||||
return {
|
||||
'count': len(lines),
|
||||
'total_amount': sum(abs(l.amount_residual) for l in lines),
|
||||
'lines': [{
|
||||
'id': l.id,
|
||||
'date': str(l.date),
|
||||
'ref': l.ref or l.move_id.name,
|
||||
'partner': l.partner_id.name if l.partner_id else '',
|
||||
'amount_residual': l.amount_residual,
|
||||
} for l in lines],
|
||||
}
|
||||
|
||||
|
||||
def match_bank_line_to_payments(env, params):
|
||||
st_line_id = int(params['statement_line_id'])
|
||||
move_line_ids = [int(x) for x in params['move_line_ids']]
|
||||
st_line = env['account.bank.statement.line'].browse(st_line_id)
|
||||
if not st_line.exists():
|
||||
return {'error': 'Statement line not found'}
|
||||
st_line.set_line_bank_statement_line(move_line_ids)
|
||||
return {
|
||||
'status': 'matched',
|
||||
'statement_line_id': st_line_id,
|
||||
'matched_move_lines': move_line_ids,
|
||||
'is_reconciled': st_line.is_reconciled,
|
||||
}
|
||||
|
||||
|
||||
def auto_reconcile_bank_lines(env, params):
|
||||
company_id = params.get('company_id', env.company.id)
|
||||
lines = env['account.bank.statement.line'].search([
|
||||
('is_reconciled', '=', False),
|
||||
('company_id', '=', int(company_id)),
|
||||
])
|
||||
before_count = len(lines)
|
||||
lines._try_auto_reconcile_statement_lines(company_id=int(company_id))
|
||||
still_unreconciled = env['account.bank.statement.line'].search([
|
||||
('is_reconciled', '=', False),
|
||||
('company_id', '=', int(company_id)),
|
||||
])
|
||||
reconciled_count = before_count - len(still_unreconciled)
|
||||
return {
|
||||
'status': 'completed',
|
||||
'lines_before': before_count,
|
||||
'lines_reconciled': reconciled_count,
|
||||
'lines_remaining': len(still_unreconciled),
|
||||
}
|
||||
|
||||
|
||||
def apply_reconcile_model(env, params):
|
||||
model_id = int(params['model_id'])
|
||||
st_line_id = int(params['statement_line_id'])
|
||||
reco_model = env['account.reconcile.model'].browse(model_id)
|
||||
st_line = env['account.bank.statement.line'].browse(st_line_id)
|
||||
if not reco_model.exists() or not st_line.exists():
|
||||
return {'error': 'Model or statement line not found'}
|
||||
_liquidity_lines, suspense_lines, _other_lines = st_line._seek_for_lines()
|
||||
residual = sum(l.amount_residual for l in suspense_lines) if suspense_lines else st_line.amount
|
||||
write_off_vals = reco_model._get_write_off_move_lines_dict(st_line, residual)
|
||||
if write_off_vals:
|
||||
line_ids_create_command = [(0, 0, vals) for vals in write_off_vals]
|
||||
st_line.move_id.write({'line_ids': line_ids_create_command})
|
||||
return {
|
||||
'status': 'applied',
|
||||
'model': reco_model.name,
|
||||
'write_off_lines': len(write_off_vals) if write_off_vals else 0,
|
||||
}
|
||||
|
||||
|
||||
def unmatch_bank_line(env, params):
|
||||
st_line_id = int(params['statement_line_id'])
|
||||
st_line = env['account.bank.statement.line'].browse(st_line_id)
|
||||
if not st_line.exists():
|
||||
return {'error': 'Statement line not found'}
|
||||
st_line.action_unreconcile_entry()
|
||||
return {'status': 'unmatched', 'statement_line_id': st_line_id}
|
||||
|
||||
|
||||
def get_reconcile_suggestions(env, params):
|
||||
st_line_id = int(params['statement_line_id'])
|
||||
st_line = env['account.bank.statement.line'].browse(st_line_id)
|
||||
if not st_line.exists():
|
||||
return {'error': 'Statement line not found'}
|
||||
models = env['account.reconcile.model'].search([
|
||||
('company_id', '=', env.company.id),
|
||||
])
|
||||
return {
|
||||
'models': [{
|
||||
'id': m.id,
|
||||
'name': m.name,
|
||||
'trigger': m.trigger if hasattr(m, 'trigger') else 'manual',
|
||||
} for m in models],
|
||||
}
|
||||
|
||||
|
||||
def sum_payments_by_date(env, params):
|
||||
"""Sum payment/journal activity for a date range.
|
||||
IMPORTANT: Always pass journal_ids to filter to specific journals.
|
||||
Without journal_ids, returns totals across ALL journals which is
|
||||
almost never what you want for reconciliation."""
|
||||
date_from = params.get('date_from')
|
||||
date_to = params.get('date_to')
|
||||
if not date_from or not date_to:
|
||||
return {'error': 'date_from and date_to are required'}
|
||||
journal_ids = params.get('journal_ids', [])
|
||||
domain = [
|
||||
('parent_state', '=', 'posted'),
|
||||
('company_id', '=', env.company.id),
|
||||
('date', '>=', date_from),
|
||||
('date', '<=', date_to),
|
||||
]
|
||||
scope = 'all journals'
|
||||
if journal_ids:
|
||||
jids = [int(j) for j in journal_ids]
|
||||
domain.append(('journal_id', 'in', jids))
|
||||
journals = env['account.journal'].browse(jids)
|
||||
scope = ', '.join(j.name for j in journals if j.exists())
|
||||
else:
|
||||
# Without journal filter, include a warning and break down by journal
|
||||
pass
|
||||
|
||||
lines = env['account.move.line'].search(domain)
|
||||
total_debit = sum(l.debit for l in lines)
|
||||
total_credit = sum(l.credit for l in lines)
|
||||
|
||||
result = {
|
||||
'date_from': date_from,
|
||||
'date_to': date_to,
|
||||
'total_debit': total_debit,
|
||||
'total_credit': total_credit,
|
||||
'net': total_debit - total_credit,
|
||||
'line_count': len(lines),
|
||||
'scope': scope,
|
||||
}
|
||||
|
||||
# If no journal filter, add per-journal breakdown so AI doesn't
|
||||
# mistake company-wide totals for a specific journal's activity
|
||||
if not journal_ids:
|
||||
result['warning'] = (
|
||||
'No journal_ids filter was provided. These totals are across ALL '
|
||||
'journals in the company. To get card payment totals, pass the '
|
||||
'specific card/POS journal IDs.'
|
||||
)
|
||||
journal_totals = {}
|
||||
for l in lines:
|
||||
jname = l.journal_id.name
|
||||
if jname not in journal_totals:
|
||||
journal_totals[jname] = {'debit': 0.0, 'credit': 0.0, 'count': 0}
|
||||
journal_totals[jname]['debit'] += l.debit
|
||||
journal_totals[jname]['credit'] += l.credit
|
||||
journal_totals[jname]['count'] += 1
|
||||
result['by_journal'] = [
|
||||
{'journal': jn, 'debit': v['debit'], 'credit': v['credit'], 'count': v['count']}
|
||||
for jn, v in sorted(journal_totals.items(), key=lambda x: -x[1]['debit'])
|
||||
][:15]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_bank_line_details(env, params):
|
||||
"""Get full details of a single bank statement line plus matching suggestions."""
|
||||
line_id = int(params['line_id'])
|
||||
line = env['account.bank.statement.line'].browse(line_id)
|
||||
if not line.exists():
|
||||
return {'error': 'Bank statement line not found'}
|
||||
|
||||
result = {
|
||||
'id': line.id,
|
||||
'date': str(line.date),
|
||||
'payment_ref': line.payment_ref or '',
|
||||
'partner_name': line.partner_name or (line.partner_id.name if line.partner_id else ''),
|
||||
'partner_id': line.partner_id.id if line.partner_id else None,
|
||||
'amount': line.amount,
|
||||
'journal': line.journal_id.name,
|
||||
'journal_id': line.journal_id.id,
|
||||
'is_reconciled': line.is_reconciled,
|
||||
'existing_bills': [],
|
||||
'suggested_partner': None,
|
||||
}
|
||||
|
||||
# Search for existing vendor bills matching amount ± $0.50 and date ± 3 days
|
||||
abs_amount = abs(line.amount)
|
||||
from datetime import timedelta as td
|
||||
date_from = line.date - td(days=3)
|
||||
date_to = line.date + td(days=3)
|
||||
matching_bills = env['account.move'].search([
|
||||
('move_type', '=', 'in_invoice'),
|
||||
('state', '=', 'posted'),
|
||||
('amount_total', '>=', abs_amount - 0.50),
|
||||
('amount_total', '<=', abs_amount + 0.50),
|
||||
('date', '>=', str(date_from)),
|
||||
('date', '<=', str(date_to)),
|
||||
('company_id', '=', env.company.id),
|
||||
], limit=5)
|
||||
for bill in matching_bills:
|
||||
result['existing_bills'].append({
|
||||
'id': bill.id,
|
||||
'name': bill.name,
|
||||
'partner': bill.partner_id.name if bill.partner_id else '',
|
||||
'amount_total': bill.amount_total,
|
||||
'date': str(bill.date),
|
||||
'payment_state': bill.payment_state,
|
||||
})
|
||||
|
||||
# Try to suggest a partner from payment_ref keyword
|
||||
if line.payment_ref and not line.partner_id:
|
||||
# Extract meaningful words from payment_ref (skip common banking terms)
|
||||
skip_words = {'misc', 'payment', 'online', 'banking', 'pad', 'business',
|
||||
'deposit', 'cheque', 'transfer', 'e-transfer', 'sent', 'autodeposit'}
|
||||
words = [w for w in line.payment_ref.split() if len(w) > 2 and w.lower() not in skip_words]
|
||||
for word in words[:3]:
|
||||
partners = env['res.partner'].search([
|
||||
('name', 'ilike', word),
|
||||
('supplier_rank', '>', 0),
|
||||
], limit=3)
|
||||
if partners:
|
||||
result['suggested_partner'] = {
|
||||
'id': partners[0].id,
|
||||
'name': partners[0].name,
|
||||
'match_word': word,
|
||||
}
|
||||
break
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def check_recurring_pattern(env, params):
|
||||
"""Check if a bank line matches a known recurring payment pattern.
|
||||
Returns the historical coding (account, HST, partner, reconcile model) if found."""
|
||||
line_id = params.get('line_id')
|
||||
payment_ref = params.get('payment_ref', '')
|
||||
amount = params.get('amount')
|
||||
|
||||
# If line_id provided, get the ref and amount from the line
|
||||
if line_id:
|
||||
line = env['account.bank.statement.line'].browse(int(line_id))
|
||||
if line.exists():
|
||||
payment_ref = line.payment_ref or ''
|
||||
amount = line.amount
|
||||
|
||||
if not payment_ref:
|
||||
return {'match': False, 'reason': 'No payment reference to match'}
|
||||
|
||||
# Search cached patterns by keyword
|
||||
patterns = env['fusion.recurring.pattern'].search([
|
||||
('company_id', '=', env.company.id),
|
||||
])
|
||||
|
||||
best_match = None
|
||||
for pat in patterns:
|
||||
if not pat.ref_keyword:
|
||||
continue
|
||||
# Check if the pattern keyword appears in the payment_ref
|
||||
if pat.ref_keyword.lower()[:30] in payment_ref.lower():
|
||||
# If amount matches too, it's a strong match
|
||||
if amount and pat.amount_is_fixed and abs(pat.amount - amount) < 0.01:
|
||||
best_match = pat
|
||||
break
|
||||
# Keyword-only match (amount may vary)
|
||||
if not best_match or pat.occurrences > best_match.occurrences:
|
||||
best_match = pat
|
||||
|
||||
if not best_match:
|
||||
return {'match': False, 'payment_ref': payment_ref}
|
||||
|
||||
result = {
|
||||
'match': True,
|
||||
'pattern_id': best_match.id,
|
||||
'pattern_name': best_match.name,
|
||||
'occurrences': best_match.occurrences,
|
||||
'first_seen': str(best_match.first_seen) if best_match.first_seen else '',
|
||||
'last_seen': str(best_match.last_seen) if best_match.last_seen else '',
|
||||
'expense_account_id': best_match.expense_account_id.id if best_match.expense_account_id else None,
|
||||
'expense_account_code': best_match.expense_account_code or '',
|
||||
'expense_account_name': best_match.expense_account_id.name if best_match.expense_account_id else '',
|
||||
'has_hst': best_match.has_hst,
|
||||
'partner_id': best_match.partner_id.id if best_match.partner_id else None,
|
||||
'partner_name': best_match.partner_id.name if best_match.partner_id else '',
|
||||
'action_note': best_match.action_note or '',
|
||||
'amount_is_fixed': best_match.amount_is_fixed,
|
||||
}
|
||||
if best_match.reconcile_model_id:
|
||||
result['reconcile_model_id'] = best_match.reconcile_model_id.id
|
||||
result['reconcile_model_name'] = best_match.reconcile_model_id.name
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def match_internal_transfers(env, params):
|
||||
"""[Tier 3] Find and match inter-account transfers between two bank journals.
|
||||
Matches exact amounts within a date window. Only matches when there is exactly
|
||||
ONE candidate on each side (no ambiguous matches). Requires user approval.
|
||||
|
||||
Typical use: Scotia Current Account ↔ Scotia Visa payments."""
|
||||
journal_a_id = int(params['journal_a_id']) # e.g., Scotia Current (50)
|
||||
journal_b_id = int(params['journal_b_id']) # e.g., Scotia Visa (51)
|
||||
date_from = params.get('date_from', '2025-01-01')
|
||||
date_to = params.get('date_to', '2025-03-31')
|
||||
max_days_apart = int(params.get('max_days_apart', 2))
|
||||
|
||||
# Get unreconciled positive lines from both journals
|
||||
# (transfers show as positive on the RECEIVING side)
|
||||
lines_a = env['account.bank.statement.line'].search([
|
||||
('is_reconciled', '=', False),
|
||||
('journal_id', '=', journal_a_id),
|
||||
('company_id', '=', env.company.id),
|
||||
])
|
||||
lines_a = lines_a.filtered(
|
||||
lambda l: l.move_id.date >= fields.Date.from_string(date_from)
|
||||
and l.move_id.date <= fields.Date.from_string(date_to)
|
||||
and l.amount > 0 # money coming IN on this account
|
||||
)
|
||||
|
||||
lines_b = env['account.bank.statement.line'].search([
|
||||
('is_reconciled', '=', False),
|
||||
('journal_id', '=', journal_b_id),
|
||||
('company_id', '=', env.company.id),
|
||||
])
|
||||
lines_b = lines_b.filtered(
|
||||
lambda l: l.move_id.date >= fields.Date.from_string(date_from)
|
||||
and l.move_id.date <= fields.Date.from_string(date_to)
|
||||
and l.amount > 0 # money coming IN on this account
|
||||
)
|
||||
|
||||
matched_pairs = []
|
||||
used_a = set()
|
||||
used_b = set()
|
||||
|
||||
# For each line in A, find exact-amount match in B within date window
|
||||
for la in sorted(lines_a, key=lambda l: l.move_id.date):
|
||||
if la.id in used_a:
|
||||
continue
|
||||
candidates = []
|
||||
for lb in lines_b:
|
||||
if lb.id in used_b:
|
||||
continue
|
||||
if abs(la.amount - lb.amount) < 0.01:
|
||||
days = abs((la.move_id.date - lb.move_id.date).days)
|
||||
if days <= max_days_apart:
|
||||
candidates.append(lb)
|
||||
# Only match if EXACTLY ONE candidate — skip ambiguous
|
||||
if len(candidates) == 1:
|
||||
lb = candidates[0]
|
||||
matched_pairs.append({
|
||||
'line_a_id': la.id,
|
||||
'line_a_date': str(la.move_id.date),
|
||||
'line_a_ref': la.payment_ref or '',
|
||||
'line_a_journal': la.journal_id.name,
|
||||
'line_b_id': lb.id,
|
||||
'line_b_date': str(lb.move_id.date),
|
||||
'line_b_ref': lb.payment_ref or '',
|
||||
'line_b_journal': lb.journal_id.name,
|
||||
'amount': la.amount,
|
||||
'days_apart': abs((la.move_id.date - lb.move_id.date).days),
|
||||
})
|
||||
used_a.add(la.id)
|
||||
used_b.add(lb.id)
|
||||
|
||||
if not matched_pairs:
|
||||
return {
|
||||
'status': 'no_matches',
|
||||
'message': 'No unambiguous transfer pairs found.',
|
||||
'lines_a_checked': len(lines_a),
|
||||
'lines_b_checked': len(lines_b),
|
||||
}
|
||||
|
||||
# If this is just a dry-run check (no execute flag), return the pairs for review
|
||||
if not params.get('execute', False):
|
||||
return {
|
||||
'status': 'pairs_found',
|
||||
'count': len(matched_pairs),
|
||||
'pairs': matched_pairs,
|
||||
'message': f'Found {len(matched_pairs)} unambiguous transfer pairs. Set execute=true to reconcile them.',
|
||||
}
|
||||
|
||||
# Execute: create internal transfer journal entries to reconcile both sides
|
||||
reconciled = []
|
||||
for pair in matched_pairs:
|
||||
try:
|
||||
line_a = env['account.bank.statement.line'].browse(pair['line_a_id'])
|
||||
line_b = env['account.bank.statement.line'].browse(pair['line_b_id'])
|
||||
|
||||
# Create an internal transfer payment
|
||||
payment = env['account.payment'].create({
|
||||
'payment_type': 'outbound',
|
||||
'partner_type': 'supplier',
|
||||
'partner_id': env.company.partner_id.id, # Self as partner for internal transfer
|
||||
'amount': pair['amount'],
|
||||
'journal_id': journal_a_id,
|
||||
'destination_journal_id': journal_b_id,
|
||||
'date': line_a.move_id.date,
|
||||
'ref': f'Internal Transfer: {pair["line_a_ref"]} ↔ {pair["line_b_ref"]}',
|
||||
'is_internal_transfer': True,
|
||||
})
|
||||
payment.action_post()
|
||||
|
||||
# Now match the payment's move lines to the bank statement lines
|
||||
# The payment creates lines on both journals' outstanding accounts
|
||||
for move_line in payment.move_id.line_ids:
|
||||
if move_line.journal_id.id == journal_a_id and not move_line.reconciled:
|
||||
try:
|
||||
line_a.set_line_bank_statement_line(move_line.ids)
|
||||
except Exception:
|
||||
pass
|
||||
# Check paired transfer for the other side
|
||||
if payment.paired_internal_transfer_payment_id:
|
||||
paired = payment.paired_internal_transfer_payment_id
|
||||
for move_line in paired.move_id.line_ids:
|
||||
if move_line.journal_id.id == journal_b_id and not move_line.reconciled:
|
||||
try:
|
||||
line_b.set_line_bank_statement_line(move_line.ids)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
reconciled.append({
|
||||
'line_a_id': pair['line_a_id'],
|
||||
'line_b_id': pair['line_b_id'],
|
||||
'amount': pair['amount'],
|
||||
'payment_id': payment.id,
|
||||
'status': 'reconciled',
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.error("Failed to reconcile transfer pair %s: %s", pair, e)
|
||||
reconciled.append({
|
||||
'line_a_id': pair['line_a_id'],
|
||||
'line_b_id': pair['line_b_id'],
|
||||
'amount': pair['amount'],
|
||||
'status': 'error',
|
||||
'error': str(e),
|
||||
})
|
||||
|
||||
return {
|
||||
'status': 'executed',
|
||||
'total_pairs': len(matched_pairs),
|
||||
'reconciled': len([r for r in reconciled if r['status'] == 'reconciled']),
|
||||
'errors': len([r for r in reconciled if r['status'] == 'error']),
|
||||
'details': reconciled,
|
||||
}
|
||||
|
||||
|
||||
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,
|
||||
'match_bank_line_to_payments': match_bank_line_to_payments,
|
||||
'auto_reconcile_bank_lines': auto_reconcile_bank_lines,
|
||||
'apply_reconcile_model': apply_reconcile_model,
|
||||
'unmatch_bank_line': unmatch_bank_line,
|
||||
'get_reconcile_suggestions': get_reconcile_suggestions,
|
||||
'sum_payments_by_date': sum_payments_by_date,
|
||||
'get_bank_line_details': get_bank_line_details,
|
||||
'check_recurring_pattern': check_recurring_pattern,
|
||||
'match_internal_transfers': match_internal_transfers,
|
||||
'find_unreconciled_cheques': find_unreconciled_cheques,
|
||||
'reconcile_payroll_cheques': reconcile_payroll_cheques,
|
||||
'suggest_bank_line_matches': suggest_bank_line_matches,
|
||||
'search_matching_entries': search_matching_entries,
|
||||
}
|
||||
299
fusion_accounting_ai/services/tools/hst_management.py
Normal file
299
fusion_accounting_ai/services/tools/hst_management.py
Normal file
@@ -0,0 +1,299 @@
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def calculate_hst_balance(env, params):
|
||||
date_from = params.get('date_from')
|
||||
date_to = params.get('date_to')
|
||||
base_domain = [
|
||||
('parent_state', '=', 'posted'),
|
||||
('company_id', '=', env.company.id),
|
||||
]
|
||||
if date_from:
|
||||
base_domain.append(('date', '>=', date_from))
|
||||
if date_to:
|
||||
base_domain.append(('date', '<=', date_to))
|
||||
|
||||
# Odoo 19 Enterprise: account.account may not have company_id field
|
||||
# (shared chart of accounts). Use try/except to handle both cases.
|
||||
try:
|
||||
collected_accounts = env['account.account'].search([
|
||||
('code', '=like', '2005%'), ('company_ids', 'in', env.company.id),
|
||||
])
|
||||
itc_accounts = env['account.account'].search([
|
||||
('code', '=like', '2006%'), ('company_ids', 'in', env.company.id),
|
||||
])
|
||||
except Exception:
|
||||
collected_accounts = env['account.account'].search([
|
||||
('code', '=like', '2005%'),
|
||||
])
|
||||
itc_accounts = env['account.account'].search([
|
||||
('code', '=like', '2006%'),
|
||||
])
|
||||
|
||||
collected_lines = env['account.move.line'].search(
|
||||
base_domain + [('account_id', 'in', collected_accounts.ids)]
|
||||
)
|
||||
itc_lines = env['account.move.line'].search(
|
||||
base_domain + [('account_id', 'in', itc_accounts.ids)]
|
||||
)
|
||||
|
||||
hst_collected = abs(sum(l.balance for l in collected_lines))
|
||||
itcs = abs(sum(l.balance for l in itc_lines))
|
||||
|
||||
return {
|
||||
'hst_collected': hst_collected,
|
||||
'input_tax_credits': itcs,
|
||||
'net_hst': hst_collected - itcs,
|
||||
'status': 'owing' if (hst_collected - itcs) > 0 else 'refund',
|
||||
'period': f'{date_from or "all"} to {date_to or "now"}',
|
||||
}
|
||||
|
||||
|
||||
def get_tax_report(env, params):
|
||||
report_ref = params.get('report_ref', 'account.generic_tax_report')
|
||||
try:
|
||||
report = env.ref(report_ref)
|
||||
except Exception:
|
||||
return {'error': f'Report not found: {report_ref}'}
|
||||
options = report.get_options({
|
||||
'date': {
|
||||
'date_from': params.get('date_from', ''),
|
||||
'date_to': params.get('date_to', ''),
|
||||
}
|
||||
})
|
||||
lines = report._get_lines(options)
|
||||
return {
|
||||
'report_name': report.name,
|
||||
'lines': [{
|
||||
'name': l.get('name', ''),
|
||||
'columns': [c.get('no_format', c.get('name', '')) for c in l.get('columns', [])],
|
||||
} for l in lines[:50]],
|
||||
}
|
||||
|
||||
|
||||
def find_missing_tax_invoices(env, params):
|
||||
date_from = params.get('date_from')
|
||||
date_to = params.get('date_to')
|
||||
domain = [
|
||||
('move_type', 'in', ('out_invoice', 'out_refund')),
|
||||
('state', '=', 'posted'),
|
||||
('company_id', '=', env.company.id),
|
||||
]
|
||||
if date_from:
|
||||
domain.append(('date', '>=', date_from))
|
||||
if date_to:
|
||||
domain.append(('date', '<=', date_to))
|
||||
|
||||
invoices = env['account.move'].search(domain)
|
||||
missing = invoices.filtered(
|
||||
lambda inv: not any(line.tax_ids for line in inv.invoice_line_ids)
|
||||
)
|
||||
return {
|
||||
'total_invoices': len(invoices),
|
||||
'missing_tax_count': len(missing),
|
||||
'invoices': [{
|
||||
'id': inv.id,
|
||||
'name': inv.name,
|
||||
'partner': inv.partner_id.name if inv.partner_id else '',
|
||||
'amount_total': inv.amount_total,
|
||||
'date': str(inv.date),
|
||||
} for inv in missing[:30]],
|
||||
}
|
||||
|
||||
|
||||
def find_missing_itc_bills(env, params):
|
||||
date_from = params.get('date_from')
|
||||
date_to = params.get('date_to')
|
||||
domain = [
|
||||
('move_type', 'in', ('in_invoice', 'in_refund')),
|
||||
('state', '=', 'posted'),
|
||||
('company_id', '=', env.company.id),
|
||||
]
|
||||
if date_from:
|
||||
domain.append(('date', '>=', date_from))
|
||||
if date_to:
|
||||
domain.append(('date', '<=', date_to))
|
||||
|
||||
bills = env['account.move'].search(domain)
|
||||
missing = bills.filtered(
|
||||
lambda b: not any(line.tax_ids for line in b.invoice_line_ids)
|
||||
)
|
||||
return {
|
||||
'total_bills': len(bills),
|
||||
'missing_itc_count': len(missing),
|
||||
'bills': [{
|
||||
'id': b.id,
|
||||
'name': b.name,
|
||||
'partner': b.partner_id.name if b.partner_id else '',
|
||||
'amount_total': b.amount_total,
|
||||
'date': str(b.date),
|
||||
} for b in missing[:30]],
|
||||
}
|
||||
|
||||
|
||||
def get_tax_return_status(env, params):
|
||||
try:
|
||||
AccountReturn = env['account.return']
|
||||
except KeyError:
|
||||
return {'error': 'Tax return model (account.return) is not available. The account_tax_report or related Enterprise module may not be installed.'}
|
||||
returns = AccountReturn.search([
|
||||
('company_id', '=', env.company.id),
|
||||
], order='date_start desc', limit=10)
|
||||
return {
|
||||
'returns': [{
|
||||
'id': r.id,
|
||||
'name': r.display_name,
|
||||
'date_start': str(r.date_start) if hasattr(r, 'date_start') else '',
|
||||
'date_end': str(r.date_end) if hasattr(r, 'date_end') else '',
|
||||
'state': r.state if hasattr(r, 'state') else '',
|
||||
} for r in returns],
|
||||
}
|
||||
|
||||
|
||||
def generate_tax_return(env, params):
|
||||
try:
|
||||
AccountReturn = env['account.return']
|
||||
except KeyError:
|
||||
return {'error': 'Tax return model (account.return) is not available.'}
|
||||
try:
|
||||
AccountReturn._generate_or_refresh_all_returns(
|
||||
company=env.company
|
||||
)
|
||||
return {'status': 'generated', 'message': 'Tax returns refreshed successfully.'}
|
||||
except Exception as e:
|
||||
return {'error': str(e)}
|
||||
|
||||
|
||||
def validate_tax_return(env, params):
|
||||
try:
|
||||
AccountReturn = env['account.return']
|
||||
except KeyError:
|
||||
return {'error': 'Tax return model (account.return) is not available.'}
|
||||
return_id = int(params['return_id'])
|
||||
tax_return = AccountReturn.browse(return_id)
|
||||
if not tax_return.exists():
|
||||
return {'error': 'Tax return not found'}
|
||||
try:
|
||||
tax_return.action_validate()
|
||||
return {'status': 'validated', 'return_id': return_id}
|
||||
except Exception as e:
|
||||
return {'error': str(e)}
|
||||
|
||||
|
||||
def create_expense_entry(env, params):
|
||||
"""[Tier 3] Create a direct GL expense entry in the Misc journal with optional HST split.
|
||||
This is the 'old school' way of recording expenses without a formal vendor bill.
|
||||
Requires user approval before execution."""
|
||||
date = params.get('date', str(env['account.move']._fields['date'].default(env['account.move'])))
|
||||
description = params.get('description', 'Expense')
|
||||
expense_account_id = int(params['expense_account_id'])
|
||||
amount = abs(float(params['amount']))
|
||||
has_hst = params.get('has_hst', False)
|
||||
bank_journal_id = int(params.get('bank_journal_id', 0))
|
||||
|
||||
# Find the MISC journal
|
||||
misc_journal = env['account.journal'].search([
|
||||
('code', '=', 'MISC'), ('company_id', '=', env.company.id),
|
||||
], limit=1)
|
||||
if not misc_journal:
|
||||
return {'error': 'Miscellaneous Operations journal (MISC) not found'}
|
||||
|
||||
expense_account = env['account.account'].browse(expense_account_id)
|
||||
if not expense_account.exists():
|
||||
return {'error': f'Expense account not found: {expense_account_id}'}
|
||||
|
||||
# Determine credit account (bank outstanding or AP)
|
||||
credit_account = None
|
||||
if bank_journal_id:
|
||||
bank_journal = env['account.journal'].browse(bank_journal_id)
|
||||
if bank_journal.exists():
|
||||
# Use the bank journal's default debit/credit account
|
||||
credit_account = (bank_journal.default_account_id
|
||||
or bank_journal.company_id.account_journal_payment_credit_account_id)
|
||||
if not credit_account:
|
||||
# Fallback to AP account
|
||||
credit_account = env['account.account'].search([
|
||||
('account_type', '=', 'liability_payable'),
|
||||
('company_ids', 'in', env.company.id),
|
||||
], limit=1)
|
||||
|
||||
if not credit_account:
|
||||
return {'error': 'Could not determine credit account for the expense entry'}
|
||||
|
||||
line_ids = []
|
||||
if has_hst:
|
||||
# Split: net expense + 13% HST ITC
|
||||
hst_rate = 0.13
|
||||
net_amount = round(amount / (1 + hst_rate), 2)
|
||||
hst_amount = round(amount - net_amount, 2)
|
||||
|
||||
# Find HST ITC account (2006%)
|
||||
itc_account = env['account.account'].search([
|
||||
('code', '=like', '2006%'),
|
||||
], limit=1)
|
||||
if not itc_account:
|
||||
# Fallback: use the HST purchase tax account
|
||||
hst_tax = env['account.tax'].search([
|
||||
('type_tax_use', '=', 'purchase'), ('amount', '=', 13.0),
|
||||
('company_id', '=', env.company.id),
|
||||
], limit=1)
|
||||
if hst_tax and hst_tax.invoice_repartition_line_ids:
|
||||
for rep in hst_tax.invoice_repartition_line_ids:
|
||||
if rep.repartition_type == 'tax' and rep.account_id:
|
||||
itc_account = rep.account_id
|
||||
break
|
||||
if not itc_account:
|
||||
return {'error': 'HST ITC account (2006) not found'}
|
||||
|
||||
line_ids = [
|
||||
(0, 0, {'name': description, 'account_id': expense_account_id,
|
||||
'debit': net_amount, 'credit': 0.0}),
|
||||
(0, 0, {'name': f'HST ITC - {description}', 'account_id': itc_account.id,
|
||||
'debit': hst_amount, 'credit': 0.0}),
|
||||
(0, 0, {'name': description, 'account_id': credit_account.id,
|
||||
'debit': 0.0, 'credit': amount}),
|
||||
]
|
||||
else:
|
||||
# Simple: debit expense / credit bank
|
||||
line_ids = [
|
||||
(0, 0, {'name': description, 'account_id': expense_account_id,
|
||||
'debit': amount, 'credit': 0.0}),
|
||||
(0, 0, {'name': description, 'account_id': credit_account.id,
|
||||
'debit': 0.0, 'credit': amount}),
|
||||
]
|
||||
|
||||
try:
|
||||
move = env['account.move'].create({
|
||||
'move_type': 'entry',
|
||||
'journal_id': misc_journal.id,
|
||||
'date': date,
|
||||
'ref': description,
|
||||
'line_ids': line_ids,
|
||||
'company_id': env.company.id,
|
||||
})
|
||||
move.action_post()
|
||||
return {
|
||||
'status': 'posted',
|
||||
'move_id': move.id,
|
||||
'move_name': move.name,
|
||||
'amount': amount,
|
||||
'has_hst': has_hst,
|
||||
'hst_amount': round(amount - amount / 1.13, 2) if has_hst else 0.0,
|
||||
}
|
||||
except Exception as e:
|
||||
_logger.error("Failed to create expense entry: %s", e)
|
||||
return {'error': str(e)}
|
||||
|
||||
|
||||
TOOLS = {
|
||||
'calculate_hst_balance': calculate_hst_balance,
|
||||
'get_tax_report': get_tax_report,
|
||||
'find_missing_tax_invoices': find_missing_tax_invoices,
|
||||
'find_missing_itc_bills': find_missing_itc_bills,
|
||||
'get_tax_return_status': get_tax_return_status,
|
||||
'generate_tax_return': generate_tax_return,
|
||||
'validate_tax_return': validate_tax_return,
|
||||
'create_expense_entry': create_expense_entry,
|
||||
}
|
||||
113
fusion_accounting_ai/services/tools/inventory.py
Normal file
113
fusion_accounting_ai/services/tools/inventory.py
Normal file
@@ -0,0 +1,113 @@
|
||||
import logging
|
||||
from odoo import fields
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_stock_valuation(env, params):
|
||||
accounts = env['account.account'].search([
|
||||
('code', '=like', '1069%'),
|
||||
('company_ids', 'in', env.company.id),
|
||||
])
|
||||
result = []
|
||||
for acct in accounts:
|
||||
balance = sum(env['account.move.line'].search([
|
||||
('account_id', '=', acct.id),
|
||||
('parent_state', '=', 'posted'),
|
||||
]).mapped('balance'))
|
||||
result.append({'code': acct.code, 'name': acct.name, 'balance': balance})
|
||||
return {'accounts': result, 'total': sum(r['balance'] for r in result)}
|
||||
|
||||
|
||||
def get_price_differences(env, params):
|
||||
accounts = env['account.account'].search([
|
||||
('code', '=like', '5010%'),
|
||||
('company_ids', 'in', env.company.id),
|
||||
])
|
||||
domain = [
|
||||
('account_id', 'in', accounts.ids),
|
||||
('parent_state', '=', 'posted'),
|
||||
('company_id', '=', env.company.id),
|
||||
]
|
||||
if params.get('date_from'):
|
||||
domain.append(('date', '>=', params['date_from']))
|
||||
if params.get('date_to'):
|
||||
domain.append(('date', '<=', params['date_to']))
|
||||
lines = env['account.move.line'].search(domain, order='date desc', limit=50)
|
||||
return {
|
||||
'total': sum(l.balance for l in lines),
|
||||
'entries': [{
|
||||
'id': l.id, 'date': str(l.date),
|
||||
'move': l.move_id.name, 'amount': l.balance,
|
||||
'partner': l.partner_id.name if l.partner_id else '',
|
||||
} for l in lines],
|
||||
}
|
||||
|
||||
|
||||
def get_cogs_ratio_by_category(env, params):
|
||||
date_from = params.get('date_from')
|
||||
date_to = params.get('date_to')
|
||||
base_domain = [
|
||||
('parent_state', '=', 'posted'),
|
||||
('company_id', '=', env.company.id),
|
||||
]
|
||||
if date_from:
|
||||
base_domain.append(('date', '>=', date_from))
|
||||
if date_to:
|
||||
base_domain.append(('date', '<=', date_to))
|
||||
|
||||
revenue_lines = env['account.move.line'].search(
|
||||
base_domain + [('account_id.account_type', '=', 'income')]
|
||||
)
|
||||
cogs_lines = env['account.move.line'].search(
|
||||
base_domain + [('account_id.account_type', '=', 'expense_direct_cost')]
|
||||
)
|
||||
revenue = abs(sum(l.balance for l in revenue_lines))
|
||||
cogs = abs(sum(l.balance for l in cogs_lines))
|
||||
ratio = (cogs / revenue * 100) if revenue else 0
|
||||
return {'revenue': revenue, 'cogs': cogs, 'ratio_pct': round(ratio, 2)}
|
||||
|
||||
|
||||
def find_unusual_adjustments(env, params):
|
||||
threshold = float(params.get('threshold', 1000))
|
||||
domain = [
|
||||
('parent_state', '=', 'posted'),
|
||||
('company_id', '=', env.company.id),
|
||||
('account_id.account_type', '=', 'expense_direct_cost'),
|
||||
]
|
||||
lines = env['account.move.line'].search(domain)
|
||||
unusual = lines.filtered(lambda l: abs(l.balance) > threshold)
|
||||
return {
|
||||
'count': len(unusual),
|
||||
'adjustments': [{
|
||||
'id': l.id, 'date': str(l.date), 'move': l.move_id.name,
|
||||
'amount': l.balance, 'name': l.name or '',
|
||||
} for l in unusual[:20]],
|
||||
}
|
||||
|
||||
|
||||
def get_inventory_turnover(env, params):
|
||||
from .reporting import get_profit_loss
|
||||
pl = get_profit_loss(env, params)
|
||||
stock = get_stock_valuation(env, params)
|
||||
avg_inventory = stock.get('total', 0)
|
||||
cogs = 0
|
||||
for line in pl.get('lines', []):
|
||||
if 'cost' in line.get('name', '').lower():
|
||||
cols = line.get('columns', [])
|
||||
if cols:
|
||||
try:
|
||||
cogs = float(cols[0])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
turnover = (cogs / avg_inventory) if avg_inventory else 0
|
||||
return {'cogs': cogs, 'avg_inventory': avg_inventory, 'turnover': round(turnover, 2)}
|
||||
|
||||
|
||||
TOOLS = {
|
||||
'get_stock_valuation': get_stock_valuation,
|
||||
'get_price_differences': get_price_differences,
|
||||
'get_cogs_ratio_by_category': get_cogs_ratio_by_category,
|
||||
'find_unusual_adjustments': find_unusual_adjustments,
|
||||
'get_inventory_turnover': get_inventory_turnover,
|
||||
}
|
||||
220
fusion_accounting_ai/services/tools/journal_review.py
Normal file
220
fusion_accounting_ai/services/tools/journal_review.py
Normal file
@@ -0,0 +1,220 @@
|
||||
import logging
|
||||
from odoo import fields
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
ACCOUNT_TYPE_EXPECTED_DIRECTION = {
|
||||
'asset_receivable': 'debit',
|
||||
'asset_cash': 'debit',
|
||||
'asset_current': 'debit',
|
||||
'asset_non_current': 'debit',
|
||||
'asset_prepayments': 'debit',
|
||||
'asset_fixed': 'debit',
|
||||
'liability_payable': 'credit',
|
||||
'liability_credit_card': 'credit',
|
||||
'liability_current': 'credit',
|
||||
'liability_non_current': 'credit',
|
||||
'equity': 'credit',
|
||||
'equity_unaffected': 'credit',
|
||||
'income': 'credit',
|
||||
'income_other': 'credit',
|
||||
'expense': 'debit',
|
||||
'expense_depreciation': 'debit',
|
||||
'expense_direct_cost': 'debit',
|
||||
'off_balance': None,
|
||||
}
|
||||
|
||||
|
||||
def find_wrong_direction_balances(env, params):
|
||||
balance_data = env['account.move.line'].read_group(
|
||||
[('parent_state', '=', 'posted'), ('company_id', '=', env.company.id)],
|
||||
['balance:sum'], ['account_id'],
|
||||
)
|
||||
acct_ids = [row['account_id'][0] for row in balance_data if row.get('account_id')]
|
||||
acct_map = {}
|
||||
if acct_ids:
|
||||
for acct in env['account.account'].browse(acct_ids):
|
||||
acct_map[acct.id] = acct
|
||||
|
||||
issues = []
|
||||
for row in balance_data:
|
||||
if not row.get('account_id'):
|
||||
continue
|
||||
acct = acct_map.get(row['account_id'][0])
|
||||
if not acct:
|
||||
continue
|
||||
expected = ACCOUNT_TYPE_EXPECTED_DIRECTION.get(acct.account_type)
|
||||
if not expected:
|
||||
continue
|
||||
balance = row.get('balance', 0) or 0
|
||||
if (expected == 'debit' and balance < -0.01) or (expected == 'credit' and balance > 0.01):
|
||||
issues.append({
|
||||
'account_id': acct.id,
|
||||
'code': acct.code,
|
||||
'name': acct.name,
|
||||
'balance': balance,
|
||||
'expected': expected,
|
||||
'actual': 'credit' if balance < 0 else 'debit',
|
||||
})
|
||||
return {'count': len(issues), 'issues': issues}
|
||||
|
||||
|
||||
def find_duplicate_entries(env, params):
|
||||
date_from = params.get('date_from')
|
||||
date_to = params.get('date_to')
|
||||
domain = [
|
||||
('state', '=', 'posted'),
|
||||
('company_id', '=', env.company.id),
|
||||
]
|
||||
if date_from:
|
||||
domain.append(('date', '>=', date_from))
|
||||
if date_to:
|
||||
domain.append(('date', '<=', date_to))
|
||||
moves = env['account.move'].search(domain, order='partner_id, amount_total, date')
|
||||
|
||||
duplicates = []
|
||||
prev = None
|
||||
for move in moves:
|
||||
if prev and (
|
||||
prev.partner_id == move.partner_id and prev.partner_id
|
||||
and abs(prev.amount_total - move.amount_total) < 0.01
|
||||
and prev.date == move.date
|
||||
and prev.journal_id == move.journal_id
|
||||
):
|
||||
duplicates.append({
|
||||
'entry_1': {'id': prev.id, 'name': prev.name},
|
||||
'entry_2': {'id': move.id, 'name': move.name},
|
||||
'partner': move.partner_id.name,
|
||||
'amount': move.amount_total,
|
||||
'date': str(move.date),
|
||||
})
|
||||
prev = move
|
||||
return {'count': len(duplicates), 'duplicates': duplicates[:20]}
|
||||
|
||||
|
||||
def find_wrong_account_entries(env, params):
|
||||
date_from = params.get('date_from')
|
||||
date_to = params.get('date_to')
|
||||
domain = [
|
||||
('parent_state', '=', 'posted'),
|
||||
('company_id', '=', env.company.id),
|
||||
]
|
||||
if date_from:
|
||||
domain.append(('date', '>=', date_from))
|
||||
if date_to:
|
||||
domain.append(('date', '<=', date_to))
|
||||
|
||||
issues = []
|
||||
tax_accounts = env['account.account'].search([
|
||||
('account_type', 'in', ('liability_current', 'asset_current')),
|
||||
('code', '=like', '2005%'),
|
||||
('company_ids', 'in', env.company.id),
|
||||
])
|
||||
if tax_accounts:
|
||||
revenue_on_tax = env['account.move.line'].search(
|
||||
domain + [
|
||||
('account_id', 'in', tax_accounts.ids),
|
||||
('product_id', '!=', False),
|
||||
]
|
||||
)
|
||||
for line in revenue_on_tax[:20]:
|
||||
issues.append({
|
||||
'id': line.id,
|
||||
'move': line.move_id.name,
|
||||
'account': f'{line.account_id.code} {line.account_id.name}',
|
||||
'product': line.product_id.name,
|
||||
'amount': line.balance,
|
||||
'issue': 'Product line on tax account',
|
||||
})
|
||||
return {'count': len(issues), 'issues': issues}
|
||||
|
||||
|
||||
def find_sequence_gaps(env, params):
|
||||
moves = env['account.move'].search([
|
||||
('state', '=', 'posted'),
|
||||
('company_id', '=', env.company.id),
|
||||
('made_sequence_gap', '=', True),
|
||||
], order='date desc', limit=50)
|
||||
return {
|
||||
'count': len(moves),
|
||||
'gaps': [{
|
||||
'id': m.id,
|
||||
'name': m.name,
|
||||
'date': str(m.date),
|
||||
'journal': m.journal_id.name,
|
||||
} for m in moves],
|
||||
}
|
||||
|
||||
|
||||
def find_draft_entries(env, params):
|
||||
min_age_days = int(params.get('min_age_days', 30))
|
||||
from datetime import timedelta
|
||||
cutoff = fields.Date.today() - timedelta(days=min_age_days)
|
||||
drafts = env['account.move'].search([
|
||||
('state', '=', 'draft'),
|
||||
('date', '<=', cutoff),
|
||||
('company_id', '=', env.company.id),
|
||||
], order='date asc', limit=50)
|
||||
return {
|
||||
'count': len(drafts),
|
||||
'entries': [{
|
||||
'id': d.id,
|
||||
'name': d.name or 'Draft',
|
||||
'date': str(d.date),
|
||||
'journal': d.journal_id.name,
|
||||
'amount': d.amount_total,
|
||||
'partner': d.partner_id.name if d.partner_id else '',
|
||||
} for d in drafts],
|
||||
}
|
||||
|
||||
|
||||
def find_unreconciled_suspense(env, params):
|
||||
suspense_accounts = env['account.account'].search([
|
||||
('code', '=like', '999%'),
|
||||
('company_ids', 'in', env.company.id),
|
||||
])
|
||||
issues = []
|
||||
for acct in suspense_accounts:
|
||||
balance = sum(env['account.move.line'].search([
|
||||
('account_id', '=', acct.id),
|
||||
('parent_state', '=', 'posted'),
|
||||
]).mapped('balance'))
|
||||
if abs(balance) > 0.01:
|
||||
issues.append({
|
||||
'account_id': acct.id,
|
||||
'code': acct.code,
|
||||
'name': acct.name,
|
||||
'balance': balance,
|
||||
})
|
||||
return {'count': len(issues), 'accounts': issues}
|
||||
|
||||
|
||||
def verify_reconciliation_integrity(env, params):
|
||||
partials = env['account.partial.reconcile'].search([
|
||||
('company_id', '=', env.company.id),
|
||||
], limit=500)
|
||||
issues = []
|
||||
for p in partials:
|
||||
debit_ok = p.debit_move_id.reconciled or abs(p.debit_move_id.amount_residual) < 0.01
|
||||
credit_ok = p.credit_move_id.reconciled or abs(p.credit_move_id.amount_residual) < 0.01
|
||||
if not debit_ok and not credit_ok:
|
||||
issues.append({
|
||||
'id': p.id,
|
||||
'debit_move': p.debit_move_id.move_id.name,
|
||||
'credit_move': p.credit_move_id.move_id.name,
|
||||
'amount': p.amount,
|
||||
'debit_residual': p.debit_move_id.amount_residual,
|
||||
'credit_residual': p.credit_move_id.amount_residual,
|
||||
})
|
||||
return {'count': len(issues), 'issues': issues[:20]}
|
||||
|
||||
|
||||
TOOLS = {
|
||||
'find_wrong_direction_balances': find_wrong_direction_balances,
|
||||
'find_duplicate_entries': find_duplicate_entries,
|
||||
'find_wrong_account_entries': find_wrong_account_entries,
|
||||
'find_sequence_gaps': find_sequence_gaps,
|
||||
'find_draft_entries': find_draft_entries,
|
||||
'find_unreconciled_suspense': find_unreconciled_suspense,
|
||||
'verify_reconciliation_integrity': verify_reconciliation_integrity,
|
||||
}
|
||||
130
fusion_accounting_ai/services/tools/month_end.py
Normal file
130
fusion_accounting_ai/services/tools/month_end.py
Normal file
@@ -0,0 +1,130 @@
|
||||
import logging
|
||||
from odoo import fields
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_close_checklist(env, params):
|
||||
from .bank_reconciliation import get_unreconciled_bank_lines
|
||||
from .journal_review import find_draft_entries, find_sequence_gaps
|
||||
from .hst_management import calculate_hst_balance
|
||||
|
||||
period = params.get('period', str(fields.Date.today())[:7])
|
||||
date_from = f'{period}-01'
|
||||
import calendar
|
||||
year, month = int(period[:4]), int(period[5:7])
|
||||
last_day = calendar.monthrange(year, month)[1]
|
||||
date_to = f'{period}-{last_day:02d}'
|
||||
|
||||
p = {'date_from': date_from, 'date_to': date_to}
|
||||
|
||||
bank = get_unreconciled_bank_lines(env, p)
|
||||
drafts = find_draft_entries(env, {'min_age_days': '0'})
|
||||
gaps = find_sequence_gaps(env, p)
|
||||
hst = calculate_hst_balance(env, p)
|
||||
|
||||
checklist = [
|
||||
{'item': 'Bank Reconciliation', 'status': 'ok' if bank['count'] == 0 else 'attention', 'detail': f"{bank['count']} unreconciled lines"},
|
||||
{'item': 'Draft Entries', 'status': 'ok' if drafts['count'] == 0 else 'attention', 'detail': f"{drafts['count']} draft entries"},
|
||||
{'item': 'Sequence Gaps', 'status': 'ok' if gaps['count'] == 0 else 'warning', 'detail': f"{gaps['count']} gaps found"},
|
||||
{'item': 'HST Balance', 'status': 'info', 'detail': f"Net HST: ${hst['net_hst']:.2f}"},
|
||||
]
|
||||
return {'period': period, 'checklist': checklist}
|
||||
|
||||
|
||||
def get_unreconciled_counts(env, params):
|
||||
accounts = env['account.account'].search([
|
||||
('reconcile', '=', True),
|
||||
('company_ids', 'in', env.company.id),
|
||||
])
|
||||
result = []
|
||||
for acct in accounts:
|
||||
count = env['account.move.line'].search_count([
|
||||
('account_id', '=', acct.id),
|
||||
('reconciled', '=', False),
|
||||
('parent_state', '=', 'posted'),
|
||||
])
|
||||
if count > 0:
|
||||
result.append({
|
||||
'account_id': acct.id,
|
||||
'code': acct.code,
|
||||
'name': acct.name,
|
||||
'unreconciled_count': count,
|
||||
})
|
||||
return {'accounts': sorted(result, key=lambda x: -x['unreconciled_count'])}
|
||||
|
||||
|
||||
def find_entries_in_locked_period(env, params):
|
||||
company = env.company
|
||||
lock_date = company.fiscalyear_lock_date
|
||||
if not lock_date:
|
||||
return {'status': 'no_lock_date', 'entries': []}
|
||||
entries = env['account.move'].search([
|
||||
('date', '<=', lock_date),
|
||||
('state', '=', 'draft'),
|
||||
('company_id', '=', company.id),
|
||||
])
|
||||
return {
|
||||
'lock_date': str(lock_date),
|
||||
'count': len(entries),
|
||||
'entries': [{'id': e.id, 'name': e.name, 'date': str(e.date)} for e in entries[:20]],
|
||||
}
|
||||
|
||||
|
||||
def get_accrual_status(env, params):
|
||||
accrual_codes = params.get('account_codes', ['2100', '2110', '2120'])
|
||||
result = []
|
||||
for code in accrual_codes:
|
||||
accounts = env['account.account'].search([
|
||||
('code', '=like', f'{code}%'),
|
||||
('company_ids', 'in', env.company.id),
|
||||
])
|
||||
for acct in accounts:
|
||||
balance = sum(env['account.move.line'].search([
|
||||
('account_id', '=', acct.id),
|
||||
('parent_state', '=', 'posted'),
|
||||
]).mapped('balance'))
|
||||
result.append({'code': acct.code, 'name': acct.name, 'balance': balance})
|
||||
return {'accruals': result}
|
||||
|
||||
|
||||
def run_hash_integrity_check(env, params):
|
||||
try:
|
||||
result = env.company._check_hash_integrity()
|
||||
return {
|
||||
'status': 'completed',
|
||||
'results': result.get('results', []),
|
||||
'printing_date': result.get('printing_date', ''),
|
||||
}
|
||||
except Exception as e:
|
||||
return {'error': str(e)}
|
||||
|
||||
|
||||
def get_period_summary(env, params):
|
||||
date_from = params.get('date_from')
|
||||
date_to = params.get('date_to')
|
||||
try:
|
||||
report = env.ref('account_reports.trial_balance_report')
|
||||
except Exception:
|
||||
report = env.ref('account.trial_balance_report', raise_if_not_found=False)
|
||||
if not report:
|
||||
return {'error': 'Trial balance report not found'}
|
||||
options = report.get_options({'date': {'date_from': date_from, 'date_to': date_to}})
|
||||
lines = report._get_lines(options)
|
||||
return {
|
||||
'period': f'{date_from} to {date_to}',
|
||||
'lines': [{
|
||||
'name': l.get('name', ''),
|
||||
'columns': [c.get('no_format', c.get('name', '')) for c in l.get('columns', [])],
|
||||
} for l in lines[:100]],
|
||||
}
|
||||
|
||||
|
||||
TOOLS = {
|
||||
'get_close_checklist': get_close_checklist,
|
||||
'get_unreconciled_counts': get_unreconciled_counts,
|
||||
'find_entries_in_locked_period': find_entries_in_locked_period,
|
||||
'get_accrual_status': get_accrual_status,
|
||||
'run_hash_integrity_check': run_hash_integrity_check,
|
||||
'get_period_summary': get_period_summary,
|
||||
}
|
||||
256
fusion_accounting_ai/services/tools/payroll.py
Normal file
256
fusion_accounting_ai/services/tools/payroll.py
Normal file
@@ -0,0 +1,256 @@
|
||||
import logging
|
||||
from odoo import fields
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_payroll_entries(env, params):
|
||||
payroll_journals = env['account.journal'].search([
|
||||
('name', 'ilike', 'payroll'),
|
||||
('company_id', '=', env.company.id),
|
||||
])
|
||||
if not payroll_journals and params.get('journal_id'):
|
||||
payroll_journals = env['account.journal'].browse(int(params['journal_id']))
|
||||
domain = [
|
||||
('journal_id', 'in', payroll_journals.ids),
|
||||
('state', '=', 'posted'),
|
||||
('company_id', '=', env.company.id),
|
||||
]
|
||||
if params.get('date_from'):
|
||||
domain.append(('date', '>=', params['date_from']))
|
||||
if params.get('date_to'):
|
||||
domain.append(('date', '<=', params['date_to']))
|
||||
entries = env['account.move'].search(domain, order='date desc', limit=50)
|
||||
return {
|
||||
'count': len(entries),
|
||||
'entries': [{
|
||||
'id': e.id, 'name': e.name, 'date': str(e.date),
|
||||
'amount': e.amount_total, 'ref': e.ref or '',
|
||||
} for e in entries],
|
||||
}
|
||||
|
||||
|
||||
def compare_payroll_to_bank(env, params):
|
||||
date_from = params.get('date_from')
|
||||
date_to = params.get('date_to')
|
||||
if not date_from or not date_to:
|
||||
return {'error': 'date_from and date_to are required'}
|
||||
payroll_journals = env['account.journal'].search([
|
||||
('name', 'ilike', 'payroll'), ('company_id', '=', env.company.id),
|
||||
])
|
||||
payroll_entries = env['account.move'].search([
|
||||
('journal_id', 'in', payroll_journals.ids),
|
||||
('state', '=', 'posted'),
|
||||
('date', '>=', date_from), ('date', '<=', date_to),
|
||||
])
|
||||
bank_lines = env['account.bank.statement.line'].search([
|
||||
('date', '>=', date_from), ('date', '<=', date_to),
|
||||
('company_id', '=', env.company.id),
|
||||
])
|
||||
payroll_total = sum(e.amount_total for e in payroll_entries)
|
||||
bank_payroll = sum(abs(l.amount) for l in bank_lines if 'payroll' in (l.payment_ref or '').lower())
|
||||
return {
|
||||
'payroll_journal_total': payroll_total,
|
||||
'bank_payroll_total': bank_payroll,
|
||||
'difference': payroll_total - bank_payroll,
|
||||
}
|
||||
|
||||
|
||||
def verify_source_deductions(env, params):
|
||||
return {
|
||||
'status': 'info',
|
||||
'message': 'Source deduction verification requires CRA rate tables. Use fusion_payroll for full verification.',
|
||||
}
|
||||
|
||||
|
||||
def get_cra_remittance_status(env, params):
|
||||
cra_accounts = env['account.account'].search([
|
||||
('name', 'ilike', 'CRA'),
|
||||
('company_ids', 'in', env.company.id),
|
||||
])
|
||||
result = []
|
||||
for acct in cra_accounts:
|
||||
balance = sum(env['account.move.line'].search([
|
||||
('account_id', '=', acct.id),
|
||||
('parent_state', '=', 'posted'),
|
||||
]).mapped('balance'))
|
||||
result.append({'code': acct.code, 'name': acct.name, 'balance': balance})
|
||||
return {'accounts': result}
|
||||
|
||||
|
||||
def find_unmatched_payroll_cheques(env, params):
|
||||
bank_lines = env['account.bank.statement.line'].search([
|
||||
('is_reconciled', '=', False),
|
||||
('company_id', '=', env.company.id),
|
||||
('payment_ref', 'ilike', 'cheque'),
|
||||
])
|
||||
return {
|
||||
'count': len(bank_lines),
|
||||
'cheques': [{
|
||||
'id': l.id, 'date': str(l.date),
|
||||
'ref': l.payment_ref, 'amount': l.amount,
|
||||
} for l in bank_lines[:30]],
|
||||
}
|
||||
|
||||
|
||||
def parse_payroll_summary(env, params):
|
||||
import re
|
||||
raw_data = params.get('data', '')
|
||||
if not raw_data:
|
||||
return {'error': 'No payroll data provided'}
|
||||
|
||||
lines = raw_data.strip().split('\n')
|
||||
entries = []
|
||||
totals = {'gross': 0, 'cpp': 0, 'ei': 0, 'tax': 0, 'net': 0}
|
||||
|
||||
for line in lines:
|
||||
amounts = re.findall(r'\$?([\d,]+\.?\d*)', line)
|
||||
if len(amounts) >= 2:
|
||||
name_part = re.sub(r'\$?[\d,]+\.?\d*', '', line).strip(' \t,|-')
|
||||
parsed_amounts = [float(a.replace(',', '')) for a in amounts]
|
||||
entry = {'name': name_part or 'Employee', 'amounts': parsed_amounts}
|
||||
if len(parsed_amounts) >= 5:
|
||||
entry.update({
|
||||
'gross': parsed_amounts[0],
|
||||
'cpp': parsed_amounts[1],
|
||||
'ei': parsed_amounts[2],
|
||||
'tax': parsed_amounts[3],
|
||||
'net': parsed_amounts[4] if len(parsed_amounts) > 4 else parsed_amounts[0] - sum(parsed_amounts[1:4]),
|
||||
})
|
||||
for k in ('gross', 'cpp', 'ei', 'tax', 'net'):
|
||||
totals[k] += entry.get(k, 0)
|
||||
entries.append(entry)
|
||||
|
||||
return {
|
||||
'status': 'parsed',
|
||||
'employee_count': len(entries),
|
||||
'entries': entries,
|
||||
'totals': totals,
|
||||
'raw_lines': len(lines),
|
||||
}
|
||||
|
||||
|
||||
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']
|
||||
|
||||
# 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,
|
||||
}))
|
||||
|
||||
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}
|
||||
|
||||
|
||||
def get_payroll_schedule(env, params):
|
||||
return {'status': 'info', 'message': 'Payroll schedule available via fusion_payroll module.'}
|
||||
|
||||
|
||||
def match_payroll_cheques(env, params):
|
||||
st_line_id = int(params['statement_line_id'])
|
||||
move_line_ids = [int(x) for x in params['move_line_ids']]
|
||||
st_line = env['account.bank.statement.line'].browse(st_line_id)
|
||||
st_line.set_line_bank_statement_line(move_line_ids)
|
||||
return {'status': 'matched', 'statement_line_id': st_line_id}
|
||||
|
||||
|
||||
def verify_payroll_deductions(env, params):
|
||||
return verify_source_deductions(env, params)
|
||||
|
||||
|
||||
def get_cra_remittance_due(env, params):
|
||||
return get_cra_remittance_status(env, params)
|
||||
|
||||
|
||||
def prepare_cra_payment(env, params):
|
||||
return create_payroll_journal_entry(env, params)
|
||||
|
||||
|
||||
def generate_t4(env, params):
|
||||
return {'status': 'info', 'message': 'T4 generation available via fusion_payroll module.'}
|
||||
|
||||
|
||||
def generate_roe(env, params):
|
||||
return {'status': 'info', 'message': 'ROE generation available via fusion_payroll module.'}
|
||||
|
||||
|
||||
def get_payroll_cost_report(env, params):
|
||||
return get_payroll_entries(env, params)
|
||||
|
||||
|
||||
TOOLS = {
|
||||
'get_payroll_entries': get_payroll_entries,
|
||||
'compare_payroll_to_bank': compare_payroll_to_bank,
|
||||
'verify_source_deductions': verify_source_deductions,
|
||||
'get_cra_remittance_status': get_cra_remittance_status,
|
||||
'find_unmatched_payroll_cheques': find_unmatched_payroll_cheques,
|
||||
'parse_payroll_summary': parse_payroll_summary,
|
||||
'create_payroll_journal_entry': create_payroll_journal_entry,
|
||||
'get_payroll_schedule': get_payroll_schedule,
|
||||
'match_payroll_cheques': match_payroll_cheques,
|
||||
'verify_payroll_deductions': verify_payroll_deductions,
|
||||
'get_cra_remittance_due': get_cra_remittance_due,
|
||||
'prepare_cra_payment': prepare_cra_payment,
|
||||
'generate_t4': generate_t4,
|
||||
'generate_roe': generate_roe,
|
||||
'get_payroll_cost_report': get_payroll_cost_report,
|
||||
}
|
||||
285
fusion_accounting_ai/services/tools/reporting.py
Normal file
285
fusion_accounting_ai/services/tools/reporting.py
Normal file
@@ -0,0 +1,285 @@
|
||||
import logging
|
||||
import base64
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_report(env, ref_id):
|
||||
try:
|
||||
return env.ref(ref_id)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _run_report(env, report_ref, params):
|
||||
report = _get_report(env, report_ref)
|
||||
if not report:
|
||||
return {'error': f'Report {report_ref} not found'}
|
||||
date_opts = {}
|
||||
if params.get('date_from'):
|
||||
date_opts['date_from'] = params['date_from']
|
||||
if params.get('date_to'):
|
||||
date_opts['date_to'] = params['date_to']
|
||||
options = report.get_options({'date': date_opts} if date_opts else {})
|
||||
lines = report._get_lines(options)
|
||||
return {
|
||||
'report_name': report.name,
|
||||
'lines': [{
|
||||
'name': l.get('name', ''),
|
||||
'level': l.get('level', 0),
|
||||
'columns': [c.get('no_format', c.get('name', '')) for c in l.get('columns', [])],
|
||||
} for l in lines[:100]],
|
||||
}
|
||||
|
||||
|
||||
def get_profit_loss(env, params):
|
||||
return _run_report(env, 'account_reports.profit_and_loss', params)
|
||||
|
||||
|
||||
def get_balance_sheet(env, params):
|
||||
return _run_report(env, 'account_reports.balance_sheet', params)
|
||||
|
||||
|
||||
def get_trial_balance(env, params):
|
||||
return _run_report(env, 'account_reports.trial_balance_report', params)
|
||||
|
||||
|
||||
def get_cash_flow(env, params):
|
||||
return _run_report(env, 'account_reports.cash_flow_statement', params)
|
||||
|
||||
|
||||
def compare_periods(env, params):
|
||||
report_ref = params.get('report_ref', 'account_reports.profit_and_loss')
|
||||
report = _get_report(env, report_ref)
|
||||
if not report:
|
||||
return {'error': f'Report {report_ref} not found'}
|
||||
|
||||
period1 = _run_report(env, report_ref, {
|
||||
'date_from': params.get('period1_from'),
|
||||
'date_to': params.get('period1_to'),
|
||||
})
|
||||
period2 = _run_report(env, report_ref, {
|
||||
'date_from': params.get('period2_from'),
|
||||
'date_to': params.get('period2_to'),
|
||||
})
|
||||
return {'period_1': period1, 'period_2': period2}
|
||||
|
||||
|
||||
def answer_financial_question(env, params):
|
||||
question = params.get('question', '')
|
||||
sql_query = params.get('sql_query')
|
||||
if sql_query:
|
||||
return {'error': 'Direct SQL not permitted. Use report tools instead.'}
|
||||
return {'status': 'info', 'message': f'Use specific report tools to answer: {question}'}
|
||||
|
||||
|
||||
def export_report(env, params):
|
||||
report_ref = params.get('report_ref', 'account_reports.profit_and_loss')
|
||||
fmt = params.get('format', 'pdf')
|
||||
report = _get_report(env, report_ref)
|
||||
if not report:
|
||||
return {'error': f'Report {report_ref} not found'}
|
||||
date_opts = {}
|
||||
if params.get('date_from'):
|
||||
date_opts['date_from'] = params['date_from']
|
||||
if params.get('date_to'):
|
||||
date_opts['date_to'] = params['date_to']
|
||||
options = report.get_options({'date': date_opts} if date_opts else {})
|
||||
|
||||
try:
|
||||
if fmt == 'xlsx':
|
||||
result = report.dispatch_report_action(options, 'export_to_xlsx')
|
||||
else:
|
||||
result = report.dispatch_report_action(options, 'export_to_pdf')
|
||||
|
||||
if isinstance(result, dict) and result.get('file_content'):
|
||||
return {
|
||||
'file_name': result.get('file_name', f'report.{fmt}'),
|
||||
'file_type': result.get('file_type', fmt),
|
||||
'file_content_b64': base64.b64encode(result['file_content']).decode(),
|
||||
}
|
||||
return {
|
||||
'status': 'generated',
|
||||
'message': f'Report exported as {fmt}. Use the Odoo UI to download.',
|
||||
}
|
||||
except Exception as e:
|
||||
return {'error': f'Export failed: {str(e)}'}
|
||||
|
||||
|
||||
def get_invoicing_summary(env, params):
|
||||
"""Get invoicing summary — total invoiced by month, by partner, or for a date range.
|
||||
Supports: monthly breakdown for a year, current month totals, or filtered by partner."""
|
||||
from datetime import date, timedelta
|
||||
import calendar
|
||||
|
||||
year = int(params.get('year', date.today().year))
|
||||
partner_name = params.get('partner_name')
|
||||
date_from = params.get('date_from')
|
||||
date_to = params.get('date_to')
|
||||
|
||||
domain = [
|
||||
('move_type', '=', 'out_invoice'),
|
||||
('state', '=', 'posted'),
|
||||
('company_id', '=', env.company.id),
|
||||
]
|
||||
|
||||
if partner_name:
|
||||
partner = env['res.partner'].search([('name', 'ilike', partner_name)], limit=1)
|
||||
if partner:
|
||||
domain.append(('partner_id', '=', partner.id))
|
||||
else:
|
||||
return {'error': f'Partner not found: {partner_name}'}
|
||||
|
||||
if date_from and date_to:
|
||||
domain += [('date', '>=', date_from), ('date', '<=', date_to)]
|
||||
invoices = env['account.move'].search(domain, order='date desc')
|
||||
total = sum(inv.amount_total for inv in invoices)
|
||||
return {
|
||||
'period': f'{date_from} to {date_to}',
|
||||
'count': len(invoices),
|
||||
'total': total,
|
||||
'invoices': [{
|
||||
'id': inv.id, 'name': inv.name, 'partner': inv.partner_id.name,
|
||||
'date': str(inv.date), 'amount': inv.amount_total,
|
||||
'payment_state': inv.payment_state,
|
||||
} for inv in invoices[:30]],
|
||||
}
|
||||
|
||||
# Monthly breakdown for the year
|
||||
months = []
|
||||
grand_total = 0
|
||||
for month in range(1, 13):
|
||||
m_start = f'{year}-{month:02d}-01'
|
||||
last_day = calendar.monthrange(year, month)[1]
|
||||
m_end = f'{year}-{month:02d}-{last_day}'
|
||||
m_domain = domain + [('date', '>=', m_start), ('date', '<=', m_end)]
|
||||
invoices = env['account.move'].search(m_domain)
|
||||
total = sum(inv.amount_total for inv in invoices)
|
||||
grand_total += total
|
||||
months.append({
|
||||
'month': f'{year}-{month:02d}',
|
||||
'month_name': calendar.month_name[month],
|
||||
'count': len(invoices),
|
||||
'total': round(total, 2),
|
||||
})
|
||||
|
||||
return {
|
||||
'year': year,
|
||||
'grand_total': round(grand_total, 2),
|
||||
'months': months,
|
||||
'partner': partner_name or 'All',
|
||||
}
|
||||
|
||||
|
||||
def get_billing_summary(env, params):
|
||||
"""Get billing (vendor bills) summary — total billed by month or date range."""
|
||||
from datetime import date
|
||||
import calendar
|
||||
|
||||
year = int(params.get('year', date.today().year))
|
||||
partner_name = params.get('partner_name')
|
||||
date_from = params.get('date_from')
|
||||
date_to = params.get('date_to')
|
||||
|
||||
domain = [
|
||||
('move_type', '=', 'in_invoice'),
|
||||
('state', '=', 'posted'),
|
||||
('company_id', '=', env.company.id),
|
||||
]
|
||||
|
||||
if partner_name:
|
||||
partner = env['res.partner'].search([('name', 'ilike', partner_name)], limit=1)
|
||||
if partner:
|
||||
domain.append(('partner_id', '=', partner.id))
|
||||
else:
|
||||
return {'error': f'Partner not found: {partner_name}'}
|
||||
|
||||
if date_from and date_to:
|
||||
domain += [('date', '>=', date_from), ('date', '<=', date_to)]
|
||||
bills = env['account.move'].search(domain, order='date desc')
|
||||
total = sum(b.amount_total for b in bills)
|
||||
return {
|
||||
'period': f'{date_from} to {date_to}',
|
||||
'count': len(bills),
|
||||
'total': total,
|
||||
'bills': [{
|
||||
'id': b.id, 'name': b.name, 'partner': b.partner_id.name,
|
||||
'date': str(b.date), 'amount': b.amount_total,
|
||||
'payment_state': b.payment_state,
|
||||
} for b in bills[:30]],
|
||||
}
|
||||
|
||||
# Monthly breakdown
|
||||
months = []
|
||||
grand_total = 0
|
||||
for month in range(1, 13):
|
||||
m_start = f'{year}-{month:02d}-01'
|
||||
last_day = calendar.monthrange(year, month)[1]
|
||||
m_end = f'{year}-{month:02d}-{last_day}'
|
||||
m_domain = domain + [('date', '>=', m_start), ('date', '<=', m_end)]
|
||||
bills = env['account.move'].search(m_domain)
|
||||
total = sum(b.amount_total for b in bills)
|
||||
grand_total += total
|
||||
months.append({
|
||||
'month': f'{year}-{month:02d}',
|
||||
'month_name': calendar.month_name[month],
|
||||
'count': len(bills),
|
||||
'total': round(total, 2),
|
||||
})
|
||||
|
||||
return {
|
||||
'year': year,
|
||||
'grand_total': round(grand_total, 2),
|
||||
'months': months,
|
||||
'partner': partner_name or 'All',
|
||||
}
|
||||
|
||||
|
||||
def get_collections_summary(env, params):
|
||||
"""Get payment collections summary — how much was collected (received) in a period."""
|
||||
date_from = params.get('date_from')
|
||||
date_to = params.get('date_to')
|
||||
if not date_from or not date_to:
|
||||
from datetime import date
|
||||
today = date.today()
|
||||
date_from = date_from or f'{today.year}-{today.month:02d}-01'
|
||||
date_to = date_to or str(today)
|
||||
|
||||
payments = env['account.payment'].search([
|
||||
('payment_type', '=', 'inbound'),
|
||||
('state', '=', 'posted'),
|
||||
('date', '>=', date_from),
|
||||
('date', '<=', date_to),
|
||||
('company_id', '=', env.company.id),
|
||||
], order='date desc')
|
||||
|
||||
total = sum(p.amount for p in payments)
|
||||
by_partner = {}
|
||||
for p in payments:
|
||||
pname = p.partner_id.name if p.partner_id else 'Unknown'
|
||||
by_partner.setdefault(pname, {'count': 0, 'total': 0})
|
||||
by_partner[pname]['count'] += 1
|
||||
by_partner[pname]['total'] += p.amount
|
||||
|
||||
top_partners = sorted(by_partner.items(), key=lambda x: -x[1]['total'])[:15]
|
||||
|
||||
return {
|
||||
'period': f'{date_from} to {date_to}',
|
||||
'total_collected': round(total, 2),
|
||||
'payment_count': len(payments),
|
||||
'by_partner': [{'partner': k, 'count': v['count'], 'total': round(v['total'], 2)} for k, v in top_partners],
|
||||
}
|
||||
|
||||
|
||||
TOOLS = {
|
||||
'get_profit_loss': get_profit_loss,
|
||||
'get_balance_sheet': get_balance_sheet,
|
||||
'get_trial_balance': get_trial_balance,
|
||||
'get_cash_flow': get_cash_flow,
|
||||
'compare_periods': compare_periods,
|
||||
'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