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>
387 lines
15 KiB
Python
387 lines
15 KiB
Python
"""
|
|
Import missing services — categories excluded from the original migration.
|
|
|
|
Original migration (phase 3) only imported categories: 4,9,17,21,32,33
|
|
This imports ALL remaining active services from other categories.
|
|
|
|
For each missing service:
|
|
1. Ensure Item exists in ERPNext
|
|
2. Ensure Subscription Plan exists
|
|
3. Create Subscription linked to correct customer + service_location
|
|
|
|
Run inside erpnext-backend-1:
|
|
/home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/import_missing_services.py
|
|
"""
|
|
import frappe
|
|
import pymysql
|
|
import os
|
|
from datetime import datetime, timezone
|
|
|
|
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 data...")
|
|
with conn.cursor() as cur:
|
|
# All active services from excluded categories
|
|
cur.execute("""
|
|
SELECT s.id, s.delivery_id, s.product_id, s.status,
|
|
s.hijack, s.hijack_price, s.hijack_desc,
|
|
s.payment_recurrence, s.date_orig, s.date_next_invoice,
|
|
s.radius_user, s.radius_pwd,
|
|
p.sku, p.price as base_price, p.category,
|
|
d.account_id
|
|
FROM service s
|
|
JOIN product p ON p.id = s.product_id
|
|
JOIN delivery d ON d.id = s.delivery_id
|
|
WHERE s.status = 1
|
|
AND p.category NOT IN (4,9,17,21,32,33)
|
|
ORDER BY d.account_id, s.id
|
|
""")
|
|
missing_services = cur.fetchall()
|
|
|
|
# All products from these categories
|
|
cur.execute("""
|
|
SELECT DISTINCT p.id, p.sku, p.price, p.category
|
|
FROM product p
|
|
JOIN service s ON s.product_id = p.id
|
|
WHERE s.status = 1 AND p.category NOT IN (4,9,17,21,32,33)
|
|
""")
|
|
products = cur.fetchall()
|
|
|
|
# Category names (hardcoded from legacy)
|
|
categories = {
|
|
1: "Installation initiale", 7: "Location serveur", 8: "Location équipement",
|
|
11: "Nom de domaine", 13: "Location espace", 15: "Hébergement",
|
|
16: "Support", 23: "Hotspot camping", 26: "Installation et équipement fibre",
|
|
28: "Quotidien pro", 34: "Installation et équipement télé",
|
|
}
|
|
|
|
conn.close()
|
|
print("Missing active services: {}".format(len(missing_services)))
|
|
print("Distinct products: {}".format(len(products)))
|
|
|
|
# Show breakdown by category
|
|
cat_counts = {}
|
|
for s in missing_services:
|
|
cat = s["category"]
|
|
cat_name = categories.get(cat, "cat={}".format(cat))
|
|
if cat_name not in cat_counts:
|
|
cat_counts[cat_name] = 0
|
|
cat_counts[cat_name] += 1
|
|
for name, cnt in sorted(cat_counts.items(), key=lambda x: -x[1]):
|
|
print(" {}: {}".format(name, cnt))
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# LOAD ERPNEXT DATA
|
|
# ═══════════════════════════════════════════════════════════════
|
|
print("\nLoading ERPNext data...")
|
|
|
|
# Existing items
|
|
existing_items = set()
|
|
items = frappe.db.sql('SELECT name FROM "tabItem"', as_dict=True)
|
|
for i in items:
|
|
existing_items.add(i["name"])
|
|
print("Existing items: {}".format(len(existing_items)))
|
|
|
|
# Existing subscription plans
|
|
existing_plans = set()
|
|
plans = frappe.db.sql('SELECT plan_name FROM "tabSubscription Plan"', as_dict=True)
|
|
for p in plans:
|
|
existing_plans.add(p["plan_name"])
|
|
print("Existing plans: {}".format(len(existing_plans)))
|
|
|
|
# Item details: sku → {item_name, item_group}
|
|
item_details = {}
|
|
item_rows = frappe.db.sql('SELECT name, item_name, item_group FROM "tabItem"', as_dict=True)
|
|
for i in item_rows:
|
|
item_details[i["name"]] = {"item_name": i["item_name"], "item_group": i["item_group"]}
|
|
|
|
# Existing subscriptions by legacy_service_id
|
|
existing_subs = set()
|
|
subs = frappe.db.sql('SELECT legacy_service_id FROM "tabSubscription" WHERE legacy_service_id IS NOT NULL', as_dict=True)
|
|
for s in subs:
|
|
existing_subs.add(s["legacy_service_id"])
|
|
print("Existing subscriptions: {}".format(len(existing_subs)))
|
|
|
|
# Customer mapping: legacy_account_id → ERPNext customer name
|
|
cust_map = {}
|
|
custs = frappe.db.sql('SELECT name, legacy_account_id FROM "tabCustomer" WHERE legacy_account_id IS NOT NULL AND legacy_account_id > 0', as_dict=True)
|
|
for c in custs:
|
|
cust_map[c["legacy_account_id"]] = c["name"]
|
|
print("Customer mapping: {}".format(len(cust_map)))
|
|
|
|
# Service location mapping: look up by customer + delivery address
|
|
# We'll find the service_location from existing subscriptions for the same delivery
|
|
loc_map = {} # (account_id, delivery_id) → service_location from existing subs
|
|
loc_subs = frappe.db.sql("""
|
|
SELECT s.legacy_service_id, s.service_location, s.party
|
|
FROM "tabSubscription" s
|
|
WHERE s.service_location IS NOT NULL AND s.legacy_service_id IS NOT NULL
|
|
""", as_dict=True)
|
|
# Build delivery → location map from legacy
|
|
with pymysql.connect(host="legacy-db", user="facturation", password="VD67owoj",
|
|
database="gestionclient", cursorclass=pymysql.cursors.DictCursor) as conn2:
|
|
with conn2.cursor() as cur2:
|
|
cur2.execute("""
|
|
SELECT s.id as service_id, s.delivery_id, d.account_id
|
|
FROM service s
|
|
JOIN delivery d ON d.id = s.delivery_id
|
|
WHERE s.status = 1
|
|
""")
|
|
svc_delivery = {r["service_id"]: (r["account_id"], r["delivery_id"]) for r in cur2.fetchall()}
|
|
|
|
# Map delivery_id → service_location from existing subscriptions
|
|
delivery_loc = {}
|
|
for ls in loc_subs:
|
|
sid = ls["legacy_service_id"]
|
|
if sid in svc_delivery:
|
|
acct, did = svc_delivery[sid]
|
|
if did not in delivery_loc and ls["service_location"]:
|
|
delivery_loc[did] = ls["service_location"]
|
|
print("Delivery→location mappings: {}".format(len(delivery_loc)))
|
|
|
|
# Category to item_group mapping
|
|
CATEGORY_GROUP = {
|
|
26: "Installation et équipement fibre",
|
|
34: "Installation et équipement télé",
|
|
8: "Installation et équipement fibre",
|
|
15: "Hébergement",
|
|
11: "Nom de domaine",
|
|
13: "Installation et équipement fibre",
|
|
1: "Installation et équipement fibre",
|
|
16: "Installation et équipement fibre",
|
|
28: "Installation et équipement fibre",
|
|
7: "Installation et équipement fibre",
|
|
23: "Installation et équipement fibre",
|
|
}
|
|
|
|
TAX_TEMPLATE = "Canada - Résidentiel - TC"
|
|
COMPANY = "Targo Communications"
|
|
|
|
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
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# PREPARE: Items and Plans
|
|
# ═══════════════════════════════════════════════════════════════
|
|
print("\n" + "=" * 70)
|
|
print("ITEMS & PLANS")
|
|
print("=" * 70)
|
|
|
|
items_to_create = []
|
|
plans_to_create = []
|
|
|
|
for p in products:
|
|
sku = p["sku"]
|
|
if sku not in existing_items:
|
|
items_to_create.append({
|
|
"sku": sku,
|
|
"price": float(p["price"]),
|
|
"category": p["category"],
|
|
"group": CATEGORY_GROUP.get(p["category"], "Installation et équipement fibre"),
|
|
})
|
|
|
|
plan_name = "PLAN-" + sku
|
|
if plan_name not in existing_plans:
|
|
plans_to_create.append({
|
|
"plan_name": plan_name,
|
|
"item": sku,
|
|
"cost": float(p["price"]),
|
|
})
|
|
|
|
print("Items to create: {}".format(len(items_to_create)))
|
|
for i in items_to_create:
|
|
print(" {} @ {:.2f} → {}".format(i["sku"], i["price"], i["group"]))
|
|
|
|
print("Plans to create: {}".format(len(plans_to_create)))
|
|
for p in plans_to_create:
|
|
print(" {} (item: {}, cost: {:.2f})".format(p["plan_name"], p["item"], p["cost"]))
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# PREPARE: Subscriptions
|
|
# ═══════════════════════════════════════════════════════════════
|
|
print("\n" + "=" * 70)
|
|
print("SUBSCRIPTIONS")
|
|
print("=" * 70)
|
|
|
|
subs_to_create = []
|
|
skipped = {"no_customer": 0, "already_exists": 0, "no_location": 0}
|
|
|
|
for s in missing_services:
|
|
sid = s["id"]
|
|
if sid in existing_subs:
|
|
skipped["already_exists"] += 1
|
|
continue
|
|
|
|
acct_id = s["account_id"]
|
|
cust_name = cust_map.get(acct_id)
|
|
if not cust_name:
|
|
skipped["no_customer"] += 1
|
|
continue
|
|
|
|
sku = s["sku"]
|
|
plan_name = "PLAN-" + sku
|
|
delivery_id = s["delivery_id"]
|
|
service_location = delivery_loc.get(delivery_id)
|
|
|
|
if not service_location:
|
|
skipped["no_location"] += 1
|
|
# Still create — just without location
|
|
pass
|
|
|
|
price = float(s["hijack_price"]) if s["hijack"] else float(s["base_price"])
|
|
freq = "A" if s["payment_recurrence"] == 0 else "M"
|
|
|
|
start_date = ts_to_date(s["date_orig"]) or "2020-01-01"
|
|
|
|
idet = item_details.get(sku, {})
|
|
subs_to_create.append({
|
|
"legacy_id": sid,
|
|
"customer": cust_name,
|
|
"plan": plan_name,
|
|
"sku": sku,
|
|
"price": price,
|
|
"freq": freq,
|
|
"start_date": start_date,
|
|
"service_location": service_location,
|
|
"hijack_desc": s["hijack_desc"] or "",
|
|
"category": s["category"],
|
|
"item_name": idet.get("item_name", sku),
|
|
"item_group": idet.get("item_group", ""),
|
|
})
|
|
|
|
print("Subscriptions to create: {}".format(len(subs_to_create)))
|
|
print("Skipped: {}".format(skipped))
|
|
|
|
# Show samples by category
|
|
for cat_name in sorted(cat_counts.keys(), key=lambda x: -cat_counts[x]):
|
|
samples = [s for s in subs_to_create if categories.get(s["category"]) == cat_name][:3]
|
|
if samples:
|
|
print("\n {} ({} total):".format(cat_name, sum(1 for s in subs_to_create if categories.get(s["category"]) == cat_name)))
|
|
for s in samples:
|
|
print(" svc#{} {} {} {:.2f} → {} at {}".format(
|
|
s["legacy_id"], s["sku"], s["freq"], s["price"],
|
|
s["customer"], s["service_location"] or "NO_LOC"))
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# APPLY
|
|
# ═══════════════════════════════════════════════════════════════
|
|
if DRY_RUN:
|
|
print("\n*** DRY RUN — no changes made ***")
|
|
print("Set DRY_RUN = False to create {} items, {} plans, {} subscriptions".format(
|
|
len(items_to_create), len(plans_to_create), len(subs_to_create)))
|
|
else:
|
|
print("\n" + "=" * 70)
|
|
print("APPLYING CHANGES")
|
|
print("=" * 70)
|
|
|
|
# Step 1: Create missing Items
|
|
for i in items_to_create:
|
|
try:
|
|
item_groups = frappe.db.sql('SELECT name FROM "tabItem Group" WHERE name = %s', (i["group"],))
|
|
if not item_groups:
|
|
i["group"] = "All Item Groups"
|
|
|
|
doc = frappe.get_doc({
|
|
"doctype": "Item",
|
|
"item_code": i["sku"],
|
|
"item_name": i["sku"],
|
|
"item_group": i["group"],
|
|
"stock_uom": "Nos",
|
|
"is_stock_item": 0,
|
|
})
|
|
doc.insert(ignore_permissions=True)
|
|
print(" Created item: {}".format(i["sku"]))
|
|
except Exception as e:
|
|
print(" ERR item {}: {}".format(i["sku"], str(e)[:100]))
|
|
frappe.db.commit()
|
|
|
|
# Step 2: Create missing Subscription Plans
|
|
for p in plans_to_create:
|
|
try:
|
|
doc = frappe.get_doc({
|
|
"doctype": "Subscription Plan",
|
|
"plan_name": p["plan_name"],
|
|
"item": p["item"],
|
|
"price_determination": "Fixed Rate",
|
|
"cost": p["cost"],
|
|
"currency": "CAD",
|
|
"billing_interval": "Month",
|
|
"billing_interval_count": 1,
|
|
})
|
|
doc.insert(ignore_permissions=True)
|
|
print(" Created plan: {}".format(p["plan_name"]))
|
|
except Exception as e:
|
|
print(" ERR plan {}: {}".format(p["plan_name"], str(e)[:100]))
|
|
frappe.db.commit()
|
|
|
|
# Step 3: Create subscriptions
|
|
created = 0
|
|
errors = 0
|
|
for s in subs_to_create:
|
|
try:
|
|
sub = frappe.get_doc({
|
|
"doctype": "Subscription",
|
|
"party_type": "Customer",
|
|
"party": s["customer"],
|
|
"company": COMPANY,
|
|
"status": "Active",
|
|
"start_date": s["start_date"],
|
|
"generate_invoice_at": "Beginning of the current subscription period",
|
|
"days_until_due": 30,
|
|
"follow_calendar_months": 1,
|
|
"generate_new_invoices_past_due_date": 1,
|
|
"submit_invoice": 0,
|
|
"cancel_at_period_end": 0,
|
|
"legacy_service_id": s["legacy_id"],
|
|
"service_location": s["service_location"],
|
|
"actual_price": s["price"],
|
|
"custom_description": s["hijack_desc"] if s["hijack_desc"] else None,
|
|
"item_code": s["sku"],
|
|
"item_name": s["item_name"],
|
|
"item_group": s["item_group"],
|
|
"billing_frequency": s["freq"],
|
|
"plans": [{
|
|
"plan": s["plan"],
|
|
"qty": 1,
|
|
}],
|
|
})
|
|
sub.flags.ignore_validate = True
|
|
sub.flags.ignore_links = True
|
|
sub.insert(ignore_permissions=True)
|
|
created += 1
|
|
|
|
if created % 500 == 0:
|
|
frappe.db.commit()
|
|
print(" Progress: {}/{}".format(created, len(subs_to_create)))
|
|
|
|
except Exception as e:
|
|
errors += 1
|
|
if errors <= 20:
|
|
print(" ERR svc#{}: {}".format(s["legacy_id"], str(e)[:150]))
|
|
|
|
frappe.db.commit()
|
|
print("\nCreated: {} subscriptions".format(created))
|
|
print("Errors: {}".format(errors))
|
|
|
|
print("\n" + "=" * 70)
|
|
print("DONE")
|
|
print("=" * 70)
|