Files
Odoo-Modules/fusion_accounting/services/tools/accounts_payable.py
gsinghpal c66bdf5089 changes
2026-04-03 15:45:18 -04:00

407 lines
16 KiB
Python

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