changes
This commit is contained in:
@@ -65,27 +65,56 @@ def get_overdue_invoices(env, params):
|
||||
|
||||
|
||||
def get_partner_balance(env, params):
|
||||
partner_id = int(params['partner_id'])
|
||||
partner = env['res.partner'].browse(partner_id)
|
||||
if not partner.exists():
|
||||
return {'error': 'Partner not found'}
|
||||
amls = env['account.move.line'].search([
|
||||
('partner_id', '=', partner_id),
|
||||
"""Get AR and AP balance for a partner. Accepts partner_id or partner_name."""
|
||||
partner = None
|
||||
if params.get('partner_id'):
|
||||
partner = env['res.partner'].browse(int(params['partner_id']))
|
||||
elif params.get('partner_name'):
|
||||
partner = env['res.partner'].search([
|
||||
('name', 'ilike', params['partner_name']),
|
||||
], limit=1)
|
||||
if not partner or not partner.exists():
|
||||
return {'error': f"Partner not found: {params.get('partner_name', params.get('partner_id', '?'))}"}
|
||||
|
||||
# AR balance (receivable)
|
||||
ar_amls = env['account.move.line'].search([
|
||||
('partner_id', '=', partner.id),
|
||||
('account_id.account_type', '=', 'asset_receivable'),
|
||||
('parent_state', '=', 'posted'),
|
||||
('reconciled', '=', False),
|
||||
('company_id', '=', env.company.id),
|
||||
])
|
||||
ar_balance = sum(aml.amount_residual for aml in ar_amls)
|
||||
|
||||
# AP balance (payable)
|
||||
ap_amls = env['account.move.line'].search([
|
||||
('partner_id', '=', partner.id),
|
||||
('account_id.account_type', '=', 'liability_payable'),
|
||||
('parent_state', '=', 'posted'),
|
||||
('reconciled', '=', False),
|
||||
('company_id', '=', env.company.id),
|
||||
])
|
||||
ap_balance = sum(aml.amount_residual for aml in ap_amls)
|
||||
|
||||
open_items = [{
|
||||
'id': aml.id,
|
||||
'move_name': aml.move_id.name,
|
||||
'ref': aml.ref or '',
|
||||
'date': str(aml.date),
|
||||
'amount_residual': aml.amount_residual,
|
||||
'type': 'receivable' if aml.account_id.account_type == 'asset_receivable' else 'payable',
|
||||
'date_maturity': str(aml.date_maturity) if aml.date_maturity else '',
|
||||
} for aml in (ar_amls | ap_amls)[:30]]
|
||||
|
||||
return {
|
||||
'partner': partner.name,
|
||||
'balance': sum(aml.amount_residual for aml in amls),
|
||||
'open_items': [{
|
||||
'id': aml.id,
|
||||
'ref': aml.ref or aml.move_id.name,
|
||||
'date': str(aml.date),
|
||||
'amount_residual': aml.amount_residual,
|
||||
'date_maturity': str(aml.date_maturity) if aml.date_maturity else '',
|
||||
} for aml in amls],
|
||||
'partner_id': partner.id,
|
||||
'ar_balance': ar_balance,
|
||||
'ap_balance': ap_balance,
|
||||
'net_balance': ar_balance + ap_balance,
|
||||
'they_owe_us': ar_balance if ar_balance > 0 else 0,
|
||||
'we_owe_them': abs(ap_balance) if ap_balance < 0 else 0,
|
||||
'open_items': open_items,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ _logger = logging.getLogger(__name__)
|
||||
def get_adp_receivable_aging(env, params):
|
||||
accounts = env['account.account'].search([
|
||||
('code', '=like', '1101%'),
|
||||
('company_id', '=', env.company.id),
|
||||
('company_ids', 'in', env.company.id),
|
||||
])
|
||||
today = fields.Date.today()
|
||||
amls = env['account.move.line'].search([
|
||||
@@ -81,7 +81,7 @@ def get_adp_summary(env, params):
|
||||
date_from = params.get('date_from')
|
||||
date_to = params.get('date_to')
|
||||
accounts = env['account.account'].search([
|
||||
('code', '=like', '1101%'), ('company_id', '=', env.company.id),
|
||||
('code', '=like', '1101%'), ('company_ids', 'in', env.company.id),
|
||||
])
|
||||
domain = [
|
||||
('account_id', 'in', accounts.ids),
|
||||
@@ -102,10 +102,136 @@ def get_adp_summary(env, params):
|
||||
}
|
||||
|
||||
|
||||
def register_adp_batch_payment(env, params):
|
||||
"""Register payments for a batch of ADP invoices from a remittance advice.
|
||||
|
||||
Takes a list of invoice numbers with payment amounts and a payment date.
|
||||
Registers a payment for each invoice via Odoo's payment wizard, which
|
||||
creates outstanding receipt entries (PBNK2) on account 1050.
|
||||
|
||||
After calling this, use suggest_bank_line_matches on the bank deposit line
|
||||
to match the outstanding receipts against the bank line.
|
||||
"""
|
||||
invoices_data = params.get('invoices', [])
|
||||
payment_date = params.get('payment_date')
|
||||
journal_id = int(params.get('journal_id', 50)) # Default Scotia Current
|
||||
|
||||
if not invoices_data:
|
||||
return {'error': 'No invoices provided'}
|
||||
if not payment_date:
|
||||
return {'error': 'payment_date is required (YYYY-MM-DD)'}
|
||||
|
||||
ADP_PARTNER_ID = 3421 # ADP (Assistive Device Program)
|
||||
results = []
|
||||
total_paid = 0.0
|
||||
errors = []
|
||||
|
||||
for inv_data in invoices_data:
|
||||
inv_number = str(inv_data.get('invoice_number', '')).strip()
|
||||
amount = float(inv_data.get('amount', 0))
|
||||
|
||||
if not inv_number or not amount:
|
||||
errors.append(f"Skipped: missing invoice_number or amount in {inv_data}")
|
||||
continue
|
||||
|
||||
# Find the invoice by name/number
|
||||
invoice = env['account.move'].search([
|
||||
('name', 'ilike', inv_number),
|
||||
('move_type', '=', 'out_invoice'),
|
||||
('state', '=', 'posted'),
|
||||
('company_id', '=', env.company.id),
|
||||
], limit=1)
|
||||
|
||||
if not invoice:
|
||||
# Try without leading zeros or with different format
|
||||
invoice = env['account.move'].search([
|
||||
('name', '=like', f'%{inv_number}'),
|
||||
('move_type', '=', 'out_invoice'),
|
||||
('state', '=', 'posted'),
|
||||
], limit=1)
|
||||
|
||||
if not invoice:
|
||||
errors.append(f"Invoice {inv_number} not found")
|
||||
continue
|
||||
|
||||
if invoice.payment_state == 'paid':
|
||||
results.append({
|
||||
'invoice': inv_number,
|
||||
'status': 'already_paid',
|
||||
'move_id': invoice.id,
|
||||
})
|
||||
continue
|
||||
|
||||
# Check if amount matches residual (allow partial)
|
||||
if amount > invoice.amount_residual + 0.01:
|
||||
errors.append(
|
||||
f"Invoice {inv_number}: payment ${amount:.2f} exceeds "
|
||||
f"residual ${invoice.amount_residual:.2f}"
|
||||
)
|
||||
continue
|
||||
|
||||
# Register payment via the payment wizard
|
||||
try:
|
||||
payment_vals = {
|
||||
'payment_type': 'inbound',
|
||||
'partner_type': 'customer',
|
||||
'partner_id': invoice.partner_id.id or ADP_PARTNER_ID,
|
||||
'amount': amount,
|
||||
'date': payment_date,
|
||||
'journal_id': journal_id,
|
||||
'ref': f'ADP Remittance - {inv_number}',
|
||||
}
|
||||
# Use the payment register wizard
|
||||
ctx = {
|
||||
'active_model': 'account.move',
|
||||
'active_ids': [invoice.id],
|
||||
}
|
||||
wizard = env['account.payment.register'].with_context(**ctx).create({
|
||||
'payment_date': payment_date,
|
||||
'amount': amount,
|
||||
'journal_id': journal_id,
|
||||
'payment_method_line_id': env['account.payment.method.line'].search([
|
||||
('journal_id', '=', journal_id),
|
||||
('payment_type', '=', 'inbound'),
|
||||
], limit=1).id,
|
||||
})
|
||||
wizard.action_create_payments()
|
||||
|
||||
results.append({
|
||||
'invoice': inv_number,
|
||||
'status': 'paid',
|
||||
'amount': amount,
|
||||
'move_id': invoice.id,
|
||||
'move_name': invoice.name,
|
||||
})
|
||||
total_paid += amount
|
||||
except Exception as e:
|
||||
_logger.warning("ADP payment failed for %s: %s", inv_number, e)
|
||||
errors.append(f"Invoice {inv_number}: payment failed — {e}")
|
||||
|
||||
env.cr.commit()
|
||||
|
||||
return {
|
||||
'status': 'completed',
|
||||
'paid_count': len([r for r in results if r.get('status') == 'paid']),
|
||||
'already_paid_count': len([r for r in results if r.get('status') == 'already_paid']),
|
||||
'total_paid': total_paid,
|
||||
'results': results,
|
||||
'errors': errors,
|
||||
'message': (
|
||||
f"Registered payments for {len([r for r in results if r.get('status') == 'paid'])} invoices "
|
||||
f"totalling ${total_paid:,.2f}. "
|
||||
+ (f"{len(errors)} errors." if errors else "No errors.")
|
||||
+ " Now use suggest_bank_line_matches to match the bank deposit."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
TOOLS = {
|
||||
'get_adp_receivable_aging': get_adp_receivable_aging,
|
||||
'match_adp_payment_to_invoice': match_adp_payment_to_invoice,
|
||||
'verify_adp_split': verify_adp_split,
|
||||
'find_adp_without_payment': find_adp_without_payment,
|
||||
'get_adp_summary': get_adp_summary,
|
||||
'register_adp_batch_payment': register_adp_batch_payment,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -19,10 +19,10 @@ def calculate_hst_balance(env, params):
|
||||
# (shared chart of accounts). Use try/except to handle both cases.
|
||||
try:
|
||||
collected_accounts = env['account.account'].search([
|
||||
('code', '=like', '2005%'), ('company_id', '=', env.company.id),
|
||||
('code', '=like', '2005%'), ('company_ids', 'in', env.company.id),
|
||||
])
|
||||
itc_accounts = env['account.account'].search([
|
||||
('code', '=like', '2006%'), ('company_id', '=', env.company.id),
|
||||
('code', '=like', '2006%'), ('company_ids', 'in', env.company.id),
|
||||
])
|
||||
except Exception:
|
||||
collected_accounts = env['account.account'].search([
|
||||
@@ -216,7 +216,7 @@ def create_expense_entry(env, params):
|
||||
# Fallback to AP account
|
||||
credit_account = env['account.account'].search([
|
||||
('account_type', '=', 'liability_payable'),
|
||||
('company_id', '=', env.company.id),
|
||||
('company_ids', 'in', env.company.id),
|
||||
], limit=1)
|
||||
|
||||
if not credit_account:
|
||||
|
||||
@@ -7,7 +7,7 @@ _logger = logging.getLogger(__name__)
|
||||
def get_stock_valuation(env, params):
|
||||
accounts = env['account.account'].search([
|
||||
('code', '=like', '1069%'),
|
||||
('company_id', '=', env.company.id),
|
||||
('company_ids', 'in', env.company.id),
|
||||
])
|
||||
result = []
|
||||
for acct in accounts:
|
||||
@@ -22,7 +22,7 @@ def get_stock_valuation(env, params):
|
||||
def get_price_differences(env, params):
|
||||
accounts = env['account.account'].search([
|
||||
('code', '=like', '5010%'),
|
||||
('company_id', '=', env.company.id),
|
||||
('company_ids', 'in', env.company.id),
|
||||
])
|
||||
domain = [
|
||||
('account_id', 'in', accounts.ids),
|
||||
|
||||
@@ -108,7 +108,7 @@ def find_wrong_account_entries(env, params):
|
||||
tax_accounts = env['account.account'].search([
|
||||
('account_type', 'in', ('liability_current', 'asset_current')),
|
||||
('code', '=like', '2005%'),
|
||||
('company_id', '=', env.company.id),
|
||||
('company_ids', 'in', env.company.id),
|
||||
])
|
||||
if tax_accounts:
|
||||
revenue_on_tax = env['account.move.line'].search(
|
||||
@@ -171,7 +171,7 @@ def find_draft_entries(env, params):
|
||||
def find_unreconciled_suspense(env, params):
|
||||
suspense_accounts = env['account.account'].search([
|
||||
('code', '=like', '999%'),
|
||||
('company_id', '=', env.company.id),
|
||||
('company_ids', 'in', env.company.id),
|
||||
])
|
||||
issues = []
|
||||
for acct in suspense_accounts:
|
||||
|
||||
@@ -35,7 +35,7 @@ def get_close_checklist(env, params):
|
||||
def get_unreconciled_counts(env, params):
|
||||
accounts = env['account.account'].search([
|
||||
('reconcile', '=', True),
|
||||
('company_id', '=', env.company.id),
|
||||
('company_ids', 'in', env.company.id),
|
||||
])
|
||||
result = []
|
||||
for acct in accounts:
|
||||
@@ -77,7 +77,7 @@ def get_accrual_status(env, params):
|
||||
for code in accrual_codes:
|
||||
accounts = env['account.account'].search([
|
||||
('code', '=like', f'{code}%'),
|
||||
('company_id', '=', env.company.id),
|
||||
('company_ids', 'in', env.company.id),
|
||||
])
|
||||
for acct in accounts:
|
||||
balance = sum(env['account.move.line'].search([
|
||||
|
||||
@@ -66,7 +66,7 @@ def verify_source_deductions(env, params):
|
||||
def get_cra_remittance_status(env, params):
|
||||
cra_accounts = env['account.account'].search([
|
||||
('name', 'ilike', 'CRA'),
|
||||
('company_id', '=', env.company.id),
|
||||
('company_ids', 'in', env.company.id),
|
||||
])
|
||||
result = []
|
||||
for acct in cra_accounts:
|
||||
@@ -130,21 +130,72 @@ def parse_payroll_summary(env, params):
|
||||
}
|
||||
|
||||
|
||||
def _resolve_account_id(env, val):
|
||||
"""Resolve an account code or ID to a valid account ID.
|
||||
Accepts: integer ID, string ID, or account code string like '2201'."""
|
||||
if not val:
|
||||
return False
|
||||
val_str = str(val).strip()
|
||||
# Try as a direct ID first
|
||||
try:
|
||||
acct = env['account.account'].browse(int(val_str))
|
||||
if acct.exists():
|
||||
return acct.id
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
# Try as an account code
|
||||
acct = env['account.account'].search([
|
||||
('code', '=', val_str),
|
||||
('company_ids', 'in', env.company.id),
|
||||
], limit=1)
|
||||
if acct:
|
||||
return acct.id
|
||||
return False
|
||||
|
||||
|
||||
def create_payroll_journal_entry(env, params):
|
||||
journal_id = int(params['journal_id'])
|
||||
date = params['date']
|
||||
ref = params.get('ref', 'Payroll Entry')
|
||||
lines_data = params['lines']
|
||||
move_vals = {
|
||||
'journal_id': journal_id,
|
||||
'date': date,
|
||||
'ref': params.get('ref', 'Payroll Entry'),
|
||||
'line_ids': [(0, 0, {
|
||||
'account_id': int(line['account_id']),
|
||||
|
||||
# Duplicate check: same journal + date + ref + similar amount
|
||||
total_debit = sum(float(l.get('debit', 0)) for l in lines_data)
|
||||
existing = env['account.move'].search([
|
||||
('journal_id', '=', journal_id),
|
||||
('date', '=', date),
|
||||
('ref', 'ilike', ref[:30]),
|
||||
('state', 'in', ('draft', 'posted')),
|
||||
], limit=1)
|
||||
if existing:
|
||||
return {
|
||||
'status': 'duplicate',
|
||||
'error': f'Entry already exists: {existing.name} (ref: {existing.ref}) on {existing.date} '
|
||||
f'for ${existing.amount_total:,.2f}. Skipping to avoid duplicate.',
|
||||
'existing_move_id': existing.id,
|
||||
'existing_name': existing.name,
|
||||
}
|
||||
|
||||
# Resolve account codes to IDs
|
||||
resolved_lines = []
|
||||
for line in lines_data:
|
||||
account_id = _resolve_account_id(env, line['account_id'])
|
||||
if not account_id:
|
||||
return {'error': f"Account not found: {line['account_id']}. "
|
||||
f"Provide a valid account code (e.g. '2201') or database ID."}
|
||||
resolved_lines.append((0, 0, {
|
||||
'account_id': account_id,
|
||||
'name': line.get('name', 'Payroll'),
|
||||
'debit': float(line.get('debit', 0)),
|
||||
'credit': float(line.get('credit', 0)),
|
||||
'partner_id': int(line['partner_id']) if line.get('partner_id') else False,
|
||||
}) for line in lines_data],
|
||||
}))
|
||||
|
||||
move_vals = {
|
||||
'journal_id': journal_id,
|
||||
'date': date,
|
||||
'ref': ref,
|
||||
'line_ids': resolved_lines,
|
||||
}
|
||||
move = env['account.move'].create(move_vals)
|
||||
return {'status': 'created', 'move_id': move.id, 'name': move.name}
|
||||
|
||||
@@ -106,6 +106,171 @@ def export_report(env, params):
|
||||
return {'error': f'Export failed: {str(e)}'}
|
||||
|
||||
|
||||
def get_invoicing_summary(env, params):
|
||||
"""Get invoicing summary — total invoiced by month, by partner, or for a date range.
|
||||
Supports: monthly breakdown for a year, current month totals, or filtered by partner."""
|
||||
from datetime import date, timedelta
|
||||
import calendar
|
||||
|
||||
year = int(params.get('year', date.today().year))
|
||||
partner_name = params.get('partner_name')
|
||||
date_from = params.get('date_from')
|
||||
date_to = params.get('date_to')
|
||||
|
||||
domain = [
|
||||
('move_type', '=', 'out_invoice'),
|
||||
('state', '=', 'posted'),
|
||||
('company_id', '=', env.company.id),
|
||||
]
|
||||
|
||||
if partner_name:
|
||||
partner = env['res.partner'].search([('name', 'ilike', partner_name)], limit=1)
|
||||
if partner:
|
||||
domain.append(('partner_id', '=', partner.id))
|
||||
else:
|
||||
return {'error': f'Partner not found: {partner_name}'}
|
||||
|
||||
if date_from and date_to:
|
||||
domain += [('date', '>=', date_from), ('date', '<=', date_to)]
|
||||
invoices = env['account.move'].search(domain, order='date desc')
|
||||
total = sum(inv.amount_total for inv in invoices)
|
||||
return {
|
||||
'period': f'{date_from} to {date_to}',
|
||||
'count': len(invoices),
|
||||
'total': total,
|
||||
'invoices': [{
|
||||
'id': inv.id, 'name': inv.name, 'partner': inv.partner_id.name,
|
||||
'date': str(inv.date), 'amount': inv.amount_total,
|
||||
'payment_state': inv.payment_state,
|
||||
} for inv in invoices[:30]],
|
||||
}
|
||||
|
||||
# Monthly breakdown for the year
|
||||
months = []
|
||||
grand_total = 0
|
||||
for month in range(1, 13):
|
||||
m_start = f'{year}-{month:02d}-01'
|
||||
last_day = calendar.monthrange(year, month)[1]
|
||||
m_end = f'{year}-{month:02d}-{last_day}'
|
||||
m_domain = domain + [('date', '>=', m_start), ('date', '<=', m_end)]
|
||||
invoices = env['account.move'].search(m_domain)
|
||||
total = sum(inv.amount_total for inv in invoices)
|
||||
grand_total += total
|
||||
months.append({
|
||||
'month': f'{year}-{month:02d}',
|
||||
'month_name': calendar.month_name[month],
|
||||
'count': len(invoices),
|
||||
'total': round(total, 2),
|
||||
})
|
||||
|
||||
return {
|
||||
'year': year,
|
||||
'grand_total': round(grand_total, 2),
|
||||
'months': months,
|
||||
'partner': partner_name or 'All',
|
||||
}
|
||||
|
||||
|
||||
def get_billing_summary(env, params):
|
||||
"""Get billing (vendor bills) summary — total billed by month or date range."""
|
||||
from datetime import date
|
||||
import calendar
|
||||
|
||||
year = int(params.get('year', date.today().year))
|
||||
partner_name = params.get('partner_name')
|
||||
date_from = params.get('date_from')
|
||||
date_to = params.get('date_to')
|
||||
|
||||
domain = [
|
||||
('move_type', '=', 'in_invoice'),
|
||||
('state', '=', 'posted'),
|
||||
('company_id', '=', env.company.id),
|
||||
]
|
||||
|
||||
if partner_name:
|
||||
partner = env['res.partner'].search([('name', 'ilike', partner_name)], limit=1)
|
||||
if partner:
|
||||
domain.append(('partner_id', '=', partner.id))
|
||||
else:
|
||||
return {'error': f'Partner not found: {partner_name}'}
|
||||
|
||||
if date_from and date_to:
|
||||
domain += [('date', '>=', date_from), ('date', '<=', date_to)]
|
||||
bills = env['account.move'].search(domain, order='date desc')
|
||||
total = sum(b.amount_total for b in bills)
|
||||
return {
|
||||
'period': f'{date_from} to {date_to}',
|
||||
'count': len(bills),
|
||||
'total': total,
|
||||
'bills': [{
|
||||
'id': b.id, 'name': b.name, 'partner': b.partner_id.name,
|
||||
'date': str(b.date), 'amount': b.amount_total,
|
||||
'payment_state': b.payment_state,
|
||||
} for b in bills[:30]],
|
||||
}
|
||||
|
||||
# Monthly breakdown
|
||||
months = []
|
||||
grand_total = 0
|
||||
for month in range(1, 13):
|
||||
m_start = f'{year}-{month:02d}-01'
|
||||
last_day = calendar.monthrange(year, month)[1]
|
||||
m_end = f'{year}-{month:02d}-{last_day}'
|
||||
m_domain = domain + [('date', '>=', m_start), ('date', '<=', m_end)]
|
||||
bills = env['account.move'].search(m_domain)
|
||||
total = sum(b.amount_total for b in bills)
|
||||
grand_total += total
|
||||
months.append({
|
||||
'month': f'{year}-{month:02d}',
|
||||
'month_name': calendar.month_name[month],
|
||||
'count': len(bills),
|
||||
'total': round(total, 2),
|
||||
})
|
||||
|
||||
return {
|
||||
'year': year,
|
||||
'grand_total': round(grand_total, 2),
|
||||
'months': months,
|
||||
'partner': partner_name or 'All',
|
||||
}
|
||||
|
||||
|
||||
def get_collections_summary(env, params):
|
||||
"""Get payment collections summary — how much was collected (received) in a period."""
|
||||
date_from = params.get('date_from')
|
||||
date_to = params.get('date_to')
|
||||
if not date_from or not date_to:
|
||||
from datetime import date
|
||||
today = date.today()
|
||||
date_from = date_from or f'{today.year}-{today.month:02d}-01'
|
||||
date_to = date_to or str(today)
|
||||
|
||||
payments = env['account.payment'].search([
|
||||
('payment_type', '=', 'inbound'),
|
||||
('state', '=', 'posted'),
|
||||
('date', '>=', date_from),
|
||||
('date', '<=', date_to),
|
||||
('company_id', '=', env.company.id),
|
||||
], order='date desc')
|
||||
|
||||
total = sum(p.amount for p in payments)
|
||||
by_partner = {}
|
||||
for p in payments:
|
||||
pname = p.partner_id.name if p.partner_id else 'Unknown'
|
||||
by_partner.setdefault(pname, {'count': 0, 'total': 0})
|
||||
by_partner[pname]['count'] += 1
|
||||
by_partner[pname]['total'] += p.amount
|
||||
|
||||
top_partners = sorted(by_partner.items(), key=lambda x: -x[1]['total'])[:15]
|
||||
|
||||
return {
|
||||
'period': f'{date_from} to {date_to}',
|
||||
'total_collected': round(total, 2),
|
||||
'payment_count': len(payments),
|
||||
'by_partner': [{'partner': k, 'count': v['count'], 'total': round(v['total'], 2)} for k, v in top_partners],
|
||||
}
|
||||
|
||||
|
||||
TOOLS = {
|
||||
'get_profit_loss': get_profit_loss,
|
||||
'get_balance_sheet': get_balance_sheet,
|
||||
@@ -114,4 +279,7 @@ TOOLS = {
|
||||
'compare_periods': compare_periods,
|
||||
'answer_financial_question': answer_financial_question,
|
||||
'export_report': export_report,
|
||||
'get_invoicing_summary': get_invoicing_summary,
|
||||
'get_billing_summary': get_billing_summary,
|
||||
'get_collections_summary': get_collections_summary,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user