Backend services: - targo-hub: extract deepGetValue to helpers.js, DRY disconnect reasons lookup map, compact CAPABILITIES, consolidate vision.js prompts/schemas, extract dispatch scoring weights, trim section dividers across 9 files - modem-bridge: extract getSession() helper (6 occurrences), resetIdleTimer(), consolidate DM query factory, fix duplicate username fill bug, trim headers (server.js -36%, tplink-session.js -47%, docker-compose.yml -57%) Frontend: - useWifiDiagnostic: extract THRESHOLDS const, split processDiagnostic into 6 focused helpers (processOnlineStatus, processWanIPs, processRadios, processMeshNodes, processClients, checkRadioIssues) - EquipmentDetail: merge duplicate ROLE_LABELS, remove verbose comments Documentation (17 → 13 files, -1,400 lines): - New consolidated README.md (architecture, services, dependencies, auth) - Merge ECOSYSTEM-OVERVIEW into ARCHITECTURE.md - Merge MIGRATION-PLAN + ARCHITECTURE-COMPARE + FIELD-GAP + CHANGELOG → MIGRATION.md - Merge COMPETITIVE-ANALYSIS into PLATFORM-STRATEGY.md - Update ROADMAP.md with current phase status - Delete CONTEXT.md (absorbed into README) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
276 lines
11 KiB
Python
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="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)
|