This commit is contained in:
gsinghpal
2026-04-04 15:37:16 -04:00
parent c66bdf5089
commit 3cc93b8783
36 changed files with 3278 additions and 548 deletions

View File

@@ -35,7 +35,7 @@ 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),
('company_ids', 'in', env.company.id),
])
domain = [
('account_id', 'in', accounts.ids),
@@ -484,6 +484,464 @@ def match_internal_transfers(env, params):
}
def find_unreconciled_cheques(env, params):
"""Find unreconciled cheque bank lines and classify as payroll vs non-payroll
by checking if the amount matches an existing payroll liability entry."""
PAYROLL_ACCT = 433 # 2201 Payroll Liabilities
journal_id = int(params.get('journal_id', 50)) # Default Scotia Current
limit = int(params.get('limit', 50))
AML = env['account.move.line'].sudo()
BSL = env['account.bank.statement.line'].sudo()
# Build set of known payroll liability amounts
payroll_amounts = set()
for aml in AML.search([
('account_id', '=', PAYROLL_ACCT),
('parent_state', '=', 'posted'),
('credit', '>', 0),
]):
payroll_amounts.add(round(aml.credit, 2))
cheque_lines = BSL.search([
('journal_id', '=', journal_id),
('is_reconciled', '=', False),
('payment_ref', 'ilike', 'cheque'),
('amount', '<', 0),
('company_id', '=', env.company.id),
], limit=limit, order='move_id desc')
payroll = []
non_payroll = []
for line in cheque_lines:
amt = round(abs(line.amount), 2)
entry = {
'id': line.id,
'date': str(line.move_id.date),
'ref': line.payment_ref or '',
'amount': amt,
'journal': line.journal_id.name,
}
if amt in payroll_amounts:
entry['type'] = 'payroll'
payroll.append(entry)
else:
entry['type'] = 'non_payroll'
non_payroll.append(entry)
return {
'count': len(cheque_lines),
'payroll_count': len(payroll),
'non_payroll_count': len(non_payroll),
'payroll': payroll,
'non_payroll': non_payroll,
}
def reconcile_payroll_cheques(env, params):
"""Reconcile payroll cheque bank lines by applying the Payroll Cheque Clearing
reconcile model. Only reconciles cheques whose amount matches an existing
payroll liability entry on account 2201. Non-payroll cheques are skipped.
Params:
journal_id (int): Bank journal ID (default 50 = Scotia Current)
line_ids (list): Optional list of specific bank line IDs to reconcile.
If not provided, reconciles all matching payroll cheques.
"""
PAYROLL_ACCT = 433
journal_id = int(params.get('journal_id', 50))
AML = env['account.move.line'].sudo()
BSL = env['account.bank.statement.line'].sudo()
RecModel = env['account.reconcile.model'].sudo()
model = RecModel.search([
('name', 'ilike', 'Payroll Cheque'),
('company_id', '=', env.company.id),
], limit=1)
if not model:
return {'error': 'No "Payroll Cheque Clearing" reconcile model found. Create one first.'}
# Get lines to process
if params.get('line_ids'):
cheque_lines = BSL.browse([int(x) for x in params['line_ids']])
cheque_lines = cheque_lines.filtered(lambda l: not l.is_reconciled)
else:
cheque_lines = BSL.search([
('journal_id', '=', journal_id),
('is_reconciled', '=', False),
('payment_ref', 'ilike', 'cheque'),
('amount', '<', 0),
('company_id', '=', env.company.id),
])
# Filter post-lock-date
lock = env.company.fiscalyear_lock_date
if lock:
cheque_lines = cheque_lines.filtered(lambda l: l.move_id.date > lock)
# Filter to payroll-only amounts
payroll_amounts = set()
for aml in AML.search([
('account_id', '=', PAYROLL_ACCT),
('parent_state', '=', 'posted'),
('credit', '>', 0),
]):
payroll_amounts.add(round(aml.credit, 2))
payroll_lines = cheque_lines.filtered(
lambda l: round(abs(l.amount), 2) in payroll_amounts
)
skipped = len(cheque_lines) - len(payroll_lines)
if not payroll_lines:
return {
'status': 'nothing_to_do',
'message': f'No payroll cheques to reconcile ({skipped} non-payroll cheques skipped)',
}
try:
model._apply_reconcile_models(payroll_lines)
env.cr.commit()
except Exception as e:
return {'error': f'Reconciliation failed: {e}'}
still = payroll_lines.filtered(lambda l: not l.is_reconciled)
reconciled = len(payroll_lines) - len(still)
return {
'status': 'completed',
'reconciled': reconciled,
'still_unreconciled': len(still),
'non_payroll_skipped': skipped,
'message': f'Reconciled {reconciled} payroll cheques. {skipped} non-payroll cheques skipped.',
}
def _extract_partner_from_ref(env, payment_ref):
"""Extract a partner from a bank line payment_ref using keyword matching."""
if not payment_ref:
return None
skip_words = {
'misc', 'payment', 'online', 'banking', 'pad', 'business', 'deposit',
'cheque', 'transfer', 'e-transfer', 'sent', 'autodeposit', 'credit',
'debit', 'memo', 'free', 'interac', 'from', 'the', 'and', 'for',
'miscellaneous', 'bill', 'correction', 'adjustment', 'other',
}
# Strip common suffixes like colons and split
clean_ref = payment_ref.replace(':', ' ').replace('-', ' ')
words = [w for w in clean_ref.split() if len(w) > 2 and w.lower() not in skip_words]
# Try progressively shorter phrases
for n in range(min(len(words), 4), 0, -1):
for i in range(len(words) - n + 1):
phrase = ' '.join(words[i:i+n])
partners = env['res.partner'].search([
('name', 'ilike', phrase),
('company_id', 'in', [env.company.id, False]),
], limit=3)
if partners:
return partners[0]
# Fallback: try each word individually with supplier/customer rank
for word in words:
if len(word) < 4:
continue
partners = env['res.partner'].search([
('name', 'ilike', word),
('company_id', 'in', [env.company.id, False]),
'|', ('customer_rank', '>', 0), ('supplier_rank', '>', 0),
], limit=3)
if partners:
return partners[0]
return None
def _find_best_subset(candidates, target, max_items=8):
"""Find the subset of candidates whose amounts sum closest to target.
Returns (aml_ids, total) for the best combination."""
items = candidates[:max_items]
if not items:
return [], 0.0
best_ids = []
best_total = 0.0
best_diff = abs(target)
n = len(items)
# Brute force all subsets (2^n, max 256)
for mask in range(1, 1 << n):
subset_ids = []
subset_total = 0.0
for j in range(n):
if mask & (1 << j):
subset_ids.append(items[j]['aml_id'])
subset_total += items[j]['amount_residual']
diff = abs(subset_total - target)
if diff < best_diff:
best_diff = diff
best_ids = subset_ids
best_total = subset_total
if diff < 0.01:
break # Exact match found
return best_ids, round(best_total, 2)
def suggest_bank_line_matches(env, params):
"""Find candidate journal items (invoices/bills) that could match a bank statement line.
Scores and ranks matches, finds best subset-sum combination.
Returns data for a reconciliation-mode fusion-table."""
line_id = int(params['statement_line_id'])
line = env['account.bank.statement.line'].browse(line_id)
if not line.exists():
return {'error': 'Bank statement line not found'}
if line.is_reconciled:
return {'error': 'Bank statement line is already reconciled'}
AML = env['account.move.line'].sudo()
bank_amount = abs(line.amount)
line_date = line.move_id.date
is_incoming = line.amount > 0 # positive = customer payment, negative = vendor payment
from datetime import timedelta as td
# Determine partner
partner = line.partner_id
if not partner:
partner = _extract_partner_from_ref(env, line.payment_ref)
# Base domain common to all searches
base_domain = [
('parent_state', '=', 'posted'),
('reconciled', '=', False),
('company_id', '=', env.company.id),
('display_type', 'not in', ('line_section', 'line_subsection', 'line_note')),
('statement_line_id', '=', False),
]
# --- PRIORITY 1: Outstanding payments/receipts on bank journal accounts ---
# These are registered payments waiting to be matched to bank lines.
# For incoming bank lines → look for outstanding receipts (credit on outstanding account)
# For outgoing bank lines → look for outstanding payments (debit on outstanding account)
outstanding_acct_ids = env['account.account'].search([
('name', 'ilike', 'outstanding'),
('company_ids', 'in', env.company.id),
]).ids
outstanding_amls = AML
if outstanding_acct_ids:
os_domain = base_domain + [('account_id', 'in', outstanding_acct_ids)]
if is_incoming:
os_domain.append(('amount_residual', '>', 0)) # Debit residual on outstanding receipts
else:
os_domain.append(('amount_residual', '<', 0)) # Credit residual on outstanding payments
if partner:
outstanding_amls = AML.search(os_domain + [('partner_id', '=', partner.id)], limit=30)
if not outstanding_amls:
outstanding_amls = AML.search(os_domain, limit=30)
else:
outstanding_amls = AML.search(os_domain, limit=30)
# --- PRIORITY 2: Open invoices/bills (receivable/payable accounts) ---
inv_domain = list(base_domain)
if is_incoming:
inv_domain.append(('account_id.account_type', '=', 'asset_receivable'))
inv_domain.append(('amount_residual', '>', 0))
else:
inv_domain.append(('account_id.account_type', '=', 'liability_payable'))
inv_domain.append(('amount_residual', '<', 0))
inv_domain.append(('date', '>=', str(line_date - td(days=90))))
inv_domain.append(('date', '<=', str(line_date + td(days=30))))
invoice_amls = AML
if partner:
invoice_amls = AML.search(inv_domain + [('partner_id', '=', partner.id)], limit=30)
if not invoice_amls:
invoice_amls = AML.search(inv_domain, limit=30)
else:
invoice_amls = AML.search(inv_domain, limit=30)
# Merge: outstanding payments first (priority), then invoices/bills
combined = outstanding_amls | invoice_amls
# Score and format candidates
outstanding_ids = set(outstanding_amls.ids) if outstanding_amls else set()
candidates = []
seen_ids = set()
for aml in combined:
if aml.id in seen_ids:
continue
seen_ids.add(aml.id)
residual = abs(aml.amount_residual)
score = 0
reasons = []
is_payment = aml.id in outstanding_ids
# Source type: payment entries get a boost (preferred match)
if is_payment:
score += 15
reasons.append('payment')
# Amount scoring
if abs(residual - bank_amount) < 0.01:
score += 40
reasons.append('exact amount')
elif residual <= bank_amount * 1.05:
score += 20
reasons.append('close amount')
# Partner scoring
if partner and aml.partner_id.id == partner.id:
score += 25
reasons.append('partner')
elif partner and aml.partner_id and partner.name and aml.partner_id.name:
p1_words = set(partner.name.upper().split())
p2_words = set(aml.partner_id.name.upper().split())
if p1_words & p2_words:
score += 10
reasons.append('partial partner')
# Date proximity scoring
days_apart = abs((aml.date - line_date).days)
if days_apart <= 3:
score += 15
reasons.append(f'{days_apart}d')
elif days_apart <= 7:
score += 10
elif days_apart <= 14:
score += 5
# Reference matching
if line.payment_ref and aml.move_id.ref:
if any(w.upper() in (aml.move_id.ref or '').upper()
for w in line.payment_ref.split() if len(w) > 3):
score += 10
reasons.append('ref match')
# Determine entry type label
entry_type = 'payment' if is_payment else 'invoice'
if aml.move_id.move_type == 'in_invoice':
entry_type = 'bill'
elif aml.move_id.move_type == 'out_invoice':
entry_type = 'invoice'
elif aml.move_id.move_type in ('in_refund', 'out_refund'):
entry_type = 'credit note'
elif aml.payment_id:
entry_type = 'payment'
candidates.append({
'aml_id': aml.id,
'move_id': aml.move_id.id,
'name': aml.move_id.name or '',
'ref': aml.move_id.ref or '',
'partner': aml.partner_id.name if aml.partner_id else '',
'partner_id': aml.partner_id.id if aml.partner_id else None,
'date': str(aml.date),
'amount_total': abs(aml.balance),
'amount_residual': residual,
'account': aml.account_id.code if hasattr(aml.account_id, 'code') else '',
'type': entry_type,
'score': score,
'reasons': ', '.join(reasons) if reasons else '',
})
# Sort by score descending
candidates.sort(key=lambda c: -c['score'])
# Find best subset-sum combination
best_combo_ids, best_combo_total = _find_best_subset(candidates, bank_amount)
# Mark which candidates are in the best combination
for c in candidates:
c['in_best_combo'] = c['aml_id'] in best_combo_ids
return {
'bank_line': {
'id': line.id,
'date': str(line_date),
'ref': line.payment_ref or '',
'amount': line.amount,
'abs_amount': bank_amount,
'journal': line.journal_id.name,
'partner': partner.name if partner else '',
'partner_id': partner.id if partner else None,
'direction': 'incoming' if is_incoming else 'outgoing',
},
'candidates': candidates[:20],
'best_combination': best_combo_ids,
'best_combination_total': best_combo_total,
'is_exact_match': abs(best_combo_total - bank_amount) < 0.01,
'count': len(candidates),
}
def search_matching_entries(env, params):
"""Search open journal items by query (invoice/bill number, amount, or partner name).
Used by the reconciliation table search bar via direct RPC."""
query = (params.get('query') or '').strip()
line_id = params.get('statement_line_id')
if not query:
return {'candidates': []}
AML = env['account.move.line'].sudo()
# Search across receivable, payable, AND outstanding accounts
outstanding_acct_ids = env['account.account'].search([
('name', 'ilike', 'outstanding'),
('company_ids', 'in', env.company.id),
]).ids
domain = [
('parent_state', '=', 'posted'),
('reconciled', '=', False),
('company_id', '=', env.company.id),
('display_type', 'not in', ('line_section', 'line_subsection', 'line_note')),
'|',
('account_id.account_type', 'in', ('asset_receivable', 'liability_payable')),
('account_id', 'in', outstanding_acct_ids),
]
# Try as amount first
try:
amount = float(query.replace('$', '').replace(',', ''))
amount_domain = domain + [
'|',
'&', ('amount_residual', '>=', amount - 0.50), ('amount_residual', '<=', amount + 0.50),
'&', ('amount_residual', '>=', -amount - 0.50), ('amount_residual', '<=', -amount + 0.50),
]
amls = AML.search(amount_domain, limit=15)
if amls:
return {'candidates': _format_aml_candidates(amls)}
except ValueError:
pass
# Search by move name (invoice/bill number)
name_amls = AML.search(domain + [('move_id.name', 'ilike', query)], limit=15)
if name_amls:
return {'candidates': _format_aml_candidates(name_amls)}
# Search by move ref
ref_amls = AML.search(domain + [('move_id.ref', 'ilike', query)], limit=15)
if ref_amls:
return {'candidates': _format_aml_candidates(ref_amls)}
# Search by partner name
partner_amls = AML.search(domain + [('partner_id.name', 'ilike', query)], limit=15)
return {'candidates': _format_aml_candidates(partner_amls)}
def _format_aml_candidates(amls):
"""Format AMLs as candidate dicts for the reconciliation table."""
return [{
'aml_id': aml.id,
'move_id': aml.move_id.id,
'name': aml.move_id.name or '',
'ref': aml.move_id.ref or '',
'partner': aml.partner_id.name if aml.partner_id else '',
'partner_id': aml.partner_id.id if aml.partner_id else None,
'date': str(aml.date),
'amount_total': abs(aml.balance),
'amount_residual': abs(aml.amount_residual),
'account': aml.account_id.code if hasattr(aml.account_id, 'code') else '',
'score': 0,
'reasons': 'manual search',
'in_best_combo': False,
} for aml in amls]
TOOLS = {
'get_unreconciled_bank_lines': get_unreconciled_bank_lines,
'get_unreconciled_receipts': get_unreconciled_receipts,
@@ -496,4 +954,8 @@ TOOLS = {
'get_bank_line_details': get_bank_line_details,
'check_recurring_pattern': check_recurring_pattern,
'match_internal_transfers': match_internal_transfers,
'find_unreconciled_cheques': find_unreconciled_cheques,
'reconcile_payroll_cheques': reconcile_payroll_cheques,
'suggest_bank_line_matches': suggest_bank_line_matches,
'search_matching_entries': search_matching_entries,
}