changes
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user