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:
parent
7d7b4fdb06
commit
4a8718f67c
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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: Actif→Active, 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',
|
||||
|
|
|
|||
491
scripts/migration/reconcile_subscriptions.py
Normal file
491
scripts/migration/reconcile_subscriptions.py
Normal 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()
|
||||
423
scripts/migration/reimport_subscriptions.py
Normal file
423
scripts/migration/reimport_subscriptions.py
Normal 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()
|
||||
581
scripts/migration/rename_all_doctypes.py
Normal file
581
scripts/migration/rename_all_doctypes.py
Normal 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")
|
||||
329
scripts/migration/rename_customers.py
Normal file
329
scripts/migration/rename_customers.py
Normal 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")
|
||||
Loading…
Reference in New Issue
Block a user