gigafibre-fsm/scripts/migration/fix_invoice_outstanding.py
louispaulb 101faa21f1 feat: inline editing, search, notifications + full repo cleanup
- InlineField component + useInlineEdit composable for Odoo-style dblclick editing
- Client search by name, account ID, and legacy_customer_id (or_filters)
- SMS/Email notification panel on ContactCard via n8n webhooks
- Ticket reply thread via Communication docs
- All migration scripts (51 files) now tracked
- Client portal and field tech app added to monorepo
- README rewritten with full feature list, migration summary, architecture
- CHANGELOG updated with all recent work
- ROADMAP updated with current completion status
- Removed hardcoded tokens from docs (use $ERP_SERVICE_TOKEN)
- .gitignore updated (docker/, .claude/, exports/, .quasar/)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 07:34:41 -04:00

276 lines
11 KiB
Python

"""
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="10.100.80.100",
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)