- 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>
301 lines
11 KiB
Python
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)
|