500 lines
20 KiB
Python
500 lines
20 KiB
Python
import logging
|
|
from datetime import datetime
|
|
from odoo import fields
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
def get_unreconciled_bank_lines(env, params):
|
|
domain = [('is_reconciled', '=', False), ('company_id', '=', env.company.id)]
|
|
if params.get('journal_id'):
|
|
domain.append(('journal_id', '=', int(params['journal_id'])))
|
|
if params.get('date_from'):
|
|
domain.append(('date', '>=', params['date_from']))
|
|
if params.get('date_to'):
|
|
domain.append(('date', '<=', params['date_to']))
|
|
if params.get('min_amount'):
|
|
domain.append(('amount', '>=', float(params['min_amount'])))
|
|
limit = int(params.get('limit', 50))
|
|
lines = env['account.bank.statement.line'].search(domain, limit=limit, order='date desc')
|
|
return {
|
|
'count': len(lines),
|
|
'total_amount': sum(abs(l.amount) for l in lines),
|
|
'lines': [{
|
|
'id': l.id,
|
|
'date': str(l.date),
|
|
'payment_ref': l.payment_ref or '',
|
|
'partner_name': l.partner_name or (l.partner_id.name if l.partner_id else ''),
|
|
'amount': l.amount,
|
|
'journal': l.journal_id.name,
|
|
} for l in lines],
|
|
}
|
|
|
|
|
|
def get_unreconciled_receipts(env, params):
|
|
account_code = params.get('account_code', '1122')
|
|
accounts = env['account.account'].search([
|
|
('code', '=like', f'{account_code}%'),
|
|
('company_id', '=', env.company.id),
|
|
])
|
|
domain = [
|
|
('account_id', 'in', accounts.ids),
|
|
('reconciled', '=', False),
|
|
('parent_state', '=', 'posted'),
|
|
('company_id', '=', env.company.id),
|
|
]
|
|
lines = env['account.move.line'].search(domain, order='date desc')
|
|
return {
|
|
'count': len(lines),
|
|
'total_amount': sum(abs(l.amount_residual) for l in lines),
|
|
'lines': [{
|
|
'id': l.id,
|
|
'date': str(l.date),
|
|
'ref': l.ref or l.move_id.name,
|
|
'partner': l.partner_id.name if l.partner_id else '',
|
|
'amount_residual': l.amount_residual,
|
|
} for l in lines],
|
|
}
|
|
|
|
|
|
def match_bank_line_to_payments(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)
|
|
if not st_line.exists():
|
|
return {'error': 'Statement line not found'}
|
|
st_line.set_line_bank_statement_line(move_line_ids)
|
|
return {
|
|
'status': 'matched',
|
|
'statement_line_id': st_line_id,
|
|
'matched_move_lines': move_line_ids,
|
|
'is_reconciled': st_line.is_reconciled,
|
|
}
|
|
|
|
|
|
def auto_reconcile_bank_lines(env, params):
|
|
company_id = params.get('company_id', env.company.id)
|
|
lines = env['account.bank.statement.line'].search([
|
|
('is_reconciled', '=', False),
|
|
('company_id', '=', int(company_id)),
|
|
])
|
|
before_count = len(lines)
|
|
lines._try_auto_reconcile_statement_lines(company_id=int(company_id))
|
|
still_unreconciled = env['account.bank.statement.line'].search([
|
|
('is_reconciled', '=', False),
|
|
('company_id', '=', int(company_id)),
|
|
])
|
|
reconciled_count = before_count - len(still_unreconciled)
|
|
return {
|
|
'status': 'completed',
|
|
'lines_before': before_count,
|
|
'lines_reconciled': reconciled_count,
|
|
'lines_remaining': len(still_unreconciled),
|
|
}
|
|
|
|
|
|
def apply_reconcile_model(env, params):
|
|
model_id = int(params['model_id'])
|
|
st_line_id = int(params['statement_line_id'])
|
|
reco_model = env['account.reconcile.model'].browse(model_id)
|
|
st_line = env['account.bank.statement.line'].browse(st_line_id)
|
|
if not reco_model.exists() or not st_line.exists():
|
|
return {'error': 'Model or statement line not found'}
|
|
_liquidity_lines, suspense_lines, _other_lines = st_line._seek_for_lines()
|
|
residual = sum(l.amount_residual for l in suspense_lines) if suspense_lines else st_line.amount
|
|
write_off_vals = reco_model._get_write_off_move_lines_dict(st_line, residual)
|
|
if write_off_vals:
|
|
line_ids_create_command = [(0, 0, vals) for vals in write_off_vals]
|
|
st_line.move_id.write({'line_ids': line_ids_create_command})
|
|
return {
|
|
'status': 'applied',
|
|
'model': reco_model.name,
|
|
'write_off_lines': len(write_off_vals) if write_off_vals else 0,
|
|
}
|
|
|
|
|
|
def unmatch_bank_line(env, params):
|
|
st_line_id = int(params['statement_line_id'])
|
|
st_line = env['account.bank.statement.line'].browse(st_line_id)
|
|
if not st_line.exists():
|
|
return {'error': 'Statement line not found'}
|
|
st_line.action_unreconcile_entry()
|
|
return {'status': 'unmatched', 'statement_line_id': st_line_id}
|
|
|
|
|
|
def get_reconcile_suggestions(env, params):
|
|
st_line_id = int(params['statement_line_id'])
|
|
st_line = env['account.bank.statement.line'].browse(st_line_id)
|
|
if not st_line.exists():
|
|
return {'error': 'Statement line not found'}
|
|
models = env['account.reconcile.model'].search([
|
|
('company_id', '=', env.company.id),
|
|
])
|
|
return {
|
|
'models': [{
|
|
'id': m.id,
|
|
'name': m.name,
|
|
'trigger': m.trigger if hasattr(m, 'trigger') else 'manual',
|
|
} for m in models],
|
|
}
|
|
|
|
|
|
def sum_payments_by_date(env, params):
|
|
"""Sum payment/journal activity for a date range.
|
|
IMPORTANT: Always pass journal_ids to filter to specific journals.
|
|
Without journal_ids, returns totals across ALL journals which is
|
|
almost never what you want for reconciliation."""
|
|
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'}
|
|
journal_ids = params.get('journal_ids', [])
|
|
domain = [
|
|
('parent_state', '=', 'posted'),
|
|
('company_id', '=', env.company.id),
|
|
('date', '>=', date_from),
|
|
('date', '<=', date_to),
|
|
]
|
|
scope = 'all journals'
|
|
if journal_ids:
|
|
jids = [int(j) for j in journal_ids]
|
|
domain.append(('journal_id', 'in', jids))
|
|
journals = env['account.journal'].browse(jids)
|
|
scope = ', '.join(j.name for j in journals if j.exists())
|
|
else:
|
|
# Without journal filter, include a warning and break down by journal
|
|
pass
|
|
|
|
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)
|
|
|
|
result = {
|
|
'date_from': date_from,
|
|
'date_to': date_to,
|
|
'total_debit': total_debit,
|
|
'total_credit': total_credit,
|
|
'net': total_debit - total_credit,
|
|
'line_count': len(lines),
|
|
'scope': scope,
|
|
}
|
|
|
|
# If no journal filter, add per-journal breakdown so AI doesn't
|
|
# mistake company-wide totals for a specific journal's activity
|
|
if not journal_ids:
|
|
result['warning'] = (
|
|
'No journal_ids filter was provided. These totals are across ALL '
|
|
'journals in the company. To get card payment totals, pass the '
|
|
'specific card/POS journal IDs.'
|
|
)
|
|
journal_totals = {}
|
|
for l in lines:
|
|
jname = l.journal_id.name
|
|
if jname not in journal_totals:
|
|
journal_totals[jname] = {'debit': 0.0, 'credit': 0.0, 'count': 0}
|
|
journal_totals[jname]['debit'] += l.debit
|
|
journal_totals[jname]['credit'] += l.credit
|
|
journal_totals[jname]['count'] += 1
|
|
result['by_journal'] = [
|
|
{'journal': jn, 'debit': v['debit'], 'credit': v['credit'], 'count': v['count']}
|
|
for jn, v in sorted(journal_totals.items(), key=lambda x: -x[1]['debit'])
|
|
][:15]
|
|
|
|
return result
|
|
|
|
|
|
def get_bank_line_details(env, params):
|
|
"""Get full details of a single bank statement line plus matching suggestions."""
|
|
line_id = int(params['line_id'])
|
|
line = env['account.bank.statement.line'].browse(line_id)
|
|
if not line.exists():
|
|
return {'error': 'Bank statement line not found'}
|
|
|
|
result = {
|
|
'id': line.id,
|
|
'date': str(line.date),
|
|
'payment_ref': line.payment_ref or '',
|
|
'partner_name': line.partner_name or (line.partner_id.name if line.partner_id else ''),
|
|
'partner_id': line.partner_id.id if line.partner_id else None,
|
|
'amount': line.amount,
|
|
'journal': line.journal_id.name,
|
|
'journal_id': line.journal_id.id,
|
|
'is_reconciled': line.is_reconciled,
|
|
'existing_bills': [],
|
|
'suggested_partner': None,
|
|
}
|
|
|
|
# Search for existing vendor bills matching amount ± $0.50 and date ± 3 days
|
|
abs_amount = abs(line.amount)
|
|
from datetime import timedelta as td
|
|
date_from = line.date - td(days=3)
|
|
date_to = line.date + td(days=3)
|
|
matching_bills = env['account.move'].search([
|
|
('move_type', '=', 'in_invoice'),
|
|
('state', '=', 'posted'),
|
|
('amount_total', '>=', abs_amount - 0.50),
|
|
('amount_total', '<=', abs_amount + 0.50),
|
|
('date', '>=', str(date_from)),
|
|
('date', '<=', str(date_to)),
|
|
('company_id', '=', env.company.id),
|
|
], limit=5)
|
|
for bill in matching_bills:
|
|
result['existing_bills'].append({
|
|
'id': bill.id,
|
|
'name': bill.name,
|
|
'partner': bill.partner_id.name if bill.partner_id else '',
|
|
'amount_total': bill.amount_total,
|
|
'date': str(bill.date),
|
|
'payment_state': bill.payment_state,
|
|
})
|
|
|
|
# Try to suggest a partner from payment_ref keyword
|
|
if line.payment_ref and not line.partner_id:
|
|
# Extract meaningful words from payment_ref (skip common banking terms)
|
|
skip_words = {'misc', 'payment', 'online', 'banking', 'pad', 'business',
|
|
'deposit', 'cheque', 'transfer', 'e-transfer', 'sent', 'autodeposit'}
|
|
words = [w for w in line.payment_ref.split() if len(w) > 2 and w.lower() not in skip_words]
|
|
for word in words[:3]:
|
|
partners = env['res.partner'].search([
|
|
('name', 'ilike', word),
|
|
('supplier_rank', '>', 0),
|
|
], limit=3)
|
|
if partners:
|
|
result['suggested_partner'] = {
|
|
'id': partners[0].id,
|
|
'name': partners[0].name,
|
|
'match_word': word,
|
|
}
|
|
break
|
|
|
|
return result
|
|
|
|
|
|
def check_recurring_pattern(env, params):
|
|
"""Check if a bank line matches a known recurring payment pattern.
|
|
Returns the historical coding (account, HST, partner, reconcile model) if found."""
|
|
line_id = params.get('line_id')
|
|
payment_ref = params.get('payment_ref', '')
|
|
amount = params.get('amount')
|
|
|
|
# If line_id provided, get the ref and amount from the line
|
|
if line_id:
|
|
line = env['account.bank.statement.line'].browse(int(line_id))
|
|
if line.exists():
|
|
payment_ref = line.payment_ref or ''
|
|
amount = line.amount
|
|
|
|
if not payment_ref:
|
|
return {'match': False, 'reason': 'No payment reference to match'}
|
|
|
|
# Search cached patterns by keyword
|
|
patterns = env['fusion.recurring.pattern'].search([
|
|
('company_id', '=', env.company.id),
|
|
])
|
|
|
|
best_match = None
|
|
for pat in patterns:
|
|
if not pat.ref_keyword:
|
|
continue
|
|
# Check if the pattern keyword appears in the payment_ref
|
|
if pat.ref_keyword.lower()[:30] in payment_ref.lower():
|
|
# If amount matches too, it's a strong match
|
|
if amount and pat.amount_is_fixed and abs(pat.amount - amount) < 0.01:
|
|
best_match = pat
|
|
break
|
|
# Keyword-only match (amount may vary)
|
|
if not best_match or pat.occurrences > best_match.occurrences:
|
|
best_match = pat
|
|
|
|
if not best_match:
|
|
return {'match': False, 'payment_ref': payment_ref}
|
|
|
|
result = {
|
|
'match': True,
|
|
'pattern_id': best_match.id,
|
|
'pattern_name': best_match.name,
|
|
'occurrences': best_match.occurrences,
|
|
'first_seen': str(best_match.first_seen) if best_match.first_seen else '',
|
|
'last_seen': str(best_match.last_seen) if best_match.last_seen else '',
|
|
'expense_account_id': best_match.expense_account_id.id if best_match.expense_account_id else None,
|
|
'expense_account_code': best_match.expense_account_code or '',
|
|
'expense_account_name': best_match.expense_account_id.name if best_match.expense_account_id else '',
|
|
'has_hst': best_match.has_hst,
|
|
'partner_id': best_match.partner_id.id if best_match.partner_id else None,
|
|
'partner_name': best_match.partner_id.name if best_match.partner_id else '',
|
|
'action_note': best_match.action_note or '',
|
|
'amount_is_fixed': best_match.amount_is_fixed,
|
|
}
|
|
if best_match.reconcile_model_id:
|
|
result['reconcile_model_id'] = best_match.reconcile_model_id.id
|
|
result['reconcile_model_name'] = best_match.reconcile_model_id.name
|
|
|
|
return result
|
|
|
|
|
|
def match_internal_transfers(env, params):
|
|
"""[Tier 3] Find and match inter-account transfers between two bank journals.
|
|
Matches exact amounts within a date window. Only matches when there is exactly
|
|
ONE candidate on each side (no ambiguous matches). Requires user approval.
|
|
|
|
Typical use: Scotia Current Account ↔ Scotia Visa payments."""
|
|
journal_a_id = int(params['journal_a_id']) # e.g., Scotia Current (50)
|
|
journal_b_id = int(params['journal_b_id']) # e.g., Scotia Visa (51)
|
|
date_from = params.get('date_from', '2025-01-01')
|
|
date_to = params.get('date_to', '2025-03-31')
|
|
max_days_apart = int(params.get('max_days_apart', 2))
|
|
|
|
# Get unreconciled positive lines from both journals
|
|
# (transfers show as positive on the RECEIVING side)
|
|
lines_a = env['account.bank.statement.line'].search([
|
|
('is_reconciled', '=', False),
|
|
('journal_id', '=', journal_a_id),
|
|
('company_id', '=', env.company.id),
|
|
])
|
|
lines_a = lines_a.filtered(
|
|
lambda l: l.move_id.date >= fields.Date.from_string(date_from)
|
|
and l.move_id.date <= fields.Date.from_string(date_to)
|
|
and l.amount > 0 # money coming IN on this account
|
|
)
|
|
|
|
lines_b = env['account.bank.statement.line'].search([
|
|
('is_reconciled', '=', False),
|
|
('journal_id', '=', journal_b_id),
|
|
('company_id', '=', env.company.id),
|
|
])
|
|
lines_b = lines_b.filtered(
|
|
lambda l: l.move_id.date >= fields.Date.from_string(date_from)
|
|
and l.move_id.date <= fields.Date.from_string(date_to)
|
|
and l.amount > 0 # money coming IN on this account
|
|
)
|
|
|
|
matched_pairs = []
|
|
used_a = set()
|
|
used_b = set()
|
|
|
|
# For each line in A, find exact-amount match in B within date window
|
|
for la in sorted(lines_a, key=lambda l: l.move_id.date):
|
|
if la.id in used_a:
|
|
continue
|
|
candidates = []
|
|
for lb in lines_b:
|
|
if lb.id in used_b:
|
|
continue
|
|
if abs(la.amount - lb.amount) < 0.01:
|
|
days = abs((la.move_id.date - lb.move_id.date).days)
|
|
if days <= max_days_apart:
|
|
candidates.append(lb)
|
|
# Only match if EXACTLY ONE candidate — skip ambiguous
|
|
if len(candidates) == 1:
|
|
lb = candidates[0]
|
|
matched_pairs.append({
|
|
'line_a_id': la.id,
|
|
'line_a_date': str(la.move_id.date),
|
|
'line_a_ref': la.payment_ref or '',
|
|
'line_a_journal': la.journal_id.name,
|
|
'line_b_id': lb.id,
|
|
'line_b_date': str(lb.move_id.date),
|
|
'line_b_ref': lb.payment_ref or '',
|
|
'line_b_journal': lb.journal_id.name,
|
|
'amount': la.amount,
|
|
'days_apart': abs((la.move_id.date - lb.move_id.date).days),
|
|
})
|
|
used_a.add(la.id)
|
|
used_b.add(lb.id)
|
|
|
|
if not matched_pairs:
|
|
return {
|
|
'status': 'no_matches',
|
|
'message': 'No unambiguous transfer pairs found.',
|
|
'lines_a_checked': len(lines_a),
|
|
'lines_b_checked': len(lines_b),
|
|
}
|
|
|
|
# If this is just a dry-run check (no execute flag), return the pairs for review
|
|
if not params.get('execute', False):
|
|
return {
|
|
'status': 'pairs_found',
|
|
'count': len(matched_pairs),
|
|
'pairs': matched_pairs,
|
|
'message': f'Found {len(matched_pairs)} unambiguous transfer pairs. Set execute=true to reconcile them.',
|
|
}
|
|
|
|
# Execute: create internal transfer journal entries to reconcile both sides
|
|
reconciled = []
|
|
for pair in matched_pairs:
|
|
try:
|
|
line_a = env['account.bank.statement.line'].browse(pair['line_a_id'])
|
|
line_b = env['account.bank.statement.line'].browse(pair['line_b_id'])
|
|
|
|
# Create an internal transfer payment
|
|
payment = env['account.payment'].create({
|
|
'payment_type': 'outbound',
|
|
'partner_type': 'supplier',
|
|
'partner_id': env.company.partner_id.id, # Self as partner for internal transfer
|
|
'amount': pair['amount'],
|
|
'journal_id': journal_a_id,
|
|
'destination_journal_id': journal_b_id,
|
|
'date': line_a.move_id.date,
|
|
'ref': f'Internal Transfer: {pair["line_a_ref"]} ↔ {pair["line_b_ref"]}',
|
|
'is_internal_transfer': True,
|
|
})
|
|
payment.action_post()
|
|
|
|
# Now match the payment's move lines to the bank statement lines
|
|
# The payment creates lines on both journals' outstanding accounts
|
|
for move_line in payment.move_id.line_ids:
|
|
if move_line.journal_id.id == journal_a_id and not move_line.reconciled:
|
|
try:
|
|
line_a.set_line_bank_statement_line(move_line.ids)
|
|
except Exception:
|
|
pass
|
|
# Check paired transfer for the other side
|
|
if payment.paired_internal_transfer_payment_id:
|
|
paired = payment.paired_internal_transfer_payment_id
|
|
for move_line in paired.move_id.line_ids:
|
|
if move_line.journal_id.id == journal_b_id and not move_line.reconciled:
|
|
try:
|
|
line_b.set_line_bank_statement_line(move_line.ids)
|
|
except Exception:
|
|
pass
|
|
|
|
reconciled.append({
|
|
'line_a_id': pair['line_a_id'],
|
|
'line_b_id': pair['line_b_id'],
|
|
'amount': pair['amount'],
|
|
'payment_id': payment.id,
|
|
'status': 'reconciled',
|
|
})
|
|
except Exception as e:
|
|
_logger.error("Failed to reconcile transfer pair %s: %s", pair, e)
|
|
reconciled.append({
|
|
'line_a_id': pair['line_a_id'],
|
|
'line_b_id': pair['line_b_id'],
|
|
'amount': pair['amount'],
|
|
'status': 'error',
|
|
'error': str(e),
|
|
})
|
|
|
|
return {
|
|
'status': 'executed',
|
|
'total_pairs': len(matched_pairs),
|
|
'reconciled': len([r for r in reconciled if r['status'] == 'reconciled']),
|
|
'errors': len([r for r in reconciled if r['status'] == 'error']),
|
|
'details': reconciled,
|
|
}
|
|
|
|
|
|
TOOLS = {
|
|
'get_unreconciled_bank_lines': get_unreconciled_bank_lines,
|
|
'get_unreconciled_receipts': get_unreconciled_receipts,
|
|
'match_bank_line_to_payments': match_bank_line_to_payments,
|
|
'auto_reconcile_bank_lines': auto_reconcile_bank_lines,
|
|
'apply_reconcile_model': apply_reconcile_model,
|
|
'unmatch_bank_line': unmatch_bank_line,
|
|
'get_reconcile_suggestions': get_reconcile_suggestions,
|
|
'sum_payments_by_date': sum_payments_by_date,
|
|
'get_bank_line_details': get_bank_line_details,
|
|
'check_recurring_pattern': check_recurring_pattern,
|
|
'match_internal_transfers': match_internal_transfers,
|
|
}
|