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_ids', 'in', 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, } 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, '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, '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, }