import logging from odoo import fields from datetime import timedelta _logger = logging.getLogger(__name__) def get_ap_aging(env, params): today = fields.Date.today() domain = [ ('account_id.account_type', '=', 'liability_payable'), ('parent_state', '=', 'posted'), ('reconciled', '=', False), ('company_id', '=', env.company.id), ] amls = env['account.move.line'].search(domain) buckets = {'current': 0, '1_30': 0, '31_60': 0, '61_90': 0, '90_plus': 0} for aml in amls: amt = abs(aml.amount_residual) if not aml.date_maturity or aml.date_maturity >= today: buckets['current'] += amt else: days = (today - aml.date_maturity).days if days <= 30: buckets['1_30'] += amt elif days <= 60: buckets['31_60'] += amt elif days <= 90: buckets['61_90'] += amt else: buckets['90_plus'] += amt return {'total': sum(buckets.values()), 'buckets': buckets, 'line_count': len(amls)} def find_duplicate_bills(env, params): window_days = int(params.get('window_days', 7)) bills = env['account.move'].search([ ('move_type', 'in', ('in_invoice', 'in_refund')), ('state', '=', 'posted'), ('company_id', '=', env.company.id), ], order='partner_id, amount_total, date') duplicates = [] prev = None for bill in bills: if prev and ( prev.partner_id == bill.partner_id and abs(prev.amount_total - bill.amount_total) < 0.01 and abs((prev.date - bill.date).days) <= window_days ): duplicates.append({ 'bill_1': {'id': prev.id, 'name': prev.name, 'date': str(prev.date)}, 'bill_2': {'id': bill.id, 'name': bill.name, 'date': str(bill.date)}, 'partner': bill.partner_id.name, 'amount': bill.amount_total, }) prev = bill return {'count': len(duplicates), 'duplicates': duplicates[:20]} def match_bill_to_po(env, params): bill_id = int(params['bill_id']) bill = env['account.move'].browse(bill_id) if not bill.exists(): return {'error': 'Bill not found'} matches = [] for line in bill.invoice_line_ids: if line.purchase_line_id: matches.append({ 'bill_line': line.name or '', 'po': line.purchase_line_id.order_id.name, 'po_line': line.purchase_line_id.name, 'po_qty': line.purchase_line_id.product_qty, 'bill_qty': line.quantity, 'match': abs(line.quantity - line.purchase_line_id.product_qty) < 0.01, }) return {'bill': bill.name, 'matches': matches, 'unmatched_lines': len(bill.invoice_line_ids) - len(matches)} def get_unpaid_bills(env, params): domain = [ ('move_type', 'in', ('in_invoice', 'in_refund')), ('state', '=', 'posted'), ('payment_state', 'in', ('not_paid', 'partial')), ('company_id', '=', env.company.id), ] if params.get('partner_id'): domain.append(('partner_id', '=', int(params['partner_id']))) bills = env['account.move'].search(domain, order='invoice_date_due asc', limit=int(params.get('limit', 50))) return { 'count': len(bills), 'total': sum(b.amount_residual for b in bills), 'bills': [{ 'id': b.id, 'name': b.name, 'partner': b.partner_id.name if b.partner_id else '', 'amount_total': b.amount_total, 'amount_residual': b.amount_residual, 'date_due': str(b.invoice_date_due) if b.invoice_date_due else '', } for b in bills], } def verify_bill_taxes(env, params): bill_id = int(params['bill_id']) bill = env['account.move'].browse(bill_id) if not bill.exists(): return {'error': 'Bill not found'} issues = [] for line in bill.invoice_line_ids: if line.product_id and not line.tax_ids: issues.append({ 'line': line.name or line.product_id.name, 'issue': 'No tax applied to product line', }) return {'bill': bill.name, 'issues': issues, 'clean': len(issues) == 0} def get_payment_schedule(env, params): days_ahead = int(params.get('days_ahead', 30)) cutoff = fields.Date.today() + timedelta(days=days_ahead) bills = env['account.move'].search([ ('move_type', '=', 'in_invoice'), ('state', '=', 'posted'), ('payment_state', 'in', ('not_paid', 'partial')), ('invoice_date_due', '<=', cutoff), ('company_id', '=', env.company.id), ], order='invoice_date_due asc') return { 'period': f'Next {days_ahead} days', 'total': sum(b.amount_residual for b in bills), 'bills': [{ 'id': b.id, 'name': b.name, 'partner': b.partner_id.name if b.partner_id else '', 'amount_residual': b.amount_residual, 'date_due': str(b.invoice_date_due) if b.invoice_date_due else '', } for b in bills[:50]], } def search_partners(env, params): """Search for partners/vendors by name keyword.""" keyword = params.get('keyword', '') if not keyword or len(keyword) < 2: return {'error': 'Keyword must be at least 2 characters'} domain = [('name', 'ilike', keyword), ('company_id', 'in', [env.company.id, False])] if params.get('supplier_only'): domain.append(('supplier_rank', '>', 0)) partners = env['res.partner'].search(domain, limit=int(params.get('limit', 20))) return { 'count': len(partners), 'partners': [{ 'id': p.id, 'name': p.name, 'supplier_rank': p.supplier_rank, 'customer_rank': p.customer_rank, 'vat': p.vat or '', 'email': p.email or '', 'phone': p.phone or '', } for p in partners], } def find_similar_bank_lines(env, params): """Find past reconciled bank lines with similar description to suggest coding patterns. Also checks vendor bill tax patterns if a partner is identified.""" keyword = params.get('keyword', '') if not keyword or len(keyword) < 3: return {'error': 'Keyword must be at least 3 characters'} # Find reconciled bank lines with matching payment_ref lines = env['account.bank.statement.line'].search([ ('is_reconciled', '=', True), ('payment_ref', 'ilike', keyword), ('company_id', '=', env.company.id), ], order='date desc', limit=int(params.get('limit', 10))) matches = [] found_partner_id = None for line in lines: move = line.move_id if not move: continue expense_info = {'account_code': '', 'account_name': '', 'tax_applied': False, 'tax_amount': 0.0} for ml in move.line_ids: if ml.account_id.account_type in ('expense', 'expense_direct_cost', 'expense_depreciation'): expense_info['account_code'] = ml.account_id.code expense_info['account_name'] = ml.account_id.name expense_info['tax_applied'] = bool(ml.tax_ids) expense_info['tax_amount'] = sum(t.amount for t in ml.tax_ids) if ml.tax_ids else 0.0 break if line.partner_id and not found_partner_id: found_partner_id = line.partner_id.id matches.append({ 'id': line.id, 'date': str(line.date), 'payment_ref': line.payment_ref or '', 'amount': line.amount, 'partner': line.partner_id.name if line.partner_id else '', 'partner_id': line.partner_id.id if line.partner_id else None, 'expense_account': expense_info['account_code'], 'expense_account_name': expense_info['account_name'], 'tax_applied': expense_info['tax_applied'], 'tax_rate': expense_info['tax_amount'], }) result = { 'keyword': keyword, 'count': len(matches), 'matches': matches, 'suggestion': matches[0] if matches else None, } # Check vendor tax profile cache first (fast), fall back to live query partner_id = found_partner_id or (int(params['partner_id']) if params.get('partner_id') else None) if partner_id: profile = env['fusion.vendor.tax.profile'].search([ ('partner_id', '=', partner_id), ('company_id', '=', env.company.id), ], limit=1) if profile: result['vendor_tax_pattern'] = { 'source': 'cached_profile', 'total_bills': profile.total_bills, 'bills_with_tax': profile.bills_with_hst, 'bills_no_tax': profile.bills_zero_rated, 'avg_tax_pct': profile.avg_tax_pct, 'tax_classification': profile.tax_classification, 'tax_note': profile.tax_note, 'primary_account_id': profile.primary_account_id.id if profile.primary_account_id else None, 'primary_account_code': profile.primary_account_code or '', 'is_foreign': profile.is_foreign, 'is_po_vendor': profile.is_po_vendor, 'po_count': profile.po_count, } else: # No cached profile — live query for new/small vendors bills = env['account.move'].search([ ('move_type', '=', 'in_invoice'), ('state', '=', 'posted'), ('partner_id', '=', partner_id), ], order='date desc', limit=10) tax_stats = {'source': 'live_query', 'total_bills': len(bills), 'bills_with_tax': 0, 'bills_no_tax': 0, 'avg_tax_pct': 0.0, 'tax_note': ''} tax_pcts = [] for bill in bills: if bill.amount_tax > 0.01: tax_stats['bills_with_tax'] += 1 if bill.amount_untaxed > 0: tax_pcts.append(round(bill.amount_tax / bill.amount_untaxed * 100, 2)) else: tax_stats['bills_no_tax'] += 1 if tax_pcts: tax_stats['avg_tax_pct'] = round(sum(tax_pcts) / len(tax_pcts), 2) if tax_stats['total_bills'] > 0: if tax_stats['bills_no_tax'] == tax_stats['total_bills']: tax_stats['tax_note'] = 'This vendor NEVER charges HST. All bills are zero-rated.' elif tax_stats['avg_tax_pct'] < 2.0 and tax_stats['bills_with_tax'] > 0: tax_stats['tax_note'] = ( f'HST only on shipping (avg {tax_stats["avg_tax_pct"]}%). ' f'Do NOT apply HST to full amount.' ) elif tax_stats['avg_tax_pct'] >= 12.0: tax_stats['tax_note'] = f'Consistently charges HST at ~{tax_stats["avg_tax_pct"]}%.' result['vendor_tax_pattern'] = tax_stats return result def create_vendor_bill(env, params): """[Tier 3] Create a vendor bill (account.move with move_type='in_invoice'). Requires user approval before execution.""" partner_id = int(params['partner_id']) invoice_date = params.get('invoice_date', str(fields.Date.today())) bill_lines = params.get('lines', []) if not bill_lines: return {'error': 'At least one invoice line is required'} partner = env['res.partner'].browse(partner_id) if not partner.exists(): return {'error': f'Partner not found: {partner_id}'} invoice_line_vals = [] for line in bill_lines: line_vals = { 'name': line.get('description', 'Expense'), 'price_unit': float(line.get('price_unit', 0)), 'quantity': float(line.get('quantity', 1)), } if line.get('account_id'): line_vals['account_id'] = int(line['account_id']) if line.get('tax_ids'): line_vals['tax_ids'] = [(6, 0, [int(t) for t in line['tax_ids']])] invoice_line_vals.append((0, 0, line_vals)) try: bill = env['account.move'].create({ 'move_type': 'in_invoice', 'partner_id': partner_id, 'invoice_date': invoice_date, 'date': invoice_date, 'invoice_line_ids': invoice_line_vals, 'company_id': env.company.id, }) if params.get('post', False): bill.action_post() return { 'status': 'created', 'bill_id': bill.id, 'bill_name': bill.name, 'partner': partner.name, 'amount_total': bill.amount_total, 'state': bill.state, } except Exception as e: _logger.error("Failed to create vendor bill: %s", e) return {'error': str(e)} def register_bill_payment(env, params): """[Tier 3] Register payment on a posted vendor bill and optionally reconcile to bank line. Requires user approval before execution.""" bill_id = int(params['bill_id']) journal_id = int(params['journal_id']) bill = env['account.move'].browse(bill_id) if not bill.exists() or bill.state != 'posted': return {'error': 'Bill not found or not posted'} payment_date = params.get('payment_date', str(fields.Date.today())) try: # Use the payment register wizard ctx = { 'active_model': 'account.move', 'active_ids': [bill_id], } wizard = env['account.payment.register'].with_context(**ctx).create({ 'journal_id': journal_id, 'payment_date': payment_date, }) # Optionally set amount if provided (otherwise defaults to bill amount) if params.get('amount'): wizard.amount = float(params['amount']) payments = wizard.action_create_payments() # Find the created payment payment = None if isinstance(payments, dict) and payments.get('res_id'): payment = env['account.payment'].browse(payments['res_id']) elif isinstance(payments, dict) and payments.get('domain'): payment = env['account.payment'].search(payments['domain'], limit=1) else: # Fallback: find the latest payment for this bill payment = env['account.payment'].search([ ('partner_id', '=', bill.partner_id.id), ], order='create_date desc', limit=1) result = { 'status': 'paid', 'bill_id': bill_id, 'bill_name': bill.name, 'payment_state': bill.payment_state, } if payment: result['payment_id'] = payment.id result['payment_name'] = payment.name # Optionally reconcile to a bank statement line if params.get('statement_line_id') and payment: try: st_line = env['account.bank.statement.line'].browse(int(params['statement_line_id'])) if st_line.exists() and not st_line.is_reconciled: # Find the payment's move lines on the bank's outstanding account pay_move_lines = payment.move_id.line_ids.filtered( lambda l: l.account_id.reconcile and not l.reconciled ) if pay_move_lines: st_line.set_line_bank_statement_line(pay_move_lines.ids) result['reconciled'] = True result['statement_line_id'] = st_line.id except Exception as e: _logger.warning("Payment created but bank reconciliation failed: %s", e) result['reconcile_error'] = str(e) return result except Exception as e: _logger.error("Failed to register payment: %s", e) return {'error': str(e)} TOOLS = { 'get_ap_aging': get_ap_aging, 'find_duplicate_bills': find_duplicate_bills, 'match_bill_to_po': match_bill_to_po, 'get_unpaid_bills': get_unpaid_bills, 'verify_bill_taxes': verify_bill_taxes, 'get_payment_schedule': get_payment_schedule, 'search_partners': search_partners, 'find_similar_bank_lines': find_similar_bank_lines, 'create_vendor_bill': create_vendor_bill, 'register_bill_payment': register_bill_payment, }