This commit is contained in:
gsinghpal
2026-05-16 13:18:52 -04:00
parent 191a9c82be
commit 9ebf89bde2
1080 changed files with 0 additions and 1197 deletions

View File

@@ -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)

View File

@@ -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,
}

View File

@@ -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,
}

View 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,
}

View File

@@ -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,
}

View 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

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -0,0 +1,113 @@
import logging
from odoo import fields
_logger = logging.getLogger(__name__)
def get_stock_valuation(env, params):
accounts = env['account.account'].search([
('code', '=like', '1069%'),
('company_ids', 'in', env.company.id),
])
result = []
for acct in accounts:
balance = sum(env['account.move.line'].search([
('account_id', '=', acct.id),
('parent_state', '=', 'posted'),
]).mapped('balance'))
result.append({'code': acct.code, 'name': acct.name, 'balance': balance})
return {'accounts': result, 'total': sum(r['balance'] for r in result)}
def get_price_differences(env, params):
accounts = env['account.account'].search([
('code', '=like', '5010%'),
('company_ids', 'in', env.company.id),
])
domain = [
('account_id', 'in', accounts.ids),
('parent_state', '=', 'posted'),
('company_id', '=', env.company.id),
]
if params.get('date_from'):
domain.append(('date', '>=', params['date_from']))
if params.get('date_to'):
domain.append(('date', '<=', params['date_to']))
lines = env['account.move.line'].search(domain, order='date desc', limit=50)
return {
'total': sum(l.balance for l in lines),
'entries': [{
'id': l.id, 'date': str(l.date),
'move': l.move_id.name, 'amount': l.balance,
'partner': l.partner_id.name if l.partner_id else '',
} for l in lines],
}
def get_cogs_ratio_by_category(env, params):
date_from = params.get('date_from')
date_to = params.get('date_to')
base_domain = [
('parent_state', '=', 'posted'),
('company_id', '=', env.company.id),
]
if date_from:
base_domain.append(('date', '>=', date_from))
if date_to:
base_domain.append(('date', '<=', date_to))
revenue_lines = env['account.move.line'].search(
base_domain + [('account_id.account_type', '=', 'income')]
)
cogs_lines = env['account.move.line'].search(
base_domain + [('account_id.account_type', '=', 'expense_direct_cost')]
)
revenue = abs(sum(l.balance for l in revenue_lines))
cogs = abs(sum(l.balance for l in cogs_lines))
ratio = (cogs / revenue * 100) if revenue else 0
return {'revenue': revenue, 'cogs': cogs, 'ratio_pct': round(ratio, 2)}
def find_unusual_adjustments(env, params):
threshold = float(params.get('threshold', 1000))
domain = [
('parent_state', '=', 'posted'),
('company_id', '=', env.company.id),
('account_id.account_type', '=', 'expense_direct_cost'),
]
lines = env['account.move.line'].search(domain)
unusual = lines.filtered(lambda l: abs(l.balance) > threshold)
return {
'count': len(unusual),
'adjustments': [{
'id': l.id, 'date': str(l.date), 'move': l.move_id.name,
'amount': l.balance, 'name': l.name or '',
} for l in unusual[:20]],
}
def get_inventory_turnover(env, params):
from .reporting import get_profit_loss
pl = get_profit_loss(env, params)
stock = get_stock_valuation(env, params)
avg_inventory = stock.get('total', 0)
cogs = 0
for line in pl.get('lines', []):
if 'cost' in line.get('name', '').lower():
cols = line.get('columns', [])
if cols:
try:
cogs = float(cols[0])
except (ValueError, TypeError):
pass
turnover = (cogs / avg_inventory) if avg_inventory else 0
return {'cogs': cogs, 'avg_inventory': avg_inventory, 'turnover': round(turnover, 2)}
TOOLS = {
'get_stock_valuation': get_stock_valuation,
'get_price_differences': get_price_differences,
'get_cogs_ratio_by_category': get_cogs_ratio_by_category,
'find_unusual_adjustments': find_unusual_adjustments,
'get_inventory_turnover': get_inventory_turnover,
}

View File

