Backend services: - targo-hub: extract deepGetValue to helpers.js, DRY disconnect reasons lookup map, compact CAPABILITIES, consolidate vision.js prompts/schemas, extract dispatch scoring weights, trim section dividers across 9 files - modem-bridge: extract getSession() helper (6 occurrences), resetIdleTimer(), consolidate DM query factory, fix duplicate username fill bug, trim headers (server.js -36%, tplink-session.js -47%, docker-compose.yml -57%) Frontend: - useWifiDiagnostic: extract THRESHOLDS const, split processDiagnostic into 6 focused helpers (processOnlineStatus, processWanIPs, processRadios, processMeshNodes, processClients, checkRadioIssues) - EquipmentDetail: merge duplicate ROLE_LABELS, remove verbose comments Documentation (17 → 13 files, -1,400 lines): - New consolidated README.md (architecture, services, dependencies, auth) - Merge ECOSYSTEM-OVERVIEW into ARCHITECTURE.md - Merge MIGRATION-PLAN + ARCHITECTURE-COMPARE + FIELD-GAP + CHANGELOG → MIGRATION.md - Merge COMPETITIVE-ANALYSIS into PLATFORM-STRATEGY.md - Update ROADMAP.md with current phase status - Delete CONTEXT.md (absorbed into README) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
273 lines
11 KiB
Python
273 lines
11 KiB
Python
"""
|
|
Fix annual subscription billing dates.
|
|
|
|
For each annual subscription (billing_frequency='A'):
|
|
1. Create an annual copy of its subscription plan (billing_interval=Year)
|
|
2. Re-link the subscription to the annual plan
|
|
3. Set current_invoice_start/end from legacy date_next_invoice
|
|
4. ERPNext scheduler uses plan's billing_interval to determine period length
|
|
|
|
Run inside erpnext-backend-1:
|
|
/home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/fix_annual_billing_dates.py
|
|
"""
|
|
import frappe
|
|
import pymysql
|
|
import os
|
|
from datetime import datetime, timezone, timedelta
|
|
|
|
DRY_RUN = False
|
|
|
|
os.chdir("/home/frappe/frappe-bench/sites")
|
|
frappe.init(site="erp.gigafibre.ca", sites_path=".")
|
|
frappe.connect()
|
|
print("Connected:", frappe.local.site)
|
|
|
|
conn = pymysql.connect(
|
|
host="legacy-db", user="facturation", password="VD67owoj",
|
|
database="gestionclient", cursorclass=pymysql.cursors.DictCursor
|
|
)
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# LOAD LEGACY DATA
|
|
# ═══════════════════════════════════════════════════════════════
|
|
print("Loading legacy annual services...")
|
|
with conn.cursor() as cur:
|
|
cur.execute("""
|
|
SELECT s.id, s.date_next_invoice, s.date_orig, s.payment_recurrence,
|
|
s.hijack_price, s.hijack, p.price as base_price, p.sku
|
|
FROM service s
|
|
JOIN product p ON p.id = s.product_id
|
|
WHERE s.status = 1 AND s.payment_recurrence = 0
|
|
""")
|
|
annual_services = cur.fetchall()
|
|
conn.close()
|
|
print("Annual services in legacy: {}".format(len(annual_services)))
|
|
|
|
legacy_annual = {s["id"]: s for s in annual_services}
|
|
|
|
def ts_to_date(ts):
|
|
if not ts or ts <= 0:
|
|
return None
|
|
try:
|
|
return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d")
|
|
except (ValueError, OSError):
|
|
return None
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# LOAD ANNUAL SUBSCRIPTIONS FROM ERPNEXT
|
|
# ═══════════════════════════════════════════════════════════════
|
|
print("\nLoading annual subscriptions from ERPNext...")
|
|
annual_subs = frappe.db.sql("""
|
|
SELECT s.name, s.legacy_service_id, s.party, s.status,
|
|
s.current_invoice_start, s.current_invoice_end,
|
|
s.billing_frequency, s.start_date,
|
|
spd.plan, spd.name as spd_name
|
|
FROM "tabSubscription" s
|
|
LEFT JOIN "tabSubscription Plan Detail" spd ON spd.parent = s.name
|
|
WHERE s.billing_frequency = 'A'
|
|
ORDER BY s.name
|
|
""", as_dict=True)
|
|
print("Annual subscriptions: {}".format(len(annual_subs)))
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# LOAD EXISTING PLANS — need to create annual copies
|
|
# ═══════════════════════════════════════════════════════════════
|
|
plan_names = set(s["plan"] for s in annual_subs if s["plan"])
|
|
print("Distinct plans used by annual subs: {}".format(len(plan_names)))
|
|
|
|
existing_plans = {}
|
|
if plan_names:
|
|
placeholders = ",".join(["%s"] * len(plan_names))
|
|
plans = frappe.db.sql("""
|
|
SELECT name, plan_name, billing_interval, billing_interval_count,
|
|
cost, currency, item, price_determination, price_list
|
|
FROM "tabSubscription Plan"
|
|
WHERE plan_name IN ({})
|
|
""".format(placeholders), list(plan_names), as_dict=True)
|
|
existing_plans = {p["plan_name"]: p for p in plans}
|
|
for p in plans:
|
|
print(" {} — {}, cost={}, interval={} x{}".format(
|
|
p["plan_name"], p["item"], p["cost"], p["billing_interval"], p["billing_interval_count"]))
|
|
|
|
# Check which annual plan copies already exist
|
|
all_annual_plans = frappe.db.sql("""
|
|
SELECT name, plan_name FROM "tabSubscription Plan"
|
|
WHERE billing_interval = 'Year'
|
|
""", as_dict=True)
|
|
existing_annual_names = {p["plan_name"] for p in all_annual_plans}
|
|
print("\nExisting annual plans: {}".format(len(all_annual_plans)))
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# BUILD UPDATES
|
|
# ═══════════════════════════════════════════════════════════════
|
|
print("\n" + "=" * 70)
|
|
print("BUILDING UPDATES")
|
|
print("=" * 70)
|
|
|
|
updates = []
|
|
issues = []
|
|
plans_to_create = {} # monthly_plan_name → annual_plan_name
|
|
|
|
for sub in annual_subs:
|
|
sid = sub["legacy_service_id"]
|
|
legacy = legacy_annual.get(sid)
|
|
|
|
if not legacy:
|
|
issues.append((sub["name"], sid, "No legacy service found"))
|
|
continue
|
|
|
|
# Determine billing period from legacy
|
|
next_inv_date = ts_to_date(legacy["date_next_invoice"])
|
|
start_date = ts_to_date(legacy["date_orig"])
|
|
|
|
if next_inv_date:
|
|
inv_start_dt = datetime.strptime(next_inv_date, "%Y-%m-%d")
|
|
today_dt = datetime.now()
|
|
# Roll forward past dates to next cycle
|
|
while inv_start_dt < today_dt - timedelta(days=365):
|
|
inv_start_dt = inv_start_dt.replace(year=inv_start_dt.year + 1)
|
|
inv_start = inv_start_dt.strftime("%Y-%m-%d")
|
|
inv_end = (inv_start_dt.replace(year=inv_start_dt.year + 1) - timedelta(days=1)).strftime("%Y-%m-%d")
|
|
elif start_date:
|
|
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
|
|
today_dt = datetime.now()
|
|
candidate = start_dt.replace(year=today_dt.year)
|
|
if candidate < today_dt:
|
|
candidate = candidate.replace(year=today_dt.year + 1)
|
|
inv_start = candidate.strftime("%Y-%m-%d")
|
|
inv_end = (candidate.replace(year=candidate.year + 1) - timedelta(days=1)).strftime("%Y-%m-%d")
|
|
else:
|
|
issues.append((sub["name"], sid, "No dates in legacy"))
|
|
continue
|
|
|
|
# Figure out the annual plan name: PLAN-AN-{SKU}
|
|
monthly_plan = sub["plan"]
|
|
if monthly_plan:
|
|
# Extract SKU from plan name (PLAN-FTTH80I → FTTH80I)
|
|
sku_part = monthly_plan.replace("PLAN-", "")
|
|
annual_plan = "PLAN-AN-" + sku_part
|
|
if monthly_plan not in plans_to_create and annual_plan not in existing_annual_names:
|
|
plans_to_create[monthly_plan] = annual_plan
|
|
else:
|
|
annual_plan = None
|
|
|
|
updates.append({
|
|
"sub_name": sub["name"],
|
|
"spd_name": sub["spd_name"],
|
|
"legacy_id": sid,
|
|
"party": sub["party"],
|
|
"monthly_plan": monthly_plan,
|
|
"annual_plan": annual_plan,
|
|
"inv_start": inv_start,
|
|
"inv_end": inv_end,
|
|
"sku": legacy.get("sku", "?"),
|
|
"price": float(legacy["hijack_price"]) if legacy["hijack"] else float(legacy["base_price"] or 0),
|
|
})
|
|
|
|
print("\nUpdates to apply: {}".format(len(updates)))
|
|
print("Annual plans to create: {}".format(len(plans_to_create)))
|
|
print("Issues: {}".format(len(issues)))
|
|
|
|
# Show plans to create
|
|
for monthly, annual in plans_to_create.items():
|
|
orig = existing_plans.get(monthly, {})
|
|
print(" {} → {} (item: {}, cost: {})".format(
|
|
monthly, annual, orig.get("item", "?"), orig.get("cost", "?")))
|
|
|
|
# Show sample updates
|
|
print("\nSample updates:")
|
|
for u in updates[:15]:
|
|
print(" {} svc#{} {} — {} → period {}/{} plan:{} (${:.2f})".format(
|
|
u["sub_name"], u["legacy_id"], u["sku"], u["party"],
|
|
u["inv_start"], u["inv_end"], u["annual_plan"] or "NONE", u["price"]))
|
|
|
|
if issues:
|
|
print("\nIssues:")
|
|
for name, sid, reason in issues[:10]:
|
|
print(" {} svc#{}: {}".format(name, sid, reason))
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# APPLY
|
|
# ═══════════════════════════════════════════════════════════════
|
|
if DRY_RUN:
|
|
print("\n*** DRY RUN — no changes made ***")
|
|
print("Set DRY_RUN = False to apply {} updates + {} new plans".format(
|
|
len(updates), len(plans_to_create)))
|
|
else:
|
|
print("\n" + "=" * 70)
|
|
print("APPLYING CHANGES")
|
|
print("=" * 70)
|
|
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
|
|
# Step 0: Fix existing plan names — migration stored SP-xxx but autoname expects plan_name
|
|
# The SPD plan field already stores plan_name, so name must match for Frappe ORM to work
|
|
mismatched = frappe.db.sql("""
|
|
SELECT name, plan_name FROM "tabSubscription Plan"
|
|
WHERE name != plan_name
|
|
""", as_dict=True)
|
|
if mismatched:
|
|
print("Fixing {} plan names (SP-xxx → plan_name)...".format(len(mismatched)))
|
|
for p in mismatched:
|
|
frappe.db.sql("""
|
|
UPDATE "tabSubscription Plan" SET name = %s WHERE name = %s
|
|
""", (p["plan_name"], p["name"]))
|
|
frappe.db.commit()
|
|
print(" Done — plan names now match plan_name field")
|
|
|
|
# Step 1: Create annual plan copies
|
|
for monthly_name, annual_name in plans_to_create.items():
|
|
orig = existing_plans.get(monthly_name)
|
|
if not orig:
|
|
print(" SKIP {} — original plan not found".format(monthly_name))
|
|
continue
|
|
|
|
try:
|
|
plan = frappe.get_doc({
|
|
"doctype": "Subscription Plan",
|
|
"plan_name": annual_name,
|
|
"item": orig["item"],
|
|
"price_determination": orig.get("price_determination") or "Fixed Rate",
|
|
"cost": orig["cost"],
|
|
"currency": orig.get("currency") or "CAD",
|
|
"billing_interval": "Year",
|
|
"billing_interval_count": 1,
|
|
})
|
|
plan.insert(ignore_permissions=True)
|
|
print(" Created annual plan: {} (item: {}, cost: {})".format(
|
|
annual_name, orig["item"], orig["cost"]))
|
|
except Exception as e:
|
|
print(" ERR creating plan {}: {}".format(annual_name, str(e)[:100]))
|
|
|
|
frappe.db.commit()
|
|
|
|
# Step 2: Update subscriptions — billing dates + re-link to annual plan
|
|
updated = 0
|
|
plan_switched = 0
|
|
for u in updates:
|
|
# Set billing dates
|
|
frappe.db.sql("""
|
|
UPDATE "tabSubscription"
|
|
SET current_invoice_start = %s,
|
|
current_invoice_end = %s
|
|
WHERE name = %s
|
|
""", (u["inv_start"], u["inv_end"], u["sub_name"]))
|
|
|
|
# Switch plan in Subscription Plan Detail
|
|
if u["annual_plan"] and u["spd_name"]:
|
|
frappe.db.sql("""
|
|
UPDATE "tabSubscription Plan Detail"
|
|
SET plan = %s
|
|
WHERE name = %s
|
|
""", (u["annual_plan"], u["spd_name"]))
|
|
plan_switched += 1
|
|
|
|
updated += 1
|
|
|
|
frappe.db.commit()
|
|
print("\nUpdated {} subscriptions with billing dates".format(updated))
|
|
print("Switched {} subscriptions to annual plans".format(plan_switched))
|
|
|
|
print("\n" + "=" * 70)
|
|
print("DONE")
|
|
print("=" * 70)
|