""" Fix reversal invoices: link credit invoices to their original via return_against + PLE. These are cancellation invoices created in legacy with no credit payment — just billed_amt = total_amt. """ import frappe, os, pymysql, time os.chdir("/home/frappe/frappe-bench/sites") frappe.init(site="erp.gigafibre.ca", sites_path=".") frappe.connect() print("Connected:", frappe.local.site) t0 = time.time() legacy = pymysql.connect( host="legacy-db", user="facturation", password="*******", database="gestionclient", cursorclass=pymysql.cursors.DictCursor ) # Get all unlinked settled return invoices unlinked = frappe.db.sql(""" SELECT name, legacy_invoice_id, grand_total, customer, posting_date FROM "tabSales Invoice" WHERE docstatus = 1 AND is_return = 1 AND (return_against IS NULL OR return_against = '') AND outstanding_amount < -0.005 ORDER BY outstanding_amount ASC """, as_dict=True) print("Unlinked settled returns: {}".format(len(unlinked))) # Build legacy_invoice_id → SINV name map inv_map = {} map_rows = frappe.db.sql(""" SELECT name, legacy_invoice_id FROM "tabSales Invoice" WHERE legacy_invoice_id IS NOT NULL AND docstatus = 1 AND is_return != 1 """, as_dict=True) for r in map_rows: inv_map[str(r['legacy_invoice_id'])] = r['name'] print("Invoice map: {} entries".format(len(inv_map))) # Match each reversal to its original matches = [] # (credit_sinv, target_sinv, amount) unmatched = 0 with legacy.cursor() as cur: for ret in unlinked: leg_id = ret['legacy_invoice_id'] amount = float(ret['grand_total']) target_amount = -amount cur.execute("SELECT account_id, date_orig FROM invoice WHERE id = %s", (leg_id,)) credit_inv = cur.fetchone() if not credit_inv: unmatched += 1 continue cur.execute(""" SELECT id FROM invoice WHERE account_id = %s AND ROUND(total_amt, 2) = ROUND(%s, 2) AND total_amt > 0 AND date_orig <= %s AND date_orig >= UNIX_TIMESTAMP('2024-04-08') ORDER BY date_orig DESC LIMIT 1 """, (credit_inv['account_id'], target_amount, credit_inv['date_orig'])) match = cur.fetchone() if match: target_sinv = inv_map.get(str(match['id'])) if target_sinv: matches.append((ret['name'], target_sinv, amount)) else: unmatched += 1 else: unmatched += 1 legacy.close() print("Matched: {} | Unmatched: {}".format(len(matches), unmatched)) # Load into temp table frappe.db.sql("DROP TABLE IF EXISTS _tmp_reversal_match") frappe.db.commit() frappe.db.sql(""" CREATE TABLE _tmp_reversal_match ( id SERIAL PRIMARY KEY, credit_sinv VARCHAR(140), target_sinv VARCHAR(140), amount DOUBLE PRECISION ) """) frappe.db.commit() for i in range(0, len(matches), 5000): batch = matches[i:i+5000] values = ",".join(["('{}', '{}', {})".format(c, t, a) for c, t, a in batch]) frappe.db.sql("INSERT INTO _tmp_reversal_match (credit_sinv, target_sinv, amount) VALUES {}".format(values)) frappe.db.commit() print("Loaded {} matches into temp table".format(len(matches))) # Set return_against frappe.db.sql(""" UPDATE "tabSales Invoice" si SET return_against = rm.target_sinv FROM _tmp_reversal_match rm WHERE si.name = rm.credit_sinv """) frappe.db.commit() print("Set return_against on {} credit invoices".format(len(matches))) # Delete self-referencing PLE for these credit invoices frappe.db.sql(""" DELETE FROM "tabPayment Ledger Entry" WHERE name IN ( SELECT 'ple-' || rm.credit_sinv FROM _tmp_reversal_match rm ) """) frappe.db.commit() # Insert PLE: credit allocation against target invoice frappe.db.sql(""" INSERT INTO "tabPayment Ledger Entry" ( name, owner, creation, modified, modified_by, docstatus, posting_date, company, account_type, account, account_currency, party_type, party, due_date, voucher_type, voucher_no, against_voucher_type, against_voucher_no, amount, amount_in_account_currency, delinked, remarks ) SELECT 'plr-' || rm.id, 'Administrator', NOW(), NOW(), 'Administrator', 1, si.posting_date, 'TARGO', 'Receivable', 'Comptes clients - T', 'CAD', 'Customer', si.customer, COALESCE(si.due_date, si.posting_date), 'Sales Invoice', rm.credit_sinv, 'Sales Invoice', rm.target_sinv, rm.amount, rm.amount, 0, 'Reversal allocation' FROM _tmp_reversal_match rm JOIN "tabSales Invoice" si ON si.name = rm.credit_sinv """) frappe.db.commit() new_ple = frappe.db.sql("SELECT COUNT(*) FROM \"tabPayment Ledger Entry\" WHERE name LIKE 'plr-%%'")[0][0] print("Created {} reversal PLE entries".format(new_ple)) # Set outstanding = 0 on linked credit invoices frappe.db.sql(""" UPDATE "tabSales Invoice" SET outstanding_amount = 0, status = 'Return' WHERE name IN (SELECT credit_sinv FROM _tmp_reversal_match) """) frappe.db.commit() # Recalculate outstanding on target invoices (original invoices being cancelled) frappe.db.sql(""" UPDATE "tabSales Invoice" si SET outstanding_amount = COALESCE(( SELECT SUM(ple.amount) FROM "tabPayment Ledger Entry" ple WHERE ple.against_voucher_type = 'Sales Invoice' AND ple.against_voucher_no = si.name AND ple.delinked = 0 ), si.grand_total) WHERE si.name IN (SELECT target_sinv FROM _tmp_reversal_match) """) frappe.db.commit() # Update statuses for affected target invoices frappe.db.sql("""UPDATE "tabSales Invoice" SET status = 'Paid' WHERE name IN (SELECT target_sinv FROM _tmp_reversal_match) AND is_return != 1 AND ROUND(outstanding_amount::numeric, 2) = 0""") frappe.db.sql("""UPDATE "tabSales Invoice" SET status = 'Overdue' WHERE name IN (SELECT target_sinv FROM _tmp_reversal_match) AND is_return != 1 AND outstanding_amount > 0.005 AND COALESCE(due_date, posting_date) < CURRENT_DATE""") frappe.db.sql("""UPDATE "tabSales Invoice" SET status = 'Unpaid' WHERE name IN (SELECT target_sinv FROM _tmp_reversal_match) AND is_return != 1 AND outstanding_amount > 0.005 AND COALESCE(due_date, posting_date) >= CURRENT_DATE""") frappe.db.sql("""UPDATE "tabSales Invoice" SET status = 'Credit Note Issued' WHERE name IN (SELECT target_sinv FROM _tmp_reversal_match) AND is_return != 1 AND outstanding_amount < -0.005""") frappe.db.commit() # Cleanup frappe.db.sql("DROP TABLE IF EXISTS _tmp_reversal_match") frappe.db.commit() # Verification print("\n=== Verification ===") outstanding = frappe.db.sql(""" SELECT ROUND(SUM(CASE WHEN outstanding_amount > 0.005 THEN outstanding_amount ELSE 0 END)::numeric, 2) as owed, ROUND(SUM(CASE WHEN outstanding_amount < -0.005 THEN outstanding_amount ELSE 0 END)::numeric, 2) as overpaid FROM "tabSales Invoice" WHERE docstatus = 1 """, as_dict=True)[0] print(" Outstanding: ${} owed | ${} overpaid".format(outstanding['owed'], outstanding['overpaid'])) statuses = frappe.db.sql(""" SELECT status, COUNT(*) as cnt FROM "tabSales Invoice" WHERE docstatus = 1 GROUP BY status ORDER BY cnt DESC """, as_dict=True) print("\n Status breakdown:") for s in statuses: print(" {}: {}".format(s['status'], s['cnt'])) elapsed = time.time() - t0 print("\nDone in {:.0f}s".format(elapsed))