@@ -0,0 +1,220 @@
import logging
from odoo import fields
_logger = logging.getLogger(__name__)
ACCOUNT_TYPE_EXPECTED_DIRECTION = {
'asset_receivable': 'debit',
'asset_cash': 'debit',
'asset_current': 'debit',
'asset_non_current': 'debit',
'asset_prepayments': 'debit',
'asset_fixed': 'debit',
'liability_payable': 'credit',
'liability_credit_card': 'credit',
'liability_current': 'credit',
'liability_non_current': 'credit',
'equity': 'credit',
'equity_unaffected': 'credit',
'income': 'credit',
'income_other': 'credit',
'expense': 'debit',
'expense_depreciation': 'debit',
'expense_direct_cost': 'debit',
'off_balance': None,
}
def find_wrong_direction_balances(env, params):
balance_data = env['account.move.line'].read_group(
[('parent_state', '=', 'posted'), ('company_id', '=', env.company.id)],
['balance:sum'], ['account_id'],
)
acct_ids = [row['account_id'][0] for row in balance_data if row.get('account_id')]
acct_map = {}
if acct_ids:
for acct in env['account.account'].browse(acct_ids):
acct_map[acct.id] = acct
issues = []
for row in balance_data:
if not row.get('account_id'):
continue
acct = acct_map.get(row['account_id'][0])
if not acct:
continue
expected = ACCOUNT_TYPE_EXPECTED_DIRECTION.get(acct.account_type)
if not expected:
continue
balance = row.get('balance', 0) or 0
if (expected == 'debit' and balance < -0.01) or (expected == 'credit' and balance > 0.01):
issues.append({
'account_id': acct.id,
'code': acct.code,
'name': acct.name,
'balance': balance,
'expected': expected,
'actual': 'credit' if balance < 0 else 'debit',
})
return {'count': len(issues), 'issues': issues}
def find_duplicate_entries(env, params):
date_from = params.get('date_from')
date_to = params.get('date_to')
domain = [
('state', '=', 'posted'),
('company_id', '=', env.company.id),
]
if date_from:
domain.append(('date', '>=', date_from))
if date_to:
domain.append(('date', '<=', date_to))
moves = env['account.move'].search(domain, order='partner_id, amount_total, date')
duplicates = []
prev = None
for move in moves:
if prev and (
prev.partner_id == move.partner_id and prev.partner_id
and abs(prev.amount_total - move.amount_total) < 0.01
and prev.date == move.date
and prev.journal_id == move.journal_id
):
duplicates.append({
'entry_1': {'id': prev.id, 'name': prev.name},
'entry_2': {'id': move.id, 'name': move.name},
'partner': move.partner_id.name,
'amount': move.amount_total,
'date': str(move.date),
})
prev = move
return {'count': len(duplicates), 'duplicates': duplicates[:20]}
def find_wrong_account_entries(env, params):
date_from = params.get('date_from')
date_to = params.get('date_to')
domain = [
('parent_state', '=', 'posted'),
('company_id', '=', env.company.id),
]
if date_from:
domain.append(('date', '>=', date_from))
if date_to:
domain.append(('date', '<=', date_to))
issues = []
tax_accounts = env['account.account'].search([
('account_type', 'in', ('liability_current', 'asset_current')),
('code', '=like', '2005%'),
('company_ids', 'in', env.company.id),
])
if tax_accounts:
revenue_on_tax = env['account.move.line'].search(
domain + [
('account_id', 'in', tax_accounts.ids),
('product_id', '!=', False),
]
)
for line in revenue_on_tax[:20]:
issues.append({
'id': line.id,
'move': line.move_id.name,
'account': f'{line.account_id.code} {line.account_id.name}',
'product': line.product_id.name,
'amount': line.balance,
'issue': 'Product line on tax account',
})
return {'count': len(issues), 'issues': issues}
def find_sequence_gaps(env, params):
moves = env['account.move'].search([
('state', '=', 'posted'),
('company_id', '=', env.company.id),
('made_sequence_gap', '=', True),
], order='date desc', limit=50)
return {
'count': len(moves),
'gaps': [{
'id': m.id,
'name': m.name,
'date': str(m.date),
'journal': m.journal_id.name,
} for m in moves],
}
def find_draft_entries(env, params):
min_age_days = int(params.get('min_age_days', 30))
from datetime import timedelta
cutoff = fields.Date.today() - timedelta(days=min_age_days)
drafts = env['account.move'].search([
('state', '=', 'draft'),
('date', '<=', cutoff),
('company_id', '=', env.company.id),
], order='date asc', limit=50)
return {
'count': len(drafts),
'entries': [{
'id': d.id,
'name': d.name or 'Draft',
'date': str(d.date),
'journal': d.journal_id.name,
'amount': d.amount_total,
'partner': d.partner_id.name if d.partner_id else '',
} for d in drafts],
}
def find_unreconciled_suspense(env, params):
suspense_accounts = env['account.account'].search([
('code', '=like', '999%'),
('company_ids', 'in', env.company.id),
])
issues = []
for acct in suspense_accounts:
balance = sum(env['account.move.line'].search([
('account_id', '=', acct.id),
('parent_state', '=', 'posted'),
]).mapped('balance'))
if abs(balance) > 0.01:
issues.append({
'account_id': acct.id,
'code': acct.code,
'name': acct.name,
'balance': balance,
})
return {'count': len(issues), 'accounts': issues}
def verify_reconciliation_integrity(env, params):
partials = env['account.partial.reconcile'].search([
('company_id', '=', env.company.id),
], limit=500)
issues = []
for p in partials:
debit_ok = p.debit_move_id.reconciled or abs(p.debit_move_id.amount_residual) < 0.01
credit_ok = p.credit_move_id.reconciled or abs(p.credit_move_id.amount_residual) < 0.01
if not debit_ok and not credit_ok:
issues.append({
'id': p.id,
'debit_move': p.debit_move_id.move_id.name,
'credit_move': p.credit_move_id.move_id.name,
'amount': p.amount,
'debit_residual': p.debit_move_id.amount_residual,
'credit_residual': p.credit_move_id.amount_residual,
})
return {'count': len(issues), 'issues': issues[:20]}
TOOLS = {
'find_wrong_direction_balances': find_wrong_direction_balances,
'find_duplicate_entries': find_duplicate_entries,
'find_wrong_account_entries': find_wrong_account_entries,
'find_sequence_gaps': find_sequence_gaps,
'find_draft_entries': find_draft_entries,
'find_unreconciled_suspense': find_unreconciled_suspense,
'verify_reconciliation_integrity': verify_reconciliation_integrity,
}

View File

@@ -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,
}

View 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,
}

View File

@@ -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,
}