feat: subscription reimport, customer/doctype ID rename, zero-padded format

- Switch Ops data source from Subscription to Service Subscription (source of truth)
- Reimport 39,630 native Subscriptions from Service Subscription data
- Rename 15,302 customers to CUST-{legacy_customer_id} (eliminates hex UUIDs)
- Rename all doctypes to zero-padded 10-digit numeric format:
  SINV-0000001234, PE-0000001234, ISS-0000001234, LOC-0000001234,
  EQP-0000001234, SUB-0000001234, ASUB-0000001234
- Fix subscription pricing: LPB4 now correctly shows 0$/month
- Update ASUB- prefix detection in useSubscriptionActions.js
- Add reconciliation, reimport, and rename migration scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-04-01 17:17:23 -04:00
parent 7d7b4fdb06
commit 4a8718f67c
7 changed files with 1892 additions and 18 deletions

View File

@ -8,12 +8,32 @@ import { authFetch } from 'src/api/auth'
import { BASE_URL } from 'src/config/erpnext'
import { formatMoney } from 'src/composables/useFormatters'
// Frappe REST update for Subscription doctype
// Frappe REST update — supports both Service Subscription (legacy) and native Subscription
async function updateSub (name, fields) {
const res = await authFetch(BASE_URL + '/api/resource/Subscription/' + encodeURIComponent(name), {
// Detect which doctype based on name pattern: SUB-* = Service Subscription, ASUB-* = native Subscription
const doctype = name.startsWith('ASUB-') ? 'Subscription' : 'Service Subscription'
// Map normalized field names back to target doctype fields
const mapped = {}
for (const [k, v] of Object.entries(fields)) {
if (doctype === 'Service Subscription') {
// Map template field names → Service Subscription field names
if (k === 'actual_price') mapped.monthly_price = v
else if (k === 'custom_description') mapped.plan_name = v
else if (k === 'billing_frequency') mapped.billing_cycle = v === 'A' ? 'Annuel' : 'Mensuel'
else if (k === 'status') mapped.status = v === 'Active' ? 'Actif' : v === 'Cancelled' ? 'Annulé' : v
else if (k === 'cancelation_date') mapped.cancellation_date = v
else if (k === 'cancel_at_period_end') { /* not applicable to Service Subscription */ }
else mapped[k] = v
} else {
mapped[k] = v
}
}
const res = await authFetch(BASE_URL + '/api/resource/' + encodeURIComponent(doctype) + '/' + encodeURIComponent(name), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(fields),
body: JSON.stringify(mapped),
})
if (!res.ok) {
const err = await res.text()

View File

@ -22,33 +22,50 @@ export function subSection (sub) {
const price = parseFloat(sub.actual_price || 0)
const group = (sub.item_group || '').trim()
const sku = (sub.item_code || '').toUpperCase()
const label = (sub.custom_description || sub.item_name || sub.plan_name || '').toLowerCase()
if (price < 0 || sku.startsWith('RAB')) return 'Rabais'
// Negative price = rabais
if (price < 0 || sku.startsWith('RAB') || label.includes('rabais')) return 'Rabais'
// Match by item_group / service_category
for (const [section, cfg] of Object.entries(SECTION_MAP)) {
if (section === 'Rabais') continue
if (cfg.match.some(m => group.includes(m))) return section
}
// SKU-based fallback
// Category-based matching (from Service Subscription.service_category)
if (group === 'Internet') return 'Internet'
if (group === 'VoIP' || group === 'Téléphonie') return 'Téléphonie'
if (group === 'IPTV' || group === 'Télévision') return 'Télévision'
if (group === 'Hébergement') return 'Hébergement'
if (group === 'Bundle') return 'Internet'
// SKU-based fallback (works for both legacy item_codes and SUB-* names)
if (/^(FTTH|FTTB|TURBO|FIBRE|FORFERF|FORFBASE|FORFPERF|FORFPOP|FORFMES|SYM|VIP|ENT|COM|FIBCOM|HV)/.test(sku)) return 'Internet'
if (/^(TELEP|FAX|SERV911|SERVTEL|TELE_)/.test(sku)) return 'Téléphonie'
if (/^(TV|STB|RABTV)/.test(sku)) return 'Télévision'
if (/^(LOC|FTT_H|FTTH_LOC)/.test(sku)) return 'Équipement'
if (/^(HEB|DOM)/.test(sku)) return 'Hébergement'
// Label-based fallback (Service Subscription plan_name)
if (/fibre|internet|giga|illimit|turbo|optimum/i.test(label)) return 'Internet'
if (/téléphon|voip|phone|fax|911/i.test(label)) return 'Téléphonie'
if (/télé|tv|décodeur|stb/i.test(label)) return 'Télévision'
if (/location|modem|routeur|appareil|frais.*accès/i.test(label)) return 'Équipement'
if (/héberg|domaine|cloud|espace/i.test(label)) return 'Hébergement'
return 'Autre'
}
export function isRebate (sub) {
return parseFloat(sub.actual_price || 0) < 0
return parseFloat(sub.actual_price || sub.monthly_price || 0) < 0
}
export function subMainLabel (sub) {
if (sub.custom_description && sub.custom_description.trim()) {
return sub.custom_description
}
return sub.item_name || sub.item_code || sub.name
return sub.item_name || sub.plan_name || sub.item_code || sub.name
}
export function subSubLabel (sub) {

View File

@ -126,9 +126,9 @@
<div class="sub-row clickable-row" :class="{ 'sub-rebate': isRebate(sub), 'sub-cancelled': sub.status === 'Cancelled' }">
<div class="row items-center no-wrap">
<q-icon name="drag_indicator" class="drag-handle text-grey-4 q-mr-xs" size="16px" style="cursor:grab" />
<div class="col" style="min-width:0" @click="openModal('Subscription', sub.name)">
<div class="col" style="min-width:0" @click="openModal('Service Subscription', sub.name)">
<div class="row items-center no-wrap q-gutter-x-sm">
<code class="sub-sku">{{ sub.item_code || '—' }}</code>
<code class="sub-sku">{{ sub.name }}</code>
<span class="text-weight-medium ellipsis-text" style="flex:1;min-width:0">
{{ subMainLabel(sub) }}
</span>
@ -172,8 +172,8 @@
class="sub-row clickable-row" :class="{ 'sub-rebate': isRebate(sub), 'sub-cancelled': sub.status === 'Cancelled' }">
<div class="row items-center no-wrap">
<div class="col" style="min-width:0">
<div class="row items-center no-wrap q-gutter-x-sm" @click="openModal('Subscription', sub.name)">
<code class="sub-sku">{{ sub.item_code || '—' }}</code>
<div class="row items-center no-wrap q-gutter-x-sm" @click="openModal('Service Subscription', sub.name)">
<code class="sub-sku">{{ sub.name }}</code>
<span class="sub-freq annual">A</span>
<span class="text-weight-medium ellipsis-text" style="flex:1;min-width:0">
{{ subMainLabel(sub) }}
@ -600,13 +600,26 @@ async function loadCustomer (id) {
'longitude', 'latitude'],
limit: 100, orderBy: 'status asc, address_line asc',
}),
listDocs('Subscription', {
filters: partyFilter,
fields: ['name', 'status', 'start_date', 'service_location', 'radius_user',
'actual_price', 'custom_description', 'item_code', 'item_group', 'billing_frequency', 'item_name',
'cancel_at_period_end', 'current_invoice_start', 'current_invoice_end', 'end_date', 'cancelation_date'],
limit: 100, orderBy: 'start_date desc',
}),
listDocs('Service Subscription', {
filters: custFilter,
fields: ['name', 'status', 'start_date', 'end_date', 'service_location',
'monthly_price', 'plan_name', 'service_category', 'billing_cycle',
'speed_down', 'speed_up', 'cancellation_date', 'cancellation_reason', 'notes'],
limit: 200, orderBy: 'start_date desc',
}).then(subs => subs.map(s => ({
// Normalize to match template field names (formerly from Subscription doctype)
...s,
actual_price: s.monthly_price,
custom_description: s.plan_name,
item_name: s.plan_name,
item_code: s.name,
item_group: s.service_category || '',
billing_frequency: s.billing_cycle === 'Annuel' ? 'A' : 'M',
cancel_at_period_end: 0,
cancelation_date: s.cancellation_date,
// Map status: ActifActive, AnnuléCancelled, etc.
status: s.status === 'Actif' ? 'Active' : s.status === 'Annulé' ? 'Cancelled' : s.status === 'Suspendu' ? 'Cancelled' : s.status === 'En attente' ? 'Active' : s.status,
}))),
listDocs('Service Equipment', {
filters: custFilter,
fields: ['name', 'equipment_type', 'brand', 'model', 'serial_number', 'mac_address',

View File

@ -0,0 +1,491 @@
#!/usr/bin/env python3
"""
Reconcile Service Subscription (complete legacy import) vs Subscription (ERPNext native).
Identifies per-customer discrepancies:
- Missing records in Subscription that exist in Service Subscription
- Price/status mismatches
- Generates a sync plan to fix the native Subscription table
Run inside erpnext-backend-1:
/home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/reconcile_subscriptions.py
Modes:
--audit Audit only report discrepancies (default)
--sync Create missing Subscriptions from Service Subscriptions
--fix Fix price/status mismatches on existing Subscriptions
--full Audit + Sync + Fix (do everything)
"""
import sys
import os
import json
from datetime import datetime, timezone
import uuid
os.chdir("/home/frappe/frappe-bench/sites")
import frappe
frappe.init(site="erp.gigafibre.ca", sites_path=".")
frappe.connect()
frappe.local.flags.ignore_permissions = True
print(f"Connected: {frappe.local.site}")
ADMIN = "Administrator"
COMPANY = "TARGO"
TAX_TEMPLATE = "QC TPS 5% + TVQ 9.975% - T"
def uid(prefix=""):
return prefix + uuid.uuid4().hex[:10]
def now():
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f")
# ═══════════════════════════════════════════════════════════════
# STEP 1: Load all Service Subscriptions (source of truth)
# ═══════════════════════════════════════════════════════════════
def load_service_subscriptions():
"""Load all Service Subscriptions grouped by customer."""
print("\n" + "=" * 70)
print("LOADING SERVICE SUBSCRIPTIONS (source of truth)")
print("=" * 70)
rows = frappe.db.sql("""
SELECT name, customer, service_location, plan_name, monthly_price,
status, service_category, billing_cycle, start_date, end_date,
speed_down, speed_up, cancellation_date, cancellation_reason,
legacy_service_id, product_sku, radius_user, radius_password
FROM "tabService Subscription"
ORDER BY customer, service_location, monthly_price DESC
""", as_dict=True)
by_customer = {}
for r in rows:
cust = r["customer"]
if cust not in by_customer:
by_customer[cust] = []
by_customer[cust].append(r)
total = len(rows)
active = sum(1 for r in rows if r["status"] == "Actif")
customers = len(by_customer)
print(f" Total: {total} records, {active} active, {customers} customers")
return by_customer
# ═══════════════════════════════════════════════════════════════
# STEP 2: Load all Subscriptions (ERPNext native)
# ═══════════════════════════════════════════════════════════════
def load_subscriptions():
"""Load all native Subscriptions grouped by party (customer)."""
print("\n" + "=" * 70)
print("LOADING SUBSCRIPTIONS (ERPNext native)")
print("=" * 70)
rows = frappe.db.sql("""
SELECT s.name, s.party as customer, s.status, s.start_date, s.end_date,
s.actual_price, s.custom_description, s.item_code, s.item_group,
s.billing_frequency, s.legacy_service_id, s.service_location,
s.radius_user, s.cancel_at_period_end, s.cancelation_date,
s.additional_discount_amount
FROM "tabSubscription" s
ORDER BY s.party, s.service_location, s.actual_price DESC
""", as_dict=True)
by_customer = {}
by_legacy_id = {}
for r in rows:
cust = r["customer"]
if cust not in by_customer:
by_customer[cust] = []
by_customer[cust].append(r)
if r.get("legacy_service_id"):
by_legacy_id[r["legacy_service_id"]] = r
total = len(rows)
active = sum(1 for r in rows if r["status"] == "Active")
customers = len(by_customer)
print(f" Total: {total} records, {active} active, {customers} customers")
return by_customer, by_legacy_id
# ═══════════════════════════════════════════════════════════════
# STEP 3: Audit — Compare per-customer
# ═══════════════════════════════════════════════════════════════
def audit(ss_by_cust, sub_by_cust, sub_by_legacy_id):
"""Compare per-customer and identify discrepancies."""
print("\n" + "=" * 70)
print("AUDIT: COMPARING PER-CUSTOMER")
print("=" * 70)
all_customers = set(list(ss_by_cust.keys()) + list(sub_by_cust.keys()))
# Discrepancy tracking
missing_in_sub = [] # Service Subscriptions with no matching Subscription
price_mismatches = [] # Matching records with different prices
status_mismatches = [] # Matching records with different statuses
count_mismatches = [] # Customers with different record counts
total_mismatches = [] # Customers with different monthly totals
customers_ok = 0
customers_mismatch = 0
for cust in sorted(all_customers):
ss_list = ss_by_cust.get(cust, [])
sub_list = sub_by_cust.get(cust, [])
# Compare totals
ss_total = sum(float(s.get("monthly_price") or 0) for s in ss_list if s["status"] == "Actif")
sub_total = sum(float(s.get("actual_price") or 0) for s in sub_list if s["status"] == "Active")
ss_active = [s for s in ss_list if s["status"] == "Actif"]
sub_active = [s for s in sub_list if s["status"] == "Active"]
has_issue = False
# Count mismatch
if len(ss_active) != len(sub_active):
count_mismatches.append({
"customer": cust,
"ss_count": len(ss_active),
"sub_count": len(sub_active),
"diff": len(ss_active) - len(sub_active),
})
has_issue = True
# Total mismatch (more than 0.01$ difference)
if abs(ss_total - sub_total) > 0.01:
total_mismatches.append({
"customer": cust,
"ss_total": ss_total,
"sub_total": sub_total,
"diff": ss_total - sub_total,
})
has_issue = True
# Per-record matching via legacy_service_id
for ss in ss_list:
legacy_id = ss.get("legacy_service_id")
if legacy_id and legacy_id in sub_by_legacy_id:
sub = sub_by_legacy_id[legacy_id]
# Price check
ss_price = float(ss.get("monthly_price") or 0)
sub_price = float(sub.get("actual_price") or 0)
if abs(ss_price - sub_price) > 0.01:
price_mismatches.append({
"customer": cust,
"ss_name": ss["name"],
"sub_name": sub["name"],
"legacy_id": legacy_id,
"plan": ss.get("plan_name") or "",
"ss_price": ss_price,
"sub_price": sub_price,
"diff": ss_price - sub_price,
})
has_issue = True
# Status check
ss_status = "Active" if ss["status"] == "Actif" else "Cancelled"
sub_status = sub["status"]
if ss_status != sub_status:
status_mismatches.append({
"customer": cust,
"ss_name": ss["name"],
"sub_name": sub["name"],
"legacy_id": legacy_id,
"ss_status": ss["status"],
"sub_status": sub_status,
})
has_issue = True
elif legacy_id:
# No matching Subscription found
missing_in_sub.append({
"customer": cust,
"ss_name": ss["name"],
"legacy_id": legacy_id,
"plan_name": ss.get("plan_name") or "",
"monthly_price": float(ss.get("monthly_price") or 0),
"status": ss["status"],
"service_location": ss.get("service_location") or "",
"service_category": ss.get("service_category") or "",
"billing_cycle": ss.get("billing_cycle") or "Mensuel",
"start_date": ss.get("start_date"),
"product_sku": ss.get("product_sku") or "",
"radius_user": ss.get("radius_user") or "",
"radius_password": ss.get("radius_password") or "",
})
has_issue = True
if has_issue:
customers_mismatch += 1
else:
customers_ok += 1
# ── Report ──
print(f"\n Customers OK (totals match): {customers_ok}")
print(f" Customers with discrepancies: {customers_mismatch}")
print(f" ---")
print(f" Missing in Subscription: {len(missing_in_sub)}")
print(f" Price mismatches: {len(price_mismatches)}")
print(f" Status mismatches: {len(status_mismatches)}")
print(f" Count mismatches: {len(count_mismatches)}")
print(f" Total (monthly $) mismatches: {len(total_mismatches)}")
# Top 20 total mismatches
if total_mismatches:
print(f"\n TOP 20 MONTHLY TOTAL MISMATCHES:")
sorted_tm = sorted(total_mismatches, key=lambda x: abs(x["diff"]), reverse=True)
for tm in sorted_tm[:20]:
print(f" {tm['customer']:25s} SS: {tm['ss_total']:>8.2f} SUB: {tm['sub_total']:>8.2f} DIFF: {tm['diff']:>+8.2f}")
# Top 20 count mismatches
if count_mismatches:
print(f"\n TOP 20 COUNT MISMATCHES:")
sorted_cm = sorted(count_mismatches, key=lambda x: abs(x["diff"]), reverse=True)
for cm in sorted_cm[:20]:
print(f" {cm['customer']:25s} SS: {cm['ss_count']:3d} SUB: {cm['sub_count']:3d} DIFF: {cm['diff']:+3d}")
# Missing by category
if missing_in_sub:
cats = {}
for m in missing_in_sub:
cat = m["service_category"] or "Unknown"
cats[cat] = cats.get(cat, 0) + 1
print(f"\n MISSING IN SUBSCRIPTION BY CATEGORY:")
for cat, count in sorted(cats.items(), key=lambda x: -x[1]):
print(f" {cat:25s} {count:5d}")
return {
"missing": missing_in_sub,
"price_mismatches": price_mismatches,
"status_mismatches": status_mismatches,
"count_mismatches": count_mismatches,
"total_mismatches": total_mismatches,
}
# ═══════════════════════════════════════════════════════════════
# STEP 4: Sync — Create missing Subscriptions
# ═══════════════════════════════════════════════════════════════
def sync_missing(missing_records):
"""Create native Subscriptions for records missing from the Subscription table."""
print("\n" + "=" * 70)
print(f"SYNC: CREATING {len(missing_records)} MISSING SUBSCRIPTIONS")
print("=" * 70)
if not missing_records:
print(" Nothing to sync!")
return
ts = now()
# Load existing subscription plans
plans = frappe.db.sql('SELECT plan_name, name FROM "tabSubscription Plan"', as_dict=True)
plan_map = {p["plan_name"]: p["name"] for p in plans}
# Load existing items (to find or create plans)
items = frappe.db.sql('SELECT name, item_name, item_group FROM "tabItem"', as_dict=True)
item_map = {i["name"]: i for i in items}
created = 0
errors = 0
plan_created = 0
for rec in missing_records:
sku = rec["product_sku"] or "UNKNOWN"
plan_name = f"PLAN-{sku}"
# Create plan if it doesn't exist
if plan_name not in plan_map:
plan_id = uid("SP-")
try:
frappe.db.sql("""
INSERT INTO "tabSubscription Plan" (
name, creation, modified, modified_by, owner, docstatus, idx,
plan_name, item, currency, price_determination, cost,
billing_interval, billing_interval_count
) VALUES (%s, %s, %s, %s, %s, 0, 0,
%s, %s, 'CAD', 'Fixed Rate', %s, %s, 1)
""", (plan_id, ts, ts, ADMIN, ADMIN, plan_name, sku,
abs(rec["monthly_price"]),
"Year" if rec["billing_cycle"] == "Annuel" else "Month"))
plan_map[plan_name] = plan_id
plan_created += 1
except Exception as e:
frappe.db.rollback()
print(f" ERR plan {plan_name}: {str(e)[:80]}")
# Determine status
status = "Active" if rec["status"] == "Actif" else "Cancelled"
# Determine discount: negative price = discount record, handle differently
price = rec["monthly_price"]
discount = 0
if price < 0:
# This is a rebate/discount line — store as negative actual_price
discount = 0 # Don't use additional_discount for negative-price lines
# Start date
start_date = rec.get("start_date") or "2020-01-01"
if hasattr(start_date, 'strftime'):
start_date = start_date.strftime("%Y-%m-%d")
# Billing frequency
billing_freq = "A" if rec["billing_cycle"] == "Annuel" else "M"
sub_id = uid("SUB-")
try:
frappe.db.sql("""
INSERT INTO "tabSubscription" (
name, creation, modified, modified_by, owner, docstatus, idx,
party_type, party, company, status,
start_date, generate_invoice_at, days_until_due,
follow_calendar_months, generate_new_invoices_past_due_date,
submit_invoice, cancel_at_period_end,
sales_tax_template,
additional_discount_amount,
radius_user, radius_pwd, legacy_service_id,
service_location,
actual_price, custom_description, item_code, item_group,
billing_frequency
) VALUES (
%s, %s, %s, %s, %s, 0, 0,
'Customer', %s, %s, %s,
%s, 'Beginning of the current subscription period', 30,
0, 1, 0, 0,
%s, %s,
%s, %s, %s,
%s,
%s, %s, %s, %s, %s
)
""", (sub_id, ts, ts, ADMIN, ADMIN,
rec["customer"], COMPANY, status,
start_date,
TAX_TEMPLATE, discount,
rec.get("radius_user") or None,
rec.get("radius_password") or None,
rec["legacy_id"],
rec.get("service_location") or None,
price, rec.get("plan_name") or "",
sku, rec.get("service_category") or "",
billing_freq))
# Create plan detail child record
if plan_name in plan_map:
frappe.db.sql("""
INSERT INTO "tabSubscription Plan Detail" (
name, creation, modified, modified_by, owner, docstatus, idx,
plan, qty,
parent, parentfield, parenttype
) VALUES (%s, %s, %s, %s, %s, 0, 1,
%s, 1,
%s, 'plans', 'Subscription')
""", (uid("SPD-"), ts, ts, ADMIN, ADMIN, plan_name, sub_id))
created += 1
if created % 500 == 0:
frappe.db.commit()
print(f" [{created}/{len(missing_records)}] created...")
except Exception as e:
errors += 1
frappe.db.rollback()
if errors <= 20:
print(f" ERR {rec['customer']} {rec['ss_name']}: {str(e)[:100]}")
frappe.db.commit()
print(f"\n Plans created: {plan_created}")
print(f" Subscriptions created: {created}")
print(f" Errors: {errors}")
# ═══════════════════════════════════════════════════════════════
# STEP 5: Fix — Correct price/status mismatches
# ═══════════════════════════════════════════════════════════════
def fix_mismatches(price_mismatches, status_mismatches):
"""Fix price and status mismatches on existing Subscriptions."""
print("\n" + "=" * 70)
print(f"FIX: CORRECTING {len(price_mismatches)} PRICE + {len(status_mismatches)} STATUS MISMATCHES")
print("=" * 70)
fixed_price = 0
fixed_status = 0
for pm in price_mismatches:
try:
frappe.db.sql("""
UPDATE "tabSubscription"
SET actual_price = %s, modified = %s
WHERE name = %s
""", (pm["ss_price"], now(), pm["sub_name"]))
fixed_price += 1
except Exception as e:
print(f" ERR price fix {pm['sub_name']}: {str(e)[:80]}")
for sm in status_mismatches:
new_status = "Active" if sm["ss_status"] == "Actif" else "Cancelled"
try:
frappe.db.sql("""
UPDATE "tabSubscription"
SET status = %s, modified = %s
WHERE name = %s
""", (new_status, now(), sm["sub_name"]))
fixed_status += 1
except Exception as e:
print(f" ERR status fix {sm['sub_name']}: {str(e)[:80]}")
frappe.db.commit()
print(f" Price fixes: {fixed_price}")
print(f" Status fixes: {fixed_status}")
# ═══════════════════════════════════════════════════════════════
# MAIN
# ═══════════════════════════════════════════════════════════════
def main():
mode = sys.argv[1] if len(sys.argv) > 1 else "--audit"
print(f"\nMode: {mode}")
print(f"Time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
# Load data
ss_by_cust = load_service_subscriptions()
sub_by_cust, sub_by_legacy_id = load_subscriptions()
# Audit
results = audit(ss_by_cust, sub_by_cust, sub_by_legacy_id)
if mode in ("--sync", "--full"):
sync_missing(results["missing"])
if mode in ("--fix", "--full"):
fix_mismatches(results["price_mismatches"], results["status_mismatches"])
# Write report file
report_path = "/tmp/reconcile_report.json"
report = {
"timestamp": datetime.now().isoformat(),
"mode": mode,
"summary": {
"missing_in_subscription": len(results["missing"]),
"price_mismatches": len(results["price_mismatches"]),
"status_mismatches": len(results["status_mismatches"]),
"count_mismatches": len(results["count_mismatches"]),
"total_mismatches": len(results["total_mismatches"]),
},
"total_mismatches_top50": sorted(results["total_mismatches"], key=lambda x: abs(x["diff"]), reverse=True)[:50],
"missing_sample": results["missing"][:50],
}
with open(report_path, "w") as f:
json.dump(report, f, indent=2, default=str)
print(f"\nReport saved: {report_path}")
frappe.clear_cache()
print("\nDone — cache cleared")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,423 @@
#!/usr/bin/env python3
"""
Clean reimport of native Subscriptions from Service Subscription (source of truth).
Fixes the migration issue where Phase 3/4 created hex customer IDs (CUST-04ea550dcf)
instead of mapping to the correct sequential IDs (CUST-988).
Steps:
1. Purge all native Subscriptions + Plan Details
2. Recreate Subscriptions from Service Subscription data (correct customer mapping)
3. Delete orphaned hex customers (no remaining FK references)
4. Verify totals match
Run inside erpnext-backend-1:
/home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/reimport_subscriptions.py [--dry-run]
Log: stdout (run with tee)
"""
import sys
import os
import uuid
import time
from datetime import datetime, timezone
os.chdir("/home/frappe/frappe-bench/sites")
import frappe
frappe.init(site="erp.gigafibre.ca", sites_path=".")
frappe.connect()
frappe.local.flags.ignore_permissions = True
print(f"Connected: {frappe.local.site}")
DRY_RUN = "--dry-run" in sys.argv
if DRY_RUN:
print("*** DRY RUN — no changes will be written ***")
ADMIN = "Administrator"
COMPANY = "TARGO"
TAX_TEMPLATE = "QC TPS 5% + TVQ 9.975% - T"
def uid(prefix=""):
return prefix + uuid.uuid4().hex[:10]
def now():
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f")
# ═══════════════════════════════════════════════════════════════
# STEP 1: PURGE native Subscriptions
# ═══════════════════════════════════════════════════════════════
print("\n" + "=" * 70)
print("STEP 1: PURGE EXISTING SUBSCRIPTIONS")
print("=" * 70)
sub_count = frappe.db.sql('SELECT COUNT(*) FROM "tabSubscription"')[0][0]
spd_count = frappe.db.sql('SELECT COUNT(*) FROM "tabSubscription Plan Detail"')[0][0]
sp_count = frappe.db.sql('SELECT COUNT(*) FROM "tabSubscription Plan"')[0][0]
print(f" Current counts: {sub_count} Subscriptions, {spd_count} Plan Details, {sp_count} Plans")
if not DRY_RUN:
frappe.db.sql('DELETE FROM "tabSubscription Plan Detail"')
frappe.db.sql('DELETE FROM "tabSubscription"')
frappe.db.sql('DELETE FROM "tabSubscription Plan"')
frappe.db.commit()
print(" Purged all Subscriptions, Plan Details, and Plans.")
else:
print(" [DRY RUN] Would purge all Subscriptions, Plan Details, and Plans.")
# ═══════════════════════════════════════════════════════════════
# STEP 2: LOAD Service Subscriptions (source of truth)
# ═══════════════════════════════════════════════════════════════
print("\n" + "=" * 70)
print("STEP 2: LOAD SERVICE SUBSCRIPTIONS")
print("=" * 70)
ss_rows = frappe.db.sql("""
SELECT name, customer, service_location, plan_name, monthly_price,
status, service_category, billing_cycle, start_date, end_date,
speed_down, speed_up, cancellation_date, cancellation_reason,
legacy_service_id, product_sku, radius_user, radius_password, notes
FROM "tabService Subscription"
ORDER BY customer, service_location, monthly_price DESC
""", as_dict=True)
total = len(ss_rows)
active = sum(1 for r in ss_rows if r["status"] == "Actif")
customers = len(set(r["customer"] for r in ss_rows))
print(f" Loaded {total} Service Subscriptions ({active} active) for {customers} customers")
# ═══════════════════════════════════════════════════════════════
# STEP 3: BUILD Subscription Plans from unique SKUs
# ═══════════════════════════════════════════════════════════════
print("\n" + "=" * 70)
print("STEP 3: CREATE SUBSCRIPTION PLANS")
print("=" * 70)
t0 = time.time()
# Collect unique SKUs with their typical price and category
sku_info = {}
for r in ss_rows:
sku = r.get("product_sku") or ""
if not sku:
continue
if sku not in sku_info:
sku_info[sku] = {
"price": abs(float(r["monthly_price"] or 0)),
"category": r.get("service_category") or "",
"billing_cycle": r.get("billing_cycle") or "Mensuel",
"plan_name": r.get("plan_name") or sku,
}
# Also add a fallback plan for records without SKU
plan_map = {} # plan_name → plan_id
plans_created = 0
if not DRY_RUN:
ts = now()
for sku, info in sku_info.items():
plan_name = f"PLAN-{sku}"
plan_id = uid("SP-")
billing_interval = "Year" if info["billing_cycle"] == "Annuel" else "Month"
try:
frappe.db.sql("""
INSERT INTO "tabSubscription Plan" (
name, creation, modified, modified_by, owner, docstatus, idx,
plan_name, item, currency, price_determination, cost,
billing_interval, billing_interval_count
) VALUES (%s, %s, %s, %s, %s, 0, 0,
%s, %s, 'CAD', 'Fixed Rate', %s, %s, 1)
""", (plan_id, ts, ts, ADMIN, ADMIN, plan_name, sku, info["price"], billing_interval))
plan_map[plan_name] = plan_id
plans_created += 1
except Exception as e:
frappe.db.rollback()
print(f" ERR plan {sku}: {str(e)[:80]}")
frappe.db.commit()
print(f" Created {plans_created} Subscription Plans [{time.time()-t0:.1f}s]")
# ═══════════════════════════════════════════════════════════════
# STEP 4: CREATE Subscriptions from Service Subscriptions
# ═══════════════════════════════════════════════════════════════
print("\n" + "=" * 70)
print("STEP 4: CREATE SUBSCRIPTIONS")
print("=" * 70)
t0 = time.time()
# Verify all customers exist
all_customers = set(r["customer"] for r in ss_rows)
existing_customers = set(
r[0] for r in frappe.db.sql('SELECT name FROM "tabCustomer"')
)
missing_customers = all_customers - existing_customers
if missing_customers:
print(f" WARNING: {len(missing_customers)} customers referenced but don't exist!")
for c in list(missing_customers)[:10]:
print(f" {c}")
sub_ok = sub_err = 0
ts = now()
for i, ss in enumerate(ss_rows):
if DRY_RUN:
sub_ok += 1
continue
cust = ss["customer"]
if cust in missing_customers:
sub_err += 1
continue
# Map status
if ss["status"] == "Actif":
status = "Active"
elif ss["status"] == "Annulé":
status = "Cancelled"
elif ss["status"] == "Suspendu":
status = "Past Due Date"
else:
status = "Active" # default
# Price
price = float(ss["monthly_price"] or 0)
# Start date
start_date = ss.get("start_date") or "2020-01-01"
if hasattr(start_date, "strftime"):
start_date = start_date.strftime("%Y-%m-%d")
# End/cancellation date
end_date = ss.get("end_date")
if hasattr(end_date, "strftime"):
end_date = end_date.strftime("%Y-%m-%d")
cancel_date = ss.get("cancellation_date")
if hasattr(cancel_date, "strftime"):
cancel_date = cancel_date.strftime("%Y-%m-%d")
# Billing frequency
billing_freq = "A" if ss.get("billing_cycle") == "Annuel" else "M"
# SKU / plan
sku = ss.get("product_sku") or ""
plan_name = f"PLAN-{sku}" if sku else ""
# Category
category = ss.get("service_category") or ""
# Description: plan_name from SS
description = ss.get("plan_name") or ""
sub_id = uid("SUB-")
try:
frappe.db.sql("""
INSERT INTO "tabSubscription" (
name, creation, modified, modified_by, owner, docstatus, idx,
party_type, party, company, status,
start_date, generate_invoice_at, days_until_due,
follow_calendar_months, generate_new_invoices_past_due_date,
submit_invoice, cancel_at_period_end,
sales_tax_template,
additional_discount_amount,
radius_user, radius_pwd, legacy_service_id,
service_location,
actual_price, custom_description, item_code, item_group,
billing_frequency,
cancelation_date
) VALUES (
%s, %s, %s, %s, %s, 0, 0,
'Customer', %s, %s, %s,
%s, 'Beginning of the current subscription period', 30,
1, 1, 0, 0,
%s, 0,
%s, %s, %s,
%s,
%s, %s, %s, %s, %s,
%s
)
""", (sub_id, ts, ts, ADMIN, ADMIN,
cust, COMPANY, status,
start_date,
TAX_TEMPLATE,
ss.get("radius_user") or None,
ss.get("radius_password") or None,
ss.get("legacy_service_id"),
ss.get("service_location") or None,
price, description, sku, category, billing_freq,
cancel_date))
# Create plan detail child record
if plan_name and plan_name in plan_map:
frappe.db.sql("""
INSERT INTO "tabSubscription Plan Detail" (
name, creation, modified, modified_by, owner, docstatus, idx,
plan, qty,
parent, parentfield, parenttype
) VALUES (%s, %s, %s, %s, %s, 0, 1,
%s, 1,
%s, 'plans', 'Subscription')
""", (uid("SPD-"), ts, ts, ADMIN, ADMIN, plan_name, sub_id))
sub_ok += 1
if sub_ok % 2000 == 0:
frappe.db.commit()
print(f" [{sub_ok}/{total}] created... [{time.time()-t0:.0f}s]")
except Exception as e:
sub_err += 1
frappe.db.rollback()
if sub_err <= 20:
print(f" ERR {cust}/{ss['name']}: {str(e)[:100]}")
if not DRY_RUN:
frappe.db.commit()
print(f" Created {sub_ok} Subscriptions, {sub_err} errors [{time.time()-t0:.1f}s]")
# ═══════════════════════════════════════════════════════════════
# STEP 5: CLEANUP — Delete orphaned hex customers
# ═══════════════════════════════════════════════════════════════
print("\n" + "=" * 70)
print("STEP 5: CLEANUP ORPHANED HEX CUSTOMERS")
print("=" * 70)
t0 = time.time()
# Find hex-pattern customers (non-numeric suffix after CUST-)
hex_customers = frappe.db.sql("""
SELECT name, customer_name, legacy_account_id
FROM "tabCustomer"
WHERE name ~ '^CUST-' AND name !~ '^CUST-[0-9]+$'
""", as_dict=True)
print(f" Found {len(hex_customers)} hex-pattern customers")
if hex_customers:
# Check which hex customers still have FK references using subqueries (avoid ANY() limits)
hex_subquery = """(SELECT name FROM "tabCustomer" WHERE name ~ '^CUST-' AND name !~ '^CUST-[0-9]+$')"""
fk_tables = [
("tabSales Invoice", "customer"),
("tabPayment Entry", "party"),
("tabIssue", "customer"),
("tabService Location", "customer"),
("tabService Subscription", "customer"),
("tabSubscription", "party"),
("tabService Equipment", "customer"),
]
referenced = set()
for table, col in fk_tables:
try:
refs = frappe.db.sql(
f'SELECT DISTINCT "{col}" FROM "{table}" WHERE "{col}" IN {hex_subquery}'
)
for r in refs:
referenced.add(r[0])
print(f" {table:30s} {col:15s}{len(refs)} hex refs")
except Exception as e:
frappe.db.rollback()
print(f" ERR {table}: {str(e)[:60]}")
orphaned = [c for c in hex_customers if c["name"] not in referenced]
still_used = [c for c in hex_customers if c["name"] in referenced]
print(f" Still referenced (keep): {len(still_used)}")
print(f" Orphaned (can delete): {len(orphaned)}")
if orphaned and not DRY_RUN:
# Delete orphaned hex customers using subquery (avoid ANY() limits)
# First, create a temp table of referenced hex customers
frappe.db.sql("""
DELETE FROM "tabDynamic Link"
WHERE link_doctype = 'Customer'
AND link_name IN (SELECT name FROM "tabCustomer"
WHERE name ~ '^CUST-' AND name !~ '^CUST-[0-9]+$'
AND name NOT IN (SELECT DISTINCT party FROM "tabSubscription" WHERE party IS NOT NULL)
AND name NOT IN (SELECT DISTINCT customer FROM "tabSales Invoice" WHERE customer IS NOT NULL)
AND name NOT IN (SELECT DISTINCT party FROM "tabPayment Entry" WHERE party IS NOT NULL)
AND name NOT IN (SELECT DISTINCT customer FROM "tabIssue" WHERE customer IS NOT NULL)
AND name NOT IN (SELECT DISTINCT customer FROM "tabService Location" WHERE customer IS NOT NULL)
AND name NOT IN (SELECT DISTINCT customer FROM "tabService Subscription" WHERE customer IS NOT NULL)
AND name NOT IN (SELECT DISTINCT customer FROM "tabService Equipment" WHERE customer IS NOT NULL)
)
""")
frappe.db.commit()
deleted = frappe.db.sql("""
DELETE FROM "tabCustomer"
WHERE name ~ '^CUST-' AND name !~ '^CUST-[0-9]+$'
AND name NOT IN (SELECT DISTINCT party FROM "tabSubscription" WHERE party IS NOT NULL)
AND name NOT IN (SELECT DISTINCT customer FROM "tabSales Invoice" WHERE customer IS NOT NULL)
AND name NOT IN (SELECT DISTINCT party FROM "tabPayment Entry" WHERE party IS NOT NULL)
AND name NOT IN (SELECT DISTINCT customer FROM "tabIssue" WHERE customer IS NOT NULL)
AND name NOT IN (SELECT DISTINCT customer FROM "tabService Location" WHERE customer IS NOT NULL)
AND name NOT IN (SELECT DISTINCT customer FROM "tabService Subscription" WHERE customer IS NOT NULL)
AND name NOT IN (SELECT DISTINCT customer FROM "tabService Equipment" WHERE customer IS NOT NULL)
""")
frappe.db.commit()
print(f" Deleted orphaned hex customers")
elif orphaned:
print(f" [DRY RUN] Would delete {len(orphaned)} orphaned hex customers")
else:
print(" No hex-pattern customers found.")
# ═══════════════════════════════════════════════════════════════
# STEP 6: VERIFY
# ═══════════════════════════════════════════════════════════════
print("\n" + "=" * 70)
print("STEP 6: VERIFICATION")
print("=" * 70)
new_sub_count = frappe.db.sql('SELECT COUNT(*) FROM "tabSubscription"')[0][0]
new_spd_count = frappe.db.sql('SELECT COUNT(*) FROM "tabSubscription Plan Detail"')[0][0]
new_sp_count = frappe.db.sql('SELECT COUNT(*) FROM "tabSubscription Plan"')[0][0]
cust_count = frappe.db.sql('SELECT COUNT(*) FROM "tabCustomer"')[0][0]
print(f" Subscriptions: {new_sub_count}")
print(f" Plan Details: {new_spd_count}")
print(f" Plans: {new_sp_count}")
print(f" Customers: {cust_count}")
# Check for customer ID consistency: all subscriptions should point to sequential customers
hex_refs = frappe.db.sql("""
SELECT COUNT(*) FROM "tabSubscription"
WHERE party ~ '^CUST-' AND party !~ '^CUST-[0-9]+$'
""")[0][0]
print(f" Subscriptions pointing to hex customers: {hex_refs} (should be 0)")
# Spot-check: LPB4 customer (legacy_account_id = 4)
lpb4 = frappe.db.sql("""
SELECT name FROM "tabCustomer" WHERE legacy_account_id = 4
""", as_dict=True)
if lpb4:
cust_name = lpb4[0]["name"]
lpb4_subs = frappe.db.sql("""
SELECT actual_price, item_code, custom_description, status, billing_frequency
FROM "tabSubscription"
WHERE party = %s
ORDER BY actual_price DESC
""", (cust_name,), as_dict=True)
total_price = sum(float(s["actual_price"] or 0) for s in lpb4_subs if s["status"] == "Active")
print(f"\n Spot-check: {cust_name} (LPB4)")
for s in lpb4_subs:
print(f" {s['item_code'] or '':15s} {float(s['actual_price'] or 0):>8.2f} {s['billing_frequency']} {s['status']} {s['custom_description'] or ''}")
print(f" {'TOTAL':15s} {total_price:>8.2f}$/mo")
# Summary
print("\n" + "=" * 70)
print("COMPLETE")
print("=" * 70)
print(f" Next: bench --site erp.gigafibre.ca clear-cache")
print()

View File

@ -0,0 +1,581 @@
#!/usr/bin/env python3
"""
Rename all doctype IDs to zero-padded 10-digit numeric format.
Format: {PREFIX}-{legacy_id:010d}
SINV-0000001234 (Sales Invoice)
PE-0000001234 (Payment Entry)
ISS-0000001234 (Issue)
LOC-0000001234 (Service Location)
EQP-0000001234 (Service Equipment)
SUB-0000001234 (Service Subscription)
Two-phase rename to avoid PK collisions.
Uses temp mapping tables + bulk UPDATE ... FROM for FK references.
Run inside erpnext-backend-1:
/home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/rename_all_doctypes.py [--dry-run]
"""
import sys
import os
import time
os.chdir("/home/frappe/frappe-bench/sites")
import frappe
frappe.init(site="erp.gigafibre.ca", sites_path=".")
frappe.connect()
frappe.local.flags.ignore_permissions = True
print(f"Connected: {frappe.local.site}")
DRY_RUN = "--dry-run" in sys.argv
if DRY_RUN:
print("*** DRY RUN ***\n")
def rename_doctype(label, table, prefix, legacy_col, fk_refs, pad=10):
"""
Rename a doctype's records to {prefix}-{legacy_id:0{pad}d}.
Args:
label: Human-readable label
table: PostgreSQL table name (e.g. "tabSales Invoice")
prefix: ID prefix (e.g. "SINV")
legacy_col: Column containing the legacy numeric ID
fk_refs: List of (fk_table, fk_column, extra_where) tuples
pad: Zero-padding width (default 10)
"""
print("\n" + "=" * 70)
print(f"RENAMING: {label}{prefix}-{('0' * pad)}")
print("=" * 70)
t_start = time.time()
# Step 1: Build mapping
rows = frappe.db.sql(f"""
SELECT name, {legacy_col} FROM "{table}"
WHERE {legacy_col} IS NOT NULL AND {legacy_col} > 0
""", as_dict=True)
mapping = {}
new_used = set()
collisions = 0
for r in rows:
old_name = r["name"]
new_name = f"{prefix}-{int(r[legacy_col]):0{pad}d}"
if new_name in new_used:
collisions += 1
new_name = f"{new_name}d{collisions}"
new_used.add(new_name)
if old_name != new_name:
mapping[old_name] = new_name
# Also handle records WITHOUT legacy ID
no_legacy = frappe.db.sql(f"""
SELECT COUNT(*) FROM "{table}"
WHERE {legacy_col} IS NULL OR {legacy_col} = 0
""")[0][0]
total = len(rows) + no_legacy
print(f" Total records: {total}")
print(f" Will rename: {len(mapping)}")
print(f" Already correct: {len(rows) - len(mapping)}")
print(f" No legacy ID (skip): {no_legacy}")
if collisions:
print(f" Collisions: {collisions}")
if not mapping:
print(" Nothing to rename!")
return
# Show samples
samples = list(mapping.items())[:5]
for old, new in samples:
print(f" {old:30s}{new}")
if DRY_RUN:
return
# Step 2: Create temp mapping table
map_table = f"_rename_map_{prefix.lower()}"
frappe.db.sql(f'DROP TABLE IF EXISTS {map_table}')
frappe.db.sql(f"""
CREATE TEMP TABLE {map_table} (
old_name VARCHAR(140) PRIMARY KEY,
new_name VARCHAR(140) NOT NULL
)
""")
# Insert mappings in batches
items = list(mapping.items())
batch_size = 1000
for start in range(0, len(items), batch_size):
batch = items[start:start + batch_size]
frappe.db.sql(
f"INSERT INTO {map_table} (old_name, new_name) VALUES " +
",".join(["(%s, %s)"] * len(batch)),
[v for pair in batch for v in pair]
)
frappe.db.commit()
# Step 3: Update FK references
for fk_table, fk_col, extra_where in fk_refs:
t0 = time.time()
try:
frappe.db.sql(f"""
UPDATE "{fk_table}" t
SET "{fk_col}" = m.new_name
FROM {map_table} m
WHERE t."{fk_col}" = m.old_name
{extra_where}
""")
frappe.db.commit()
print(f" FK {fk_table}.{fk_col} [{time.time()-t0:.1f}s]")
except Exception as e:
frappe.db.rollback()
err = str(e)[:80]
if "does not exist" not in err:
print(f" FK {fk_table}.{fk_col} ERR: {err}")
# Step 4: Two-phase rename
# Phase A: old → _TEMP_new
print(f" Phase A: temp rename...")
t0 = time.time()
for start in range(0, len(items), 500):
batch = items[start:start + 500]
cases = " ".join(f"WHEN '{old}' THEN '_TEMP_{new}'" for old, new in batch)
old_names = "','".join(old for old, _ in batch)
frappe.db.sql(f"""
UPDATE "{table}"
SET name = CASE name {cases} END
WHERE name IN ('{old_names}')
""")
frappe.db.commit()
print(f" Phase A done [{time.time()-t0:.1f}s]")
# Phase B: _TEMP_new → new
print(f" Phase B: final rename...")
t0 = time.time()
for start in range(0, len(items), 500):
batch = items[start:start + 500]
cases = " ".join(f"WHEN '_TEMP_{new}' THEN '{new}'" for _, new in batch)
temp_names = "','".join(f"_TEMP_{new}" for _, new in batch)
frappe.db.sql(f"""
UPDATE "{table}"
SET name = CASE name {cases} END
WHERE name IN ('{temp_names}')
""")
frappe.db.commit()
print(f" Phase B done [{time.time()-t0:.1f}s]")
# Clean up
frappe.db.sql(f'DROP TABLE IF EXISTS {map_table}')
frappe.db.commit()
elapsed = time.time() - t_start
print(f"{label} complete [{elapsed:.1f}s]")
def set_series(prefix, start_at):
"""Set the naming series counter."""
existing = frappe.db.sql(
'SELECT current FROM "tabSeries" WHERE name = %s', (prefix,), as_dict=True
)
if existing:
if existing[0]["current"] < start_at:
frappe.db.sql('UPDATE "tabSeries" SET current = %s WHERE name = %s', (start_at, prefix))
print(f" Series {prefix} updated to {start_at}")
else:
print(f" Series {prefix} already at {existing[0]['current']}")
else:
frappe.db.sql('INSERT INTO "tabSeries" (name, current) VALUES (%s, %s)', (prefix, start_at))
print(f" Series {prefix} created at {start_at}")
frappe.db.commit()
# ═══════════════════════════════════════════════════════════════
# 1. SALES INVOICE
# ═══════════════════════════════════════════════════════════════
# Current: SINV-1 through SINV-99999 (legacy invoice.id)
# The 'name' is already SINV-{id}, we just need to zero-pad
# Legacy column: the name itself encodes the ID. We need to extract it.
# Actually, let's check if there's a legacy column...
# Sales invoices were imported as SINV-{legacy_id}. The name IS the legacy ref.
# We need to extract the number from the name and zero-pad.
# Approach: use a direct SQL rename since legacy_id is embedded in name.
print("\n" + "=" * 70)
print("RENAMING: Sales Invoice → SINV-0000XXXXXX")
print("=" * 70)
t_start = time.time()
if not DRY_RUN:
# Build mapping from current names
sinv_rows = frappe.db.sql("""
SELECT name FROM "tabSales Invoice" WHERE name ~ '^SINV-[0-9]+$'
""")
sinv_map = {}
for r in sinv_rows:
old = r[0]
num = int(old.replace("SINV-", ""))
new = f"SINV-{num:010d}"
if old != new:
sinv_map[old] = new
print(f" Will rename: {len(sinv_map)} invoices")
for old, new in list(sinv_map.items())[:5]:
print(f" {old:20s}{new}")
if sinv_map:
# Create temp mapping
frappe.db.sql('DROP TABLE IF EXISTS _rename_sinv')
frappe.db.sql("""
CREATE TEMP TABLE _rename_sinv (
old_name VARCHAR(140) PRIMARY KEY,
new_name VARCHAR(140) NOT NULL
)
""")
items = list(sinv_map.items())
for start in range(0, len(items), 1000):
batch = items[start:start + 1000]
frappe.db.sql(
"INSERT INTO _rename_sinv (old_name, new_name) VALUES " +
",".join(["(%s, %s)"] * len(batch)),
[v for pair in batch for v in pair]
)
frappe.db.commit()
# Update FK refs
sinv_fks = [
("tabSales Invoice Item", "parent", ""),
("tabSales Invoice Payment", "parent", ""),
("tabSales Taxes and Charges", "parent", "AND t.parenttype = 'Sales Invoice'"),
("tabGL Entry", "voucher_no", "AND t.voucher_type = 'Sales Invoice'"),
("tabPayment Ledger Entry", "voucher_no", "AND t.voucher_type = 'Sales Invoice'"),
("tabPayment Entry Reference", "reference_name", "AND t.reference_doctype = 'Sales Invoice'"),
("tabComment", "reference_name", "AND t.reference_doctype = 'Sales Invoice'"),
("tabVersion", "docname", "AND t.ref_doctype = 'Sales Invoice'"),
("tabDynamic Link", "link_name", "AND t.link_doctype = 'Sales Invoice'"),
]
for fk_table, fk_col, extra_where in sinv_fks:
t0 = time.time()
try:
frappe.db.sql(f"""
UPDATE "{fk_table}" t
SET "{fk_col}" = m.new_name
FROM _rename_sinv m
WHERE t."{fk_col}" = m.old_name
{extra_where}
""")
frappe.db.commit()
print(f" FK {fk_table}.{fk_col} [{time.time()-t0:.1f}s]")
except Exception as e:
frappe.db.rollback()
err = str(e)[:80]
if "does not exist" not in err:
print(f" FK {fk_table}.{fk_col} ERR: {err}")
# Two-phase rename
print(" Phase A...")
t0 = time.time()
for start in range(0, len(items), 500):
batch = items[start:start + 500]
cases = " ".join(f"WHEN '{old}' THEN '_T_{new}'" for old, new in batch)
old_names = "','".join(old for old, _ in batch)
frappe.db.sql(f"""
UPDATE "tabSales Invoice"
SET name = CASE name {cases} END
WHERE name IN ('{old_names}')
""")
if (start + 500) % 10000 < 500:
frappe.db.commit()
print(f" {min(start + 500, len(items))}/{len(items)}")
frappe.db.commit()
print(f" Phase A done [{time.time()-t0:.1f}s]")
print(" Phase B...")
t0 = time.time()
for start in range(0, len(items), 500):
batch = items[start:start + 500]
cases = " ".join(f"WHEN '_T_{new}' THEN '{new}'" for _, new in batch)
temp_names = "','".join(f"_T_{new}" for _, new in batch)
frappe.db.sql(f"""
UPDATE "tabSales Invoice"
SET name = CASE name {cases} END
WHERE name IN ('{temp_names}')
""")
if (start + 500) % 10000 < 500:
frappe.db.commit()
print(f" {min(start + 500, len(items))}/{len(items)}")
frappe.db.commit()
print(f" Phase B done [{time.time()-t0:.1f}s]")
frappe.db.sql('DROP TABLE IF EXISTS _rename_sinv')
frappe.db.commit()
print(f" ✓ Sales Invoice [{time.time()-t_start:.1f}s]")
else:
sinv_count = frappe.db.sql("SELECT COUNT(*) FROM \"tabSales Invoice\" WHERE name ~ '^SINV-[0-9]+$'")[0][0]
print(f" [DRY] Would rename ~{sinv_count} invoices")
# ═══════════════════════════════════════════════════════════════
# 2. PAYMENT ENTRY — same pattern as SINV
# ═══════════════════════════════════════════════════════════════
print("\n" + "=" * 70)
print("RENAMING: Payment Entry → PE-0000XXXXXX")
print("=" * 70)
t_start = time.time()
if not DRY_RUN:
pe_rows = frappe.db.sql("SELECT name FROM \"tabPayment Entry\" WHERE name ~ '^PE-[0-9]+$'")
pe_map = {}
for r in pe_rows:
old = r[0]
num = int(old.replace("PE-", ""))
new = f"PE-{num:010d}"
if old != new:
pe_map[old] = new
print(f" Will rename: {len(pe_map)} payments")
for old, new in list(pe_map.items())[:3]:
print(f" {old:20s}{new}")
if pe_map:
frappe.db.sql('DROP TABLE IF EXISTS _rename_pe')
frappe.db.sql("""
CREATE TEMP TABLE _rename_pe (
old_name VARCHAR(140) PRIMARY KEY, new_name VARCHAR(140) NOT NULL
)
""")
items = list(pe_map.items())
for start in range(0, len(items), 1000):
batch = items[start:start + 1000]
frappe.db.sql(
"INSERT INTO _rename_pe (old_name, new_name) VALUES " +
",".join(["(%s, %s)"] * len(batch)),
[v for pair in batch for v in pair]
)
frappe.db.commit()
pe_fks = [
("tabPayment Entry Reference", "parent", ""),
("tabGL Entry", "voucher_no", "AND t.voucher_type = 'Payment Entry'"),
("tabPayment Ledger Entry", "voucher_no", "AND t.voucher_type = 'Payment Entry'"),
("tabComment", "reference_name", "AND t.reference_doctype = 'Payment Entry'"),
("tabVersion", "docname", "AND t.ref_doctype = 'Payment Entry'"),
("tabDynamic Link", "link_name", "AND t.link_doctype = 'Payment Entry'"),
]
for fk_table, fk_col, extra_where in pe_fks:
t0 = time.time()
try:
frappe.db.sql(f"""
UPDATE "{fk_table}" t SET "{fk_col}" = m.new_name
FROM _rename_pe m WHERE t."{fk_col}" = m.old_name {extra_where}
""")
frappe.db.commit()
print(f" FK {fk_table}.{fk_col} [{time.time()-t0:.1f}s]")
except Exception as e:
frappe.db.rollback()
err = str(e)[:80]
if "does not exist" not in err:
print(f" FK {fk_table}.{fk_col} ERR: {err}")
# Two-phase rename
print(" Phase A...")
t0 = time.time()
for start in range(0, len(items), 500):
batch = items[start:start + 500]
cases = " ".join(f"WHEN '{old}' THEN '_T_{new}'" for old, new in batch)
old_names = "','".join(old for old, _ in batch)
frappe.db.sql(f'UPDATE "tabPayment Entry" SET name = CASE name {cases} END WHERE name IN (\'{old_names}\')')
if (start + 500) % 10000 < 500:
frappe.db.commit()
frappe.db.commit()
print(f" Phase A [{time.time()-t0:.1f}s]")
print(" Phase B...")
t0 = time.time()
for start in range(0, len(items), 500):
batch = items[start:start + 500]
cases = " ".join(f"WHEN '_T_{new}' THEN '{new}'" for _, new in batch)
temp_names = "','".join(f"_T_{new}" for _, new in batch)
frappe.db.sql(f'UPDATE "tabPayment Entry" SET name = CASE name {cases} END WHERE name IN (\'{temp_names}\')')
if (start + 500) % 10000 < 500:
frappe.db.commit()
frappe.db.commit()
print(f" Phase B [{time.time()-t0:.1f}s]")
frappe.db.sql('DROP TABLE IF EXISTS _rename_pe')
frappe.db.commit()
print(f" ✓ Payment Entry [{time.time()-t_start:.1f}s]")
else:
pe_count = frappe.db.sql("SELECT COUNT(*) FROM \"tabPayment Entry\" WHERE name ~ '^PE-[0-9]+$'")[0][0]
print(f" [DRY] Would rename ~{pe_count} payments")
# ═══════════════════════════════════════════════════════════════
# 3. ISSUE — has legacy_ticket_id column
# ═══════════════════════════════════════════════════════════════
rename_doctype(
label="Issue",
table="tabIssue",
prefix="ISS",
legacy_col="legacy_ticket_id",
fk_refs=[
("tabDispatch Job", "issue", ""),
("tabComment", "reference_name", "AND t.reference_doctype = 'Issue'"),
("tabVersion", "docname", "AND t.ref_doctype = 'Issue'"),
("tabCommunication", "reference_name", "AND t.reference_doctype = 'Issue'"),
("tabDynamic Link", "link_name", "AND t.link_doctype = 'Issue'"),
],
)
# ═══════════════════════════════════════════════════════════════
# 4. SERVICE LOCATION — has legacy_delivery_id column
# ═══════════════════════════════════════════════════════════════
rename_doctype(
label="Service Location",
table="tabService Location",
prefix="LOC",
legacy_col="legacy_delivery_id",
fk_refs=[
("tabService Subscription", "service_location", ""),
("tabSubscription", "service_location", ""),
("tabService Equipment", "service_location", ""),
("tabIssue", "service_location", ""),
("tabDispatch Job", "service_location", ""),
("tabComment", "reference_name", "AND t.reference_doctype = 'Service Location'"),
("tabDynamic Link", "link_name", "AND t.link_doctype = 'Service Location'"),
],
)
# ═══════════════════════════════════════════════════════════════
# 5. SERVICE EQUIPMENT — has legacy_device_id column
# ═══════════════════════════════════════════════════════════════
rename_doctype(
label="Service Equipment",
table="tabService Equipment",
prefix="EQP",
legacy_col="legacy_device_id",
fk_refs=[
("tabComment", "reference_name", "AND t.reference_doctype = 'Service Equipment'"),
("tabDynamic Link", "link_name", "AND t.link_doctype = 'Service Equipment'"),
],
)
# ═══════════════════════════════════════════════════════════════
# 6. SERVICE SUBSCRIPTION — has legacy_service_id column
# ═══════════════════════════════════════════════════════════════
rename_doctype(
label="Service Subscription",
table="tabService Subscription",
prefix="SUB",
legacy_col="legacy_service_id",
fk_refs=[
("tabComment", "reference_name", "AND t.reference_doctype = 'Service Subscription'"),
("tabDynamic Link", "link_name", "AND t.link_doctype = 'Service Subscription'"),
],
)
# ═══════════════════════════════════════════════════════════════
# 7. SUBSCRIPTION (native) — also has legacy_service_id
# ═══════════════════════════════════════════════════════════════
rename_doctype(
label="Subscription (native)",
table="tabSubscription",
prefix="ASUB",
legacy_col="legacy_service_id",
fk_refs=[
("tabSubscription Plan Detail", "parent", ""),
("tabComment", "reference_name", "AND t.reference_doctype = 'Subscription'"),
("tabDynamic Link", "link_name", "AND t.link_doctype = 'Subscription'"),
],
)
# ═══════════════════════════════════════════════════════════════
# SET NAMING SERIES COUNTERS
# ═══════════════════════════════════════════════════════════════
print("\n" + "=" * 70)
print("NAMING SERIES COUNTERS")
print("=" * 70)
if not DRY_RUN:
set_series("SINV-", 100000)
set_series("PE-", 100000)
set_series("ISS-", 250000) # max legacy=244240
set_series("LOC-", 100000)
set_series("EQP-", 100000)
set_series("SUB-", 100000) # max legacy=74184
set_series("ASUB-", 100000)
# ═══════════════════════════════════════════════════════════════
# VERIFICATION
# ═══════════════════════════════════════════════════════════════
print("\n" + "=" * 70)
print("VERIFICATION")
print("=" * 70)
checks = [
("tabSales Invoice", "SINV-"),
("tabPayment Entry", "PE-"),
("tabIssue", "ISS-"),
("tabService Location", "LOC-"),
("tabService Equipment", "EQP-"),
("tabService Subscription", "SUB-"),
("tabSubscription", "ASUB-"),
]
for table, prefix in checks:
try:
total = frappe.db.sql(f'SELECT COUNT(*) FROM "{table}"')[0][0]
correct = frappe.db.sql(f"""
SELECT COUNT(*) FROM "{table}"
WHERE name ~ %s
""", (f'^{prefix}[0-9]{{10}}$',))[0][0]
print(f" {table:30s} total={total:>8d} correct_format={correct:>8d} {'' if total == correct else f'({total - correct} remaining)'}")
except Exception as e:
frappe.db.rollback()
print(f" {table}: ERR {str(e)[:50]}")
# Spot checks
print("\n Spot checks:")
for table, field, value in [
("tabSales Invoice", "name", None),
("tabPayment Entry", "name", None),
("tabIssue", "name", None),
]:
sample = frappe.db.sql(f'SELECT name FROM "{table}" ORDER BY name LIMIT 3')
print(f" {table}: {[s[0] for s in sample]}")
# FK orphan checks
print("\n FK consistency:")
fk_checks = [
("tabSales Invoice Item", "parent", "tabSales Invoice"),
("tabPayment Entry Reference", "parent", "tabPayment Entry"),
("tabGL Entry", "voucher_no", None), # multi-type, skip
("tabSubscription Plan Detail", "parent", "tabSubscription"),
]
for fk_table, fk_col, parent_table in fk_checks:
if not parent_table:
continue
try:
orphans = frappe.db.sql(f"""
SELECT COUNT(*) FROM "{fk_table}" t
WHERE NOT EXISTS (SELECT 1 FROM "{parent_table}" p WHERE p.name = t."{fk_col}")
""")[0][0]
status = "" if orphans == 0 else f"WARNING: {orphans} orphaned!"
print(f" {fk_table}.{fk_col}{parent_table}: {status}")
except Exception as e:
frappe.db.rollback()
frappe.clear_cache()
print("\n✓ All done — cache cleared")
print(" Next: bench --site erp.gigafibre.ca clear-cache")

View File

@ -0,0 +1,329 @@
#!/usr/bin/env python3
"""
Rename all Customer IDs to CUST-{legacy_customer_id}.
Handles all FK references across the entire ERPNext database:
- Sales Invoice, Payment Entry, GL Entry, PLE, Issues, Locations,
Subscriptions, Equipment, Dispatch Jobs, Dynamic Links, Comments, etc.
Approach:
1. Build old_name new_name mapping from legacy_customer_id
2. Create temp mapping table in PostgreSQL
3. Bulk UPDATE each FK table using JOIN to mapping table
4. Rename Customer records themselves
5. Set naming series counter for new customers
6. Verify all references updated
Run inside erpnext-backend-1:
/home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/rename_customers.py [--dry-run]
Estimated time: 5-10 minutes (millions of rows to update)
"""
import sys
import os
import time
from datetime import datetime, timezone
os.chdir("/home/frappe/frappe-bench/sites")
import frappe
frappe.init(site="erp.gigafibre.ca", sites_path=".")
frappe.connect()
frappe.local.flags.ignore_permissions = True
print(f"Connected: {frappe.local.site}")
DRY_RUN = "--dry-run" in sys.argv
# Use legacy customer_id as the new name: CUST-{legacy_customer_id}
USE_LEGACY_CODE = "--legacy-code" in sys.argv
# Use zero-padded numeric: CUST-{legacy_account_id:015d}
USE_NUMERIC = "--numeric" in sys.argv
if not USE_LEGACY_CODE and not USE_NUMERIC:
USE_LEGACY_CODE = True # default
if DRY_RUN:
print("*** DRY RUN — no changes will be written ***")
mode_label = "legacy_customer_id" if USE_LEGACY_CODE else "zero-padded numeric"
print(f"Naming mode: {mode_label}")
# ═══════════════════════════════════════════════════════════════
# STEP 1: BUILD MAPPING
# ═══════════════════════════════════════════════════════════════
print("\n" + "=" * 70)
print("STEP 1: BUILD NAME MAPPING")
print("=" * 70)
t0 = time.time()
customers = frappe.db.sql("""
SELECT name, legacy_account_id, legacy_customer_id, customer_name
FROM "tabCustomer"
ORDER BY legacy_account_id
""", as_dict=True)
mapping = {} # old_name → new_name
collisions = []
new_names_used = set()
for c in customers:
old_name = c["name"]
if USE_LEGACY_CODE:
cid = c.get("legacy_customer_id") or ""
if cid:
new_name = f"CUST-{cid}"
else:
# Fallback to zero-padded account ID
new_name = f"CUST-{c['legacy_account_id']:015d}"
else:
new_name = f"CUST-{c['legacy_account_id']:015d}"
# Check for collision
if new_name in new_names_used:
collisions.append((old_name, new_name, c["customer_name"]))
# Append account ID to disambiguate
new_name = f"{new_name}-{c['legacy_account_id']}"
new_names_used.add(new_name)
if old_name != new_name:
mapping[old_name] = new_name
# Stats
unchanged = len(customers) - len(mapping)
print(f" Total customers: {len(customers)}")
print(f" Will rename: {len(mapping)}")
print(f" Already correct: {unchanged}")
print(f" Collisions resolved: {len(collisions)}")
if collisions:
print(f" Collision examples:")
for old, new, name in collisions[:5]:
print(f" {old}{new} ({name})")
# Show sample mappings
print(f"\n Sample renames:")
for old, new in list(mapping.items())[:10]:
cust = next((c for c in customers if c["name"] == old), {})
print(f" {old:25s}{new:35s} ({cust.get('customer_name', '')})")
if DRY_RUN:
print(f"\n [{time.time()-t0:.1f}s]")
print("\n*** DRY RUN complete — no changes made ***")
sys.exit(0)
# ═══════════════════════════════════════════════════════════════
# STEP 2: CREATE TEMP MAPPING TABLE
# ═══════════════════════════════════════════════════════════════
print("\n" + "=" * 70)
print("STEP 2: CREATE TEMP MAPPING TABLE")
print("=" * 70)
frappe.db.sql('DROP TABLE IF EXISTS _customer_rename_map')
frappe.db.sql("""
CREATE TEMP TABLE _customer_rename_map (
old_name VARCHAR(140) PRIMARY KEY,
new_name VARCHAR(140) NOT NULL
)
""")
# Insert in batches
batch = []
for old, new in mapping.items():
batch.append((old, new))
if len(batch) >= 1000:
frappe.db.sql(
"INSERT INTO _customer_rename_map (old_name, new_name) VALUES " +
",".join(["(%s, %s)"] * len(batch)),
[v for pair in batch for v in pair]
)
batch = []
if batch:
frappe.db.sql(
"INSERT INTO _customer_rename_map (old_name, new_name) VALUES " +
",".join(["(%s, %s)"] * len(batch)),
[v for pair in batch for v in pair]
)
frappe.db.commit()
print(f" Inserted {len(mapping)} mappings into temp table")
# ═══════════════════════════════════════════════════════════════
# STEP 3: UPDATE ALL FK REFERENCES
# ═══════════════════════════════════════════════════════════════
print("\n" + "=" * 70)
print("STEP 3: UPDATE FK REFERENCES")
print("=" * 70)
# All tables with customer references: (table, column, extra_where)
fk_tables = [
("tabSales Invoice", "customer", ""),
("tabPayment Entry", "party", ""),
("tabGL Entry", "party", ""),
("tabPayment Ledger Entry", "party", ""),
("tabIssue", "customer", ""),
("tabService Location", "customer", ""),
("tabService Subscription", "customer", ""),
("tabSubscription", "party", ""),
("tabService Equipment", "customer", ""),
("tabDispatch Job", "customer", ""),
("tabDynamic Link", "link_name", "AND t.link_doctype = 'Customer'"),
("tabComment", "reference_name", "AND t.reference_doctype = 'Customer'"),
("tabCommunication", "reference_name", "AND t.reference_doctype = 'Customer'"),
# Version log
("tabVersion", "docname", "AND t.ref_doctype = 'Customer'"),
# Sales taxes (parent references invoice, not customer directly)
# Payment Entry Reference has no direct customer column
]
total_updated = 0
for table, col, extra_where in fk_tables:
t0 = time.time()
try:
result = frappe.db.sql(f"""
UPDATE "{table}" t
SET "{col}" = m.new_name
FROM _customer_rename_map m
WHERE t."{col}" = m.old_name
{extra_where}
""")
# Get affected rows
affected = frappe.db.sql(f"""
SELECT COUNT(*) FROM "{table}" t
INNER JOIN _customer_rename_map m ON t."{col}" = m.old_name
{extra_where.replace('t.', f'"{table}".')}
""")
# Actually just count what we updated - the UPDATE already ran
frappe.db.commit()
elapsed = time.time() - t0
print(f" {table:35s} {col:20s} updated [{elapsed:.1f}s]")
total_updated += 1
except Exception as e:
frappe.db.rollback()
err_msg = str(e)[:80]
if "does not exist" in err_msg or "column" in err_msg:
pass # Table/column doesn't exist, skip
else:
print(f" {table:35s} {col:20s} ERR: {err_msg}")
print(f"\n Updated {total_updated} FK tables")
# ═══════════════════════════════════════════════════════════════
# STEP 4: RENAME CUSTOMER RECORDS THEMSELVES
# ═══════════════════════════════════════════════════════════════
print("\n" + "=" * 70)
print("STEP 4: RENAME CUSTOMER RECORDS")
print("=" * 70)
t0 = time.time()
# Must rename customer records — update the `name` column
# Do this AFTER FK updates to avoid FK constraint issues
frappe.db.sql("""
UPDATE "tabCustomer" t
SET name = m.new_name
FROM _customer_rename_map m
WHERE t.name = m.old_name
""")
frappe.db.commit()
print(f" Renamed {len(mapping)} customers [{time.time()-t0:.1f}s]")
# ═══════════════════════════════════════════════════════════════
# STEP 5: SET NAMING SERIES COUNTER
# ═══════════════════════════════════════════════════════════════
print("\n" + "=" * 70)
print("STEP 5: SET NAMING SERIES COUNTER")
print("=" * 70)
# For new customers, ERPNext will use naming_series.
# We need to make sure it doesn't collide.
# Since legacy customer_ids are alphanumeric, numeric auto-increment won't collide.
# But let's set a safe starting point anyway.
# Check if CUST- series exists
series = frappe.db.sql("""
SELECT name, current FROM "tabSeries" WHERE name = 'CUST-'
""", as_dict=True)
if series:
print(f" Current CUST- series: {series[0]['current']}")
else:
print(" No CUST- series found, creating...")
frappe.db.sql("""
INSERT INTO "tabSeries" (name, current) VALUES ('CUST-', 100000)
ON CONFLICT (name) DO UPDATE SET current = GREATEST("tabSeries".current, 100000)
""")
frappe.db.commit()
print(" Set CUST- counter to 100000")
# Note: The naming_series format should produce CUST-NNNNN or CUST-.#####
# New customers will get CUST-100001, CUST-100002, etc.
# ═══════════════════════════════════════════════════════════════
# STEP 6: CLEANUP TEMP TABLE
# ═══════════════════════════════════════════════════════════════
frappe.db.sql('DROP TABLE IF EXISTS _customer_rename_map')
frappe.db.commit()
# ═══════════════════════════════════════════════════════════════
# STEP 7: VERIFY
# ═══════════════════════════════════════════════════════════════
print("\n" + "=" * 70)
print("STEP 7: VERIFICATION")
print("=" * 70)
cust_count = frappe.db.sql('SELECT COUNT(*) FROM "tabCustomer"')[0][0]
print(f" Customers: {cust_count}")
# Check for any remaining hex-pattern IDs
hex_remaining = frappe.db.sql("""
SELECT COUNT(*) FROM "tabCustomer"
WHERE name ~ '^CUST-[0-9a-f]{8,}'
AND name !~ '^CUST-[0-9]+$'
""")[0][0]
print(f" Hex-pattern IDs remaining: {hex_remaining} (should be 0)")
# Spot checks
spots = [
(4, "LPB4"),
(2393, "Vegpro"),
(13814, "Vegpro 114796350603272"),
]
for legacy_id, label in spots:
c = frappe.db.sql("""
SELECT name, customer_name, legacy_customer_id
FROM "tabCustomer" WHERE legacy_account_id = %s
""", (legacy_id,), as_dict=True)
if c:
print(f" {label}: {c[0]['name']} ({c[0]['customer_name']})")
# Check FK consistency: any orphaned references?
for table, col in [("tabSales Invoice", "customer"), ("tabSubscription", "party"), ("tabIssue", "customer")]:
orphans = frappe.db.sql(f"""
SELECT COUNT(*) FROM "{table}" t
WHERE t."{col}" IS NOT NULL
AND t."{col}" != ''
AND NOT EXISTS (SELECT 1 FROM "tabCustomer" c WHERE c.name = t."{col}")
""")[0][0]
if orphans > 0:
print(f" WARNING: {orphans} orphaned references in {table}.{col}!")
else:
print(f" {table}.{col}: all references valid ✓")
# ═══════════════════════════════════════════════════════════════
# DONE
# ═══════════════════════════════════════════════════════════════
print("\n" + "=" * 70)
print("COMPLETE")
print("=" * 70)
print(" Next: bench --site erp.gigafibre.ca clear-cache")
frappe.clear_cache()
print(" Cache cleared")