changes
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
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
|
||||
from .financial_reports import TOOLS as FINANCIAL_REPORTS_TOOLS
|
||||
from .asset_management import TOOLS as ASSET_MANAGEMENT_TOOLS
|
||||
from .customer_followup import TOOLS as CUSTOMER_FOLLOWUP_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, FINANCIAL_REPORTS_TOOLS,
|
||||
ASSET_MANAGEMENT_TOOLS, CUSTOMER_FOLLOWUP_TOOLS,
|
||||
]:
|
||||
TOOL_DISPATCH.update(tools_dict)
|
||||
@@ -0,0 +1,384 @@
|
||||
import logging
|
||||
from odoo import fields
|
||||
from datetime import timedelta
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_ap_aging(env, params):
|
||||
"""Return AP aging buckets. Routed through FollowupAdapter for tri-mode consistency."""
|
||||
from ..data_adapters import get_adapter
|
||||
adapter = get_adapter(env, 'followup')
|
||||
return adapter.aged_payables(company_id=env.company.id)
|
||||
|
||||
|
||||
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,
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_ar_aging(env, params):
|
||||
"""Return AR aging buckets. Routed through FollowupAdapter for tri-mode consistency."""
|
||||
from ..data_adapters import get_adapter
|
||||
adapter = get_adapter(env, 'followup')
|
||||
return adapter.aged_receivables(company_id=env.company.id)
|
||||
|
||||
|
||||
def get_overdue_invoices(env, params):
|
||||
"""Return overdue customer invoices. Routed through FollowupAdapter."""
|
||||
from ..data_adapters import get_adapter
|
||||
adapter = get_adapter(env, 'followup')
|
||||
rows = adapter.overdue_invoices(
|
||||
days_overdue=int(params.get('min_days_overdue', 1)),
|
||||
limit=int(params.get('limit', 50)),
|
||||
)
|
||||
return {
|
||||
'count': len(rows),
|
||||
'invoices': [{
|
||||
'id': r['id'],
|
||||
'name': r['name'],
|
||||
'partner': r['partner_name'] or '',
|
||||
'email': r['partner_email'],
|
||||
'phone': r['partner_phone'],
|
||||
'amount_total': r['amount_total'],
|
||||
'amount_residual': r['amount_residual'],
|
||||
'date_due': str(r['invoice_date_due']) if r['invoice_date_due'] else '',
|
||||
'days_overdue': r['days_overdue'],
|
||||
} for r in rows],
|
||||
}
|
||||
|
||||
|
||||
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):
|
||||
"""Send a follow-up to a partner. Routed through FollowupAdapter so the
|
||||
Enterprise-only execute_followup path is isolated behind the adapter."""
|
||||
from ..data_adapters import get_adapter
|
||||
partner_id = int(params['partner_id'])
|
||||
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']
|
||||
adapter = get_adapter(env, 'followup')
|
||||
return adapter.send_followup(partner_id=partner_id, options=options)
|
||||
|
||||
|
||||
def get_followup_report(env, params):
|
||||
"""Return the follow-up report HTML for a partner. Routed through FollowupAdapter."""
|
||||
from ..data_adapters import get_adapter
|
||||
partner_id = int(params['partner_id'])
|
||||
adapter = get_adapter(env, 'followup')
|
||||
return adapter.followup_report_html(partner_id=partner_id)
|
||||
|
||||
|
||||
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/fusion_accounting_ai/services/tools/adp.py
Normal file
237
fusion_accounting/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,
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Fusion-engine-routed AI tools for asset management."""
|
||||
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def fusion_list_assets(env, params):
|
||||
if 'fusion.asset.engine' not in env.registry:
|
||||
return {'error': 'fusion_accounting_assets not installed'}
|
||||
from ..data_adapters import get_adapter
|
||||
adapter = get_adapter(env, 'assets')
|
||||
return adapter.list_assets(
|
||||
state=params.get('state'),
|
||||
limit=int(params.get('limit', 50)),
|
||||
company_id=int(params['company_id']) if params.get('company_id') else env.company.id,
|
||||
)
|
||||
|
||||
|
||||
def fusion_get_asset_detail(env, params):
|
||||
if 'fusion.asset.engine' not in env.registry:
|
||||
return {'error': 'fusion_accounting_assets not installed'}
|
||||
Asset = env['fusion.asset']
|
||||
asset = Asset.browse(int(params['asset_id']))
|
||||
if not asset.exists():
|
||||
return {'error': 'Asset not found'}
|
||||
return {
|
||||
'asset': {
|
||||
'id': asset.id, 'name': asset.name, 'state': asset.state,
|
||||
'cost': asset.cost, 'book_value': asset.book_value,
|
||||
'total_depreciated': asset.total_depreciated,
|
||||
'method': asset.method, 'useful_life_years': asset.useful_life_years,
|
||||
},
|
||||
'depreciation_count': len(asset.depreciation_line_ids),
|
||||
}
|
||||
|
||||
|
||||
def fusion_compute_asset_schedule(env, params):
|
||||
if 'fusion.asset.engine' not in env.registry:
|
||||
return {'error': 'fusion_accounting_assets not installed'}
|
||||
asset = env['fusion.asset'].browse(int(params['asset_id']))
|
||||
return env['fusion.asset.engine'].compute_depreciation_schedule(
|
||||
asset, recompute=bool(params.get('recompute', False)),
|
||||
)
|
||||
|
||||
|
||||
def fusion_dispose_asset(env, params):
|
||||
if 'fusion.asset.engine' not in env.registry:
|
||||
return {'error': 'fusion_accounting_assets not installed'}
|
||||
from ..data_adapters import get_adapter
|
||||
adapter = get_adapter(env, 'assets')
|
||||
return adapter.dispose_asset(
|
||||
asset_id=int(params['asset_id']),
|
||||
sale_amount=float(params.get('sale_amount', 0)),
|
||||
disposal_type=params.get('disposal_type', 'sale'),
|
||||
)
|
||||
|
||||
|
||||
def fusion_suggest_asset_useful_life(env, params):
|
||||
if 'fusion.asset.engine' not in env.registry:
|
||||
return {'error': 'fusion_accounting_assets not installed'}
|
||||
from ..data_adapters import get_adapter
|
||||
adapter = get_adapter(env, 'assets')
|
||||
return adapter.suggest_useful_life(
|
||||
description=params.get('description', ''),
|
||||
amount=float(params['amount']) if params.get('amount') else None,
|
||||
partner_name=params.get('partner_name'),
|
||||
)
|
||||
|
||||
|
||||
TOOLS = {
|
||||
'fusion_list_assets': fusion_list_assets,
|
||||
'fusion_get_asset_detail': fusion_get_asset_detail,
|
||||
'fusion_compute_asset_schedule': fusion_compute_asset_schedule,
|
||||
'fusion_dispose_asset': fusion_dispose_asset,
|
||||
'fusion_suggest_asset_useful_life': fusion_suggest_asset_useful_life,
|
||||
}
|
||||
164
fusion_accounting/fusion_accounting_ai/services/tools/audit.py
Normal file
164
fusion_accounting/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,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,98 @@
|
||||
"""Fusion-engine-routed AI tools for customer follow-ups.
|
||||
|
||||
These tools are exposed through TOOL_DISPATCH and let the assistant query
|
||||
the customer follow-up engine via natural language. All tools degrade
|
||||
gracefully when fusion_accounting_followup is not installed.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def fusion_list_overdue(env, params):
|
||||
"""List partners with overdue invoices, sorted by risk."""
|
||||
if 'fusion.followup.engine' not in env.registry:
|
||||
return {'error': 'fusion_accounting_followup not installed'}
|
||||
from ..data_adapters import get_adapter
|
||||
adapter = get_adapter(env, 'followup')
|
||||
return adapter.list_overdue(
|
||||
status=params.get('status'),
|
||||
limit=int(params.get('limit', 50)),
|
||||
company_id=int(params['company_id'])
|
||||
if params.get('company_id') else env.company.id,
|
||||
)
|
||||
|
||||
|
||||
def fusion_get_partner_followup_detail(env, params):
|
||||
"""Detailed follow-up state for a single partner: aging, risk, history."""
|
||||
if 'fusion.followup.engine' not in env.registry:
|
||||
return {'error': 'fusion_accounting_followup not installed'}
|
||||
Partner = env['res.partner']
|
||||
partner = Partner.browse(int(params['partner_id']))
|
||||
if not partner.exists():
|
||||
return {'error': 'Partner not found'}
|
||||
engine = env['fusion.followup.engine']
|
||||
overdue = engine.get_overdue_for_partner(partner)
|
||||
history = engine.snapshot_followup_history(partner, limit=10)
|
||||
return {
|
||||
'partner_id': partner.id,
|
||||
'partner_name': partner.name,
|
||||
'overdue': overdue,
|
||||
'history': history,
|
||||
}
|
||||
|
||||
|
||||
def fusion_generate_followup_text(env, params):
|
||||
"""Generate (or fall back to template) follow-up subject + body."""
|
||||
if 'fusion.followup.engine' not in env.registry:
|
||||
return {'error': 'fusion_accounting_followup not installed'}
|
||||
from odoo.addons.fusion_accounting_followup.services.followup_text_generator import (
|
||||
generate_followup_text,
|
||||
)
|
||||
return generate_followup_text(
|
||||
env,
|
||||
partner_name=params.get('partner_name', ''),
|
||||
total_overdue=float(params.get('total_overdue', 0)),
|
||||
currency_code=params.get('currency_code', 'USD'),
|
||||
longest_overdue_days=int(params.get('longest_overdue_days', 0)),
|
||||
tone=params.get('tone', 'gentle'),
|
||||
invoice_count=int(params.get('invoice_count', 0)),
|
||||
)
|
||||
|
||||
|
||||
def fusion_send_followup(env, params):
|
||||
"""Send a follow-up email via the engine (creates a fusion.followup.run)."""
|
||||
if 'fusion.followup.engine' not in env.registry:
|
||||
return {'error': 'fusion_accounting_followup not installed'}
|
||||
from ..data_adapters import get_adapter
|
||||
adapter = get_adapter(env, 'followup')
|
||||
return adapter.send_followup(
|
||||
partner_id=int(params['partner_id']),
|
||||
level_id=int(params['level_id']) if params.get('level_id') else None,
|
||||
force=bool(params.get('force', False)),
|
||||
)
|
||||
|
||||
|
||||
def fusion_get_partner_risk_score(env, params):
|
||||
"""Compute and return the payment-risk score + drivers for a partner."""
|
||||
if 'fusion.followup.engine' not in env.registry:
|
||||
return {'error': 'fusion_accounting_followup not installed'}
|
||||
partner = env['res.partner'].browse(int(params['partner_id']))
|
||||
if not partner.exists():
|
||||
return {'error': 'Partner not found'}
|
||||
overdue = env['fusion.followup.engine'].get_overdue_for_partner(partner)
|
||||
return {
|
||||
'partner_id': partner.id,
|
||||
'partner_name': partner.name,
|
||||
'risk': overdue['risk'],
|
||||
}
|
||||
|
||||
|
||||
TOOLS = {
|
||||
'fusion_list_overdue': fusion_list_overdue,
|
||||
'fusion_get_partner_followup_detail': fusion_get_partner_followup_detail,
|
||||
'fusion_generate_followup_text': fusion_generate_followup_text,
|
||||
'fusion_send_followup': fusion_send_followup,
|
||||
'fusion_get_partner_risk_score': fusion_get_partner_risk_score,
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Fusion-engine-routed AI tools for financial reports.
|
||||
|
||||
These 5 tools route through ReportsAdapter's Phase-2 methods
|
||||
(run_fusion_report / get_anomalies / get_commentary), which in turn
|
||||
call fusion.report.engine when fusion_accounting_reports is installed.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _company_id(env, params):
|
||||
raw = params.get('company_id')
|
||||
return int(raw) if raw else env.company.id
|
||||
|
||||
|
||||
def fusion_run_report(env, params):
|
||||
"""Run a fusion financial report.
|
||||
|
||||
Params: report_type (pnl|balance_sheet|trial_balance|general_ledger),
|
||||
date_from, date_to, comparison (none|previous_period|previous_year),
|
||||
optional company_id.
|
||||
"""
|
||||
if 'fusion.report.engine' not in env.registry:
|
||||
return {'error': 'fusion_accounting_reports not installed'}
|
||||
from ..data_adapters import get_adapter
|
||||
adapter = get_adapter(env, 'reports')
|
||||
result = adapter.run_fusion_report(
|
||||
report_type=params.get('report_type'),
|
||||
date_from=params.get('date_from'),
|
||||
date_to=params.get('date_to'),
|
||||
comparison=params.get('comparison', 'none'),
|
||||
company_id=_company_id(env, params),
|
||||
)
|
||||
rows = result.get('rows', [])
|
||||
return {
|
||||
'report_type': params.get('report_type'),
|
||||
'period': result.get('period'),
|
||||
'comparison_period': result.get('comparison_period'),
|
||||
'row_count': len(rows),
|
||||
'rows': rows,
|
||||
}
|
||||
|
||||
|
||||
def fusion_get_anomalies(env, params):
|
||||
"""Detect variance anomalies in a report."""
|
||||
if 'fusion.report.engine' not in env.registry:
|
||||
return {'error': 'fusion_accounting_reports not installed'}
|
||||
from ..data_adapters import get_adapter
|
||||
adapter = get_adapter(env, 'reports')
|
||||
result = adapter.get_anomalies(
|
||||
report_type=params.get('report_type'),
|
||||
date_from=params.get('date_from'),
|
||||
date_to=params.get('date_to'),
|
||||
comparison=params.get('comparison', 'previous_year'),
|
||||
company_id=_company_id(env, params),
|
||||
)
|
||||
anomalies = result.get('anomalies', [])
|
||||
return {'count': len(anomalies), 'anomalies': anomalies}
|
||||
|
||||
|
||||
def fusion_generate_commentary(env, params):
|
||||
"""Generate AI commentary for a report."""
|
||||
if 'fusion.report.engine' not in env.registry:
|
||||
return {'error': 'fusion_accounting_reports not installed'}
|
||||
from ..data_adapters import get_adapter
|
||||
adapter = get_adapter(env, 'reports')
|
||||
result = adapter.get_commentary(
|
||||
report_type=params.get('report_type'),
|
||||
date_from=params.get('date_from'),
|
||||
date_to=params.get('date_to'),
|
||||
comparison=params.get('comparison', 'none'),
|
||||
company_id=_company_id(env, params),
|
||||
)
|
||||
return {
|
||||
'summary': result.get('summary', ''),
|
||||
'highlights': result.get('highlights', []),
|
||||
'concerns': result.get('concerns', []),
|
||||
'next_actions': result.get('next_actions', []),
|
||||
}
|
||||
|
||||
|
||||
def fusion_drill_down_report_line(env, params):
|
||||
"""Drill from a report line into the underlying journal items."""
|
||||
if 'fusion.report.engine' not in env.registry:
|
||||
return {'error': 'fusion_accounting_reports not installed'}
|
||||
from datetime import datetime
|
||||
|
||||
from odoo.addons.fusion_accounting_reports.services.date_periods import (
|
||||
Period,
|
||||
)
|
||||
date_from = params['date_from']
|
||||
date_to = params['date_to']
|
||||
if isinstance(date_from, str):
|
||||
date_from = datetime.strptime(date_from, '%Y-%m-%d').date()
|
||||
if isinstance(date_to, str):
|
||||
date_to = datetime.strptime(date_to, '%Y-%m-%d').date()
|
||||
period = Period(date_from=date_from, date_to=date_to, label='drill')
|
||||
engine = env['fusion.report.engine']
|
||||
rows = engine.drill_down(
|
||||
account_id=int(params['account_id']),
|
||||
period=period,
|
||||
company_id=_company_id(env, params),
|
||||
)
|
||||
return {'count': len(rows), 'rows': rows}
|
||||
|
||||
|
||||
def fusion_compare_periods(env, params):
|
||||
"""Run a report with period comparison side-by-side.
|
||||
|
||||
Defaults comparison to 'previous_year' so callers get a comparison
|
||||
column without specifying it explicitly.
|
||||
"""
|
||||
return fusion_run_report(env, {
|
||||
**params,
|
||||
'comparison': params.get('comparison', 'previous_year'),
|
||||
})
|
||||
|
||||
|
||||
TOOLS = {
|
||||
'fusion_run_report': fusion_run_report,
|
||||
'fusion_get_anomalies': fusion_get_anomalies,
|
||||
'fusion_generate_commentary': fusion_generate_commentary,
|
||||
'fusion_drill_down_report_line': fusion_drill_down_report_line,
|
||||
'fusion_compare_periods': fusion_compare_periods,
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
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):
|
||||
"""Route through ReportsAdapter for tri-mode consistency. The Community
|
||||
fallback returns an error dict explaining the report is Enterprise-only."""
|
||||
from ..data_adapters import get_adapter
|
||||
adapter = get_adapter(env, 'reports')
|
||||
return adapter.run_report(
|
||||
ref_id=params.get('report_ref', 'account.generic_tax_report'),
|
||||
date_from=params.get('date_from'),
|
||||
date_to=params.get('date_to'),
|
||||
limit=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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
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):
|
||||
"""Period summary via trial-balance. Routed through ReportsAdapter so the
|
||||
Enterprise-only account_reports.trial_balance_report path is isolated;
|
||||
Community installs fall back to the adapter's trial_balance() aggregation."""
|
||||
from ..data_adapters import get_adapter
|
||||
adapter = get_adapter(env, 'reports')
|
||||
date_from = params.get('date_from')
|
||||
date_to = params.get('date_to')
|
||||
result = adapter.run_report(
|
||||
ref_id='account_reports.trial_balance_report',
|
||||
date_from=date_from, date_to=date_to,
|
||||
)
|
||||
if isinstance(result, dict) and result.get('error'):
|
||||
rows = adapter.trial_balance(
|
||||
date_to=date_to, company_ids=[env.company.id],
|
||||
)
|
||||
return {
|
||||
'period': f'{date_from} to {date_to}',
|
||||
'lines': [{
|
||||
'name': f"{r['account_code']} {r['account_name']}",
|
||||
'columns': [r['debit'], r['credit'], r['balance']],
|
||||
} for r in rows[:100]],
|
||||
}
|
||||
return {
|
||||
'period': f'{date_from} to {date_to}',
|
||||
'lines': result.get('lines', []),
|
||||
}
|
||||
|
||||
|
||||
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/fusion_accounting_ai/services/tools/payroll.py
Normal file
256
fusion_accounting/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,
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Enterprise account.report wrappers — all routed through ReportsAdapter.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_profit_loss(env, params):
|
||||
"""Route through ReportsAdapter for tri-mode consistency."""
|
||||
from ..data_adapters import get_adapter
|
||||
adapter = get_adapter(env, 'reports')
|
||||
return adapter.run_report(
|
||||
ref_id='account_reports.profit_and_loss',
|
||||
date_from=params.get('date_from'),
|
||||
date_to=params.get('date_to'),
|
||||
)
|
||||
|
||||
|
||||
def get_balance_sheet(env, params):
|
||||
"""Route through ReportsAdapter for tri-mode consistency."""
|
||||
from ..data_adapters import get_adapter
|
||||
adapter = get_adapter(env, 'reports')
|
||||
return adapter.run_report(
|
||||
ref_id='account_reports.balance_sheet',
|
||||
date_from=params.get('date_from'),
|
||||
date_to=params.get('date_to'),
|
||||
)
|
||||
|
||||
|
||||
def get_trial_balance(env, params):
|
||||
"""Route through ReportsAdapter for tri-mode consistency.
|
||||
|
||||
In Enterprise mode returns the hierarchical report lines. In Community
|
||||
mode falls back to the adapter's trial_balance() aggregation so the tool
|
||||
continues to return useful data with a compatible shape.
|
||||
"""
|
||||
from ..data_adapters import get_adapter
|
||||
adapter = get_adapter(env, 'reports')
|
||||
result = adapter.run_report(
|
||||
ref_id='account_reports.trial_balance_report',
|
||||
date_from=params.get('date_from'),
|
||||
date_to=params.get('date_to'),
|
||||
)
|
||||
if isinstance(result, dict) and result.get('error'):
|
||||
rows = adapter.trial_balance(
|
||||
date_to=params.get('date_to'),
|
||||
company_ids=[env.company.id],
|
||||
)
|
||||
return {
|
||||
'report_name': 'Trial Balance (Community aggregation)',
|
||||
'lines': [{
|
||||
'name': f"{r['account_code']} {r['account_name']}",
|
||||
'level': 2,
|
||||
'columns': [r['debit'], r['credit'], r['balance']],
|
||||
} for r in rows],
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def get_cash_flow(env, params):
|
||||
"""Route through ReportsAdapter for tri-mode consistency."""
|
||||
from ..data_adapters import get_adapter
|
||||
adapter = get_adapter(env, 'reports')
|
||||
return adapter.run_report(
|
||||
ref_id='account_reports.cash_flow_statement',
|
||||
date_from=params.get('date_from'),
|
||||
date_to=params.get('date_to'),
|
||||
)
|
||||
|
||||
|
||||
def compare_periods(env, params):
|
||||
"""Run the same report over two periods and return both results. Routes
|
||||
both runs through ReportsAdapter."""
|
||||
from ..data_adapters import get_adapter
|
||||
adapter = get_adapter(env, 'reports')
|
||||
report_ref = params.get('report_ref', 'account_reports.profit_and_loss')
|
||||
period1 = adapter.run_report(
|
||||
ref_id=report_ref,
|
||||
date_from=params.get('period1_from'),
|
||||
date_to=params.get('period1_to'),
|
||||
)
|
||||
period2 = adapter.run_report(
|
||||
ref_id=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):
|
||||
"""Route through ReportsAdapter for tri-mode consistency."""
|
||||
from ..data_adapters import get_adapter
|
||||
adapter = get_adapter(env, 'reports')
|
||||
return adapter.export_report(
|
||||
ref_id=params.get('report_ref', 'account_reports.profit_and_loss'),
|
||||
fmt=params.get('format', 'pdf'),
|
||||
date_from=params.get('date_from'),
|
||||
date_to=params.get('date_to'),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pure-Community tools — search account.move / account.payment directly.
|
||||
# These are tri-mode safe (the data lives in the same tables regardless of
|
||||
# install profile) so they don't need adapter routing.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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
|
||||
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]],
|
||||
}
|
||||
|
||||
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]],
|
||||
}
|
||||
|
||||
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