Task 13 Step 8 of phase-0 plan. get_ap_aging → FollowupAdapter.aged_payables(). The adapter method was added alongside aged_receivables() in the previous commit, so this is a pure tool-wrapper change. Other AP tools (find_duplicate_bills, get_unpaid_bills, get_payment_schedule, etc.) touch account.move / account.move.line with pure-Community filters (move_type in (in_invoice, in_refund)) which are tri-mode safe and do not need adapter routing. All 9 data-adapter tests pass on westin-v19. Made-with: Cursor
385 lines
15 KiB
Python
385 lines
15 KiB
Python
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,
|
|
}
|