gigafibre-fsm/scripts/migration/simulate_payment_import.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

301 lines
11 KiB
Python

"""
Simulate importing missing payments for Expro Transit Inc (account 3673).
DRY RUN — reads legacy data, shows what would be created, and produces
a visual timeline of invoice vs payment balance.
Run inside erpnext-backend-1:
/home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/simulate_payment_import.py
"""
import frappe
import pymysql
import os
import json
from datetime import datetime
os.chdir("/home/frappe/frappe-bench/sites")
frappe.init(site="erp.gigafibre.ca", sites_path=".")
frappe.connect()
print("Connected:", frappe.local.site)
conn = pymysql.connect(
host="10.100.80.100",
user="facturation",
password="*******",
database="gestionclient",
cursorclass=pymysql.cursors.DictCursor
)
ACCOUNT_ID = 3673
CUSTOMER = "CUST-cbf03814b9"
CUSTOMER_NAME = "Expro Transit Inc."
# ═══════════════════════════════════════════════════════════════
# STEP 1: Load all legacy payments + allocations
# ═══════════════════════════════════════════════════════════════
with conn.cursor() as cur:
cur.execute("""
SELECT p.id, p.date_orig, p.amount, p.applied_amt, p.type, p.reference
FROM payment p
WHERE p.account_id = %s
ORDER BY p.date_orig ASC
""", (ACCOUNT_ID,))
legacy_payments = cur.fetchall()
cur.execute("""
SELECT pi.payment_id, pi.invoice_id, pi.amount
FROM payment_item pi
JOIN payment p ON p.id = pi.payment_id
WHERE p.account_id = %s
""", (ACCOUNT_ID,))
legacy_allocs = cur.fetchall()
conn.close()
# Build allocation map: payment_id -> [{invoice_id, amount}]
alloc_map = {}
for a in legacy_allocs:
pid = a["payment_id"]
if pid not in alloc_map:
alloc_map[pid] = []
alloc_map[pid].append({
"invoice_id": a["invoice_id"],
"amount": float(a["amount"] or 0)
})
# Which PEs already exist in ERPNext?
erp_pes = frappe.db.sql("""
SELECT name FROM "tabPayment Entry" WHERE party = %s AND docstatus = 1
""", (CUSTOMER,), as_dict=True)
erp_pe_ids = set()
for pe in erp_pes:
try:
erp_pe_ids.add(int(pe["name"].split("-")[1]))
except:
pass
# ═══════════════════════════════════════════════════════════════
# STEP 2: Load all ERPNext invoices
# ═══════════════════════════════════════════════════════════════
erp_invoices = frappe.db.sql("""
SELECT name, posting_date, grand_total, outstanding_amount, status
FROM "tabSales Invoice"
WHERE customer = %s AND docstatus = 1
ORDER BY posting_date ASC
""", (CUSTOMER,), as_dict=True)
inv_map = {}
for inv in erp_invoices:
inv_map[inv["name"]] = {
"date": str(inv["posting_date"]),
"total": float(inv["grand_total"]),
"outstanding": float(inv["outstanding_amount"]),
"status": inv["status"],
}
# ═══════════════════════════════════════════════════════════════
# STEP 3: Determine which payments to create
# ═══════════════════════════════════════════════════════════════
to_create = []
for p in legacy_payments:
if p["id"] in erp_pe_ids:
continue # Already exists
dt = datetime.fromtimestamp(p["date_orig"]).strftime("%Y-%m-%d") if p["date_orig"] else None
amount = float(p["amount"] or 0)
ptype = (p["type"] or "").strip()
ref = (p["reference"] or "").strip()
# Map legacy type to ERPNext mode
mode_map = {
"paiement direct": "Virement",
"cheque": "Chèque",
"carte credit": "Carte de crédit",
"credit": "Note de crédit",
"reversement": "Note de crédit",
}
mode = mode_map.get(ptype, ptype)
# Get allocations
allocations = alloc_map.get(p["id"], [])
refs = []
for a in allocations:
sinv_name = "SINV-{}".format(a["invoice_id"])
refs.append({
"reference_doctype": "Sales Invoice",
"reference_name": sinv_name,
"allocated_amount": a["amount"],
})
to_create.append({
"pe_name": "PE-{}".format(p["id"]),
"legacy_id": p["id"],
"date": dt,
"amount": amount,
"type": ptype,
"mode": mode,
"reference": ref,
"allocations": refs,
})
print("\n" + "=" * 70)
print("PAYMENT IMPORT SIMULATION — {} ({})".format(CUSTOMER_NAME, CUSTOMER))
print("=" * 70)
print("Legacy payments: {}".format(len(legacy_payments)))
print("Already in ERPNext: {}".format(len(erp_pe_ids)))
print("TO CREATE: {}".format(len(to_create)))
total_to_create = sum(p["amount"] for p in to_create)
print("Total to import: ${:,.2f}".format(total_to_create))
# ═══════════════════════════════════════════════════════════════
# STEP 4: Build timeline showing running balance
# ═══════════════════════════════════════════════════════════════
print("\n" + "=" * 70)
print("VISUAL BALANCE TIMELINE (with new payments)")
print("=" * 70)
# Combine all events: invoices and payments (existing + to-create)
events = []
# Add invoices
for inv in erp_invoices:
events.append({
"date": str(inv["posting_date"]),
"type": "INV",
"name": inv["name"],
"amount": float(inv["grand_total"]),
"detail": inv["status"],
})
# Add existing ERPNext payments
existing_pes = frappe.db.sql("""
SELECT name, posting_date, paid_amount
FROM "tabPayment Entry"
WHERE party = %s AND docstatus = 1
""", (CUSTOMER,), as_dict=True)
for pe in existing_pes:
events.append({
"date": str(pe["posting_date"]),
"type": "PAY",
"name": pe["name"],
"amount": float(pe["paid_amount"]),
"detail": "existing",
})
# Add new (simulated) payments
for p in to_create:
events.append({
"date": p["date"] or "2012-01-01",
"type": "PAY",
"name": p["pe_name"],
"amount": p["amount"],
"detail": "NEW " + p["mode"],
})
# Sort by date, then INV before PAY on same date
events.sort(key=lambda e: (e["date"], 0 if e["type"] == "INV" else 1))
# Print timeline with running balance
balance = 0.0
total_invoiced = 0.0
total_paid = 0.0
# Group by year for readability
current_year = None
print("\n{:<12} {:<5} {:<16} {:>12} {:>12} {}".format(
"DATE", "TYPE", "DOCUMENT", "AMOUNT", "BALANCE", "DETAIL"))
print("-" * 85)
for e in events:
year = e["date"][:4]
if year != current_year:
if current_year:
print(" {:>55} {:>12}".format("--- year-end ---", "${:,.2f}".format(balance)))
current_year = year
print("\n ── {} ──".format(year))
if e["type"] == "INV":
balance += e["amount"]
total_invoiced += e["amount"]
sign = "+"
else:
balance -= e["amount"]
total_paid += e["amount"]
sign = "-"
marker = " ◀ NEW" if "NEW" in e.get("detail", "") else ""
print("{:<12} {:<5} {:<16} {:>12} {:>12} {}{}".format(
e["date"],
e["type"],
e["name"],
"{}${:,.2f}".format(sign, abs(e["amount"])),
"${:,.2f}".format(balance),
e["detail"],
marker,
))
print("-" * 85)
print("\nFINAL SUMMARY:")
print(" Total invoiced: ${:,.2f}".format(total_invoiced))
print(" Total paid: ${:,.2f}".format(total_paid))
print(" Final balance: ${:,.2f}".format(balance))
print(" Should be: $0.00" if abs(balance) < 0.02 else " ⚠ MISMATCH!")
# ═══════════════════════════════════════════════════════════════
# STEP 5: Show sample of what the PE documents would look like
# ═══════════════════════════════════════════════════════════════
print("\n" + "=" * 70)
print("SAMPLE PAYMENT ENTRY DOCUMENTS (first 5)")
print("=" * 70)
for p in to_create[:5]:
print("\n PE Name: {}".format(p["pe_name"]))
print(" Date: {}".format(p["date"]))
print(" Amount: ${:,.2f}".format(p["amount"]))
print(" Mode: {}".format(p["mode"]))
print(" Reference: {}".format(p["reference"] or ""))
print(" Allocations:")
for a in p["allocations"]:
print("{} = ${:,.2f}".format(a["reference_name"], a["allocated_amount"]))
# ═══════════════════════════════════════════════════════════════
# STEP 6: Identify potential issues
# ═══════════════════════════════════════════════════════════════
print("\n" + "=" * 70)
print("POTENTIAL ISSUES")
print("=" * 70)
# Check for payments referencing invoices that don't exist in ERPNext
missing_invs = set()
for p in to_create:
for a in p["allocations"]:
if a["reference_name"] not in inv_map:
missing_invs.add(a["reference_name"])
if missing_invs:
print("{} payments reference invoices NOT in ERPNext:".format(len(missing_invs)))
for m in sorted(missing_invs)[:10]:
print(" {}".format(m))
else:
print("✓ All payment allocations reference existing invoices")
# Check for credits (negative-type payments)
credits = [p for p in to_create if p["type"] in ("credit", "reversement")]
if credits:
print("\n{} credit/reversement entries (not regular payments):".format(len(credits)))
for c in credits:
print(" {} date={} amount=${:,.2f} type={}".format(c["pe_name"], c["date"], c["amount"], c["type"]))
else:
print("✓ No credit entries to handle specially")
# Payments without allocations
no_alloc = [p for p in to_create if not p["allocations"]]
if no_alloc:
print("\n{} payments without invoice allocations:".format(len(no_alloc)))
for p in no_alloc:
print(" {} date={} amount=${:,.2f}".format(p["pe_name"], p["date"], p["amount"]))
else:
print("✓ All payments have invoice allocations")
print("\n" + "=" * 70)
print("DRY RUN COMPLETE — no changes made")
print("=" * 70)