""" Fix outstanding_amount on Sales Invoices using legacy system as source of truth. Legacy invoice table: - billing_status: 1 = paid, 0 = unpaid - total_amt: invoice total - billed_amt: amount that has been paid - montant_du (computed) = total_amt - billed_amt ERPNext migration created wrong payment allocations, causing phantom outstanding. This script reads every legacy invoice's real status and corrects ERPNext. Run inside erpnext-backend-1: /home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/fix_invoice_outstanding.py """ import frappe import pymysql import os import time os.chdir("/home/frappe/frappe-bench/sites") frappe.init(site="erp.gigafibre.ca", sites_path=".") frappe.connect() frappe.local.flags.ignore_permissions = True print("Connected:", frappe.local.site) T_TOTAL = time.time() # ═══════════════════════════════════════════════════════════════ # PHASE 1: Load legacy invoice statuses # ═══════════════════════════════════════════════════════════════ print("\n" + "="*60) print("PHASE 1: LOAD LEGACY INVOICE DATA") print("="*60) conn = pymysql.connect( host="legacy-db", user="facturation", password="*******", database="gestionclient", cursorclass=pymysql.cursors.DictCursor ) with conn.cursor() as cur: cur.execute(""" SELECT id, total_amt, billed_amt, billing_status FROM invoice """) legacy = {} for row in cur.fetchall(): total = float(row["total_amt"] or 0) billed = float(row["billed_amt"] or 0) montant_du = round(total - billed, 2) if montant_du < 0: montant_du = 0.0 legacy[row["id"]] = { "montant_du": montant_du, "billing_status": row["billing_status"], "total_amt": total, "billed_amt": billed, } conn.close() print("Legacy invoices loaded: {:,}".format(len(legacy))) status_dist = {} for v in legacy.values(): s = v["billing_status"] status_dist[s] = status_dist.get(s, 0) + 1 print(" billing_status=0 (unpaid): {:,}".format(status_dist.get(0, 0))) print(" billing_status=1 (paid): {:,}".format(status_dist.get(1, 0))) # ═══════════════════════════════════════════════════════════════ # PHASE 2: Load ERPNext invoices and find mismatches # ═══════════════════════════════════════════════════════════════ print("\n" + "="*60) print("PHASE 2: FIND MISMATCHES") print("="*60) erp_invoices = frappe.db.sql(""" SELECT name, outstanding_amount, status, grand_total FROM "tabSales Invoice" WHERE docstatus = 1 """, as_dict=True) print("ERPNext submitted invoices: {:,}".format(len(erp_invoices))) mismatches = [] matched = 0 no_legacy = 0 errors = [] for inv in erp_invoices: # Extract legacy ID from SINV-{id} try: legacy_id = int(inv["name"].split("-")[1]) except (IndexError, ValueError): errors.append(inv["name"]) continue leg = legacy.get(legacy_id) if not leg: no_legacy += 1 continue erp_out = round(float(inv["outstanding_amount"] or 0), 2) legacy_out = leg["montant_du"] if abs(erp_out - legacy_out) > 0.005: mismatches.append({ "name": inv["name"], "legacy_id": legacy_id, "erp_outstanding": erp_out, "legacy_outstanding": legacy_out, "erp_status": inv["status"], "grand_total": float(inv["grand_total"] or 0), "billing_status": leg["billing_status"], }) else: matched += 1 print("Correct (matched): {:,}".format(matched)) print("MISMATCHED: {:,}".format(len(mismatches))) print("No legacy record: {:,}".format(no_legacy)) print("Parse errors: {:,}".format(len(errors))) # Breakdown should_be_zero = [m for m in mismatches if m["legacy_outstanding"] == 0] should_be_nonzero = [m for m in mismatches if m["legacy_outstanding"] > 0] print("\n Legacy says PAID (should be 0): {:,} invoices".format(len(should_be_zero))) print(" Legacy says UNPAID (real balance): {:,} invoices".format(len(should_be_nonzero))) phantom_total = sum(m["erp_outstanding"] for m in should_be_zero) print(" Phantom outstanding to clear: ${:,.2f}".format(phantom_total)) # Sample print("\nSample mismatches:") for m in mismatches[:10]: print(" {} | ERPNext={:.2f} → Legacy={:.2f} | billing_status={}".format( m["name"], m["erp_outstanding"], m["legacy_outstanding"], m["billing_status"])) # ═══════════════════════════════════════════════════════════════ # PHASE 3: Triple-check before fixing # ═══════════════════════════════════════════════════════════════ print("\n" + "="*60) print("PHASE 3: TRIPLE-CHECK") print("="*60) # Verify with specific known-good cases # Invoice 634020 for our problem customer — legacy says paid, ERPNext says paid test_634020 = legacy.get(634020) print("Invoice 634020 (should be paid):") print(" Legacy: montant_du={}, billing_status={}".format( test_634020["montant_du"] if test_634020 else "MISSING", test_634020["billing_status"] if test_634020 else "MISSING")) erp_634020 = frappe.db.sql('SELECT outstanding_amount, status FROM "tabSales Invoice" WHERE name=%s', ("SINV-634020",), as_dict=True) if erp_634020: print(" ERPNext: outstanding={}, status={}".format(erp_634020[0]["outstanding_amount"], erp_634020[0]["status"])) # Invoice 607832 — legacy says paid (billing_status=1, billed_amt=14.00), ERPNext says Overdue test_607832 = legacy.get(607832) print("\nInvoice 607832 (phantom overdue):") print(" Legacy: montant_du={}, billing_status={}".format( test_607832["montant_du"] if test_607832 else "MISSING", test_607832["billing_status"] if test_607832 else "MISSING")) erp_607832 = frappe.db.sql('SELECT outstanding_amount, status FROM "tabSales Invoice" WHERE name=%s', ("SINV-607832",), as_dict=True) if erp_607832: print(" ERPNext: outstanding={}, status={}".format(erp_607832[0]["outstanding_amount"], erp_607832[0]["status"])) print(" WILL FIX → outstanding=0, status=Paid") # Verify an invoice that IS genuinely unpaid in legacy (billing_status=0) unpaid_sample = [m for m in mismatches if m["billing_status"] == 0][:3] if unpaid_sample: print("\nSample genuinely unpaid invoices:") for m in unpaid_sample: print(" {} | legacy_outstanding={:.2f} (genuinely owed)".format(m["name"], m["legacy_outstanding"])) # ═══════════════════════════════════════════════════════════════ # PHASE 4: APPLY FIXES # ═══════════════════════════════════════════════════════════════ print("\n" + "="*60) print("PHASE 4: APPLY FIXES") print("="*60) fixed = 0 batch_size = 2000 for i in range(0, len(mismatches), batch_size): batch = mismatches[i:i+batch_size] for m in batch: new_outstanding = m["legacy_outstanding"] # Determine correct status if new_outstanding <= 0: new_status = "Paid" elif new_outstanding >= m["grand_total"] - 0.01: new_status = "Overdue" # Fully unpaid and past due else: new_status = "Overdue" # Partially paid, treat as overdue frappe.db.sql(""" UPDATE "tabSales Invoice" SET outstanding_amount = %s, status = %s WHERE name = %s AND docstatus = 1 """, (new_outstanding, new_status, m["name"])) fixed += 1 frappe.db.commit() print(" Fixed {:,}/{:,}...".format(min(i+batch_size, len(mismatches)), len(mismatches))) print("Fixed {:,} invoices".format(fixed)) # ═══════════════════════════════════════════════════════════════ # PHASE 5: VERIFY AFTER FIX # ═══════════════════════════════════════════════════════════════ print("\n" + "="*60) print("PHASE 5: VERIFY AFTER FIX") print("="*60) # Problem customer problem_cust = "CUST-993e9763ce" after = frappe.db.sql(""" SELECT COUNT(*) as cnt, COALESCE(SUM(outstanding_amount), 0) as total FROM "tabSales Invoice" WHERE customer = %s AND docstatus = 1 AND outstanding_amount > 0 """, (problem_cust,), as_dict=True) print("Problem customer ({}) AFTER fix:".format(problem_cust)) print(" Invoices with outstanding > 0: {}".format(after[0]["cnt"])) print(" Total outstanding: ${:.2f}".format(float(after[0]["total"]))) # Invoice 607832 specifically after_607832 = frappe.db.sql('SELECT outstanding_amount, status FROM "tabSales Invoice" WHERE name=%s', ("SINV-607832",), as_dict=True) if after_607832: print(" SINV-607832: outstanding={}, status={}".format( after_607832[0]["outstanding_amount"], after_607832[0]["status"])) # Global stats global_before_paid = frappe.db.sql(""" SELECT status, COUNT(*) as cnt, COALESCE(SUM(outstanding_amount), 0) as total FROM "tabSales Invoice" WHERE docstatus = 1 GROUP BY status ORDER BY cnt DESC """, as_dict=True) print("\nGlobal invoice status distribution AFTER fix:") for r in global_before_paid: print(" {}: {:,} invoices, outstanding ${:,.2f}".format( r["status"], r["cnt"], float(r["total"]))) # Double-check: re-scan for remaining mismatches print("\nRe-scanning for remaining mismatches...") remaining_mismatches = 0 erp_after = frappe.db.sql(""" SELECT name, outstanding_amount FROM "tabSales Invoice" WHERE docstatus = 1 """, as_dict=True) for inv in erp_after: try: legacy_id = int(inv["name"].split("-")[1]) except (IndexError, ValueError): continue leg = legacy.get(legacy_id) if not leg: continue if abs(float(inv["outstanding_amount"] or 0) - leg["montant_du"]) > 0.005: remaining_mismatches += 1 print("Remaining mismatches after fix: {}".format(remaining_mismatches)) frappe.clear_cache() elapsed = time.time() - T_TOTAL print("\n" + "="*60) print("DONE in {:.1f}s — cache cleared".format(elapsed)) print("="*60)