Three legacy data-quality issues that were leaking into ERPNext on every import run. Caught while auditing C-LPB4's mis-pinned dispatch job. 1. **Postal code embedded in address_line.** Legacy `gestionclient` had rows like `2200-3 chemin de la riviere de la guerre J0S1B0` with the postal code concatenated at the end (and the same code repeated in the dedicated zip column). Caused 48-char address_line on what should have been a 39-char address. Now stripped at import: a regex matches `\\s+<FSA><LDU>\\s*$` (with or without space) and removes it; the dedicated postal_code field carries the canonical form. 2. **Abbreviations + Cobol-style capitalization.** Legacy stored `2066 Ch De La 1Re-Concession` instead of the canonical `2066 Chemin de la 1re-Concession`. ABBREV_MAP expands `Ch` → `Chemin`, `Av` → `Avenue`, `Bd`/`Boul` → `Boulevard`, `Rte` → `Route`, `St-` → `Saint-`, `Ste-` → `Sainte-`, `Mtl` → `Montréal`. Title-casing rule preserves French articles lowercase (`de`, `du`, `des`, `la`, `le`, `les`, `au`, `aux`, `à`, `et`, `sur`, `en`) and ordinal markers (`1re`, `2e`, `3e`). 96 SLs in production had the `1Re-Concession` style; they'll be re-normalized on next migration run. 3. **`connection_type` left empty even when ONT/CPE devices existed.** Pre-loads device→delivery mapping at import start; if the legacy delivery has any device whose category/name/model contains "ont", "onu", "cpe", "fibre", "gpon", or "ftth", we set connection_type='Fibre FTTH'. Without devices on file, the field stays empty (rep fills it later) — we don't guess. 4. **`postal_code` normalized too** — `j0s1b0` → `J0S 1B0` (uppercase + canonical space). Was being inserted in lowercase no-space form. Self-tested on 8 representative cases including the actual broken records found in production (LOC-15903, LOC-6227, LOC-4 / C-LPB4). These changes affect only re-imports of locations. Existing data needs a separate backfill script — a follow-up will cover that either as a one-shot migration or by running the existing `reimport_subscriptions.py` after this script.
656 lines
24 KiB
Python
656 lines
24 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Migrate legacy delivery → Service Location, device → Service Equipment.
|
|
Then link existing Subscriptions and Issues to their Service Location.
|
|
|
|
Dependencies: migrate_all.py must have run first (Customers, Subscriptions, Issues exist).
|
|
|
|
Run inside erpnext-backend-1:
|
|
nohup python3 /tmp/migrate_locations.py > /tmp/migrate_locations.log 2>&1 &
|
|
tail -f /tmp/migrate_locations.log
|
|
|
|
Phase 1: Add legacy_delivery_id custom field + column to Service Location
|
|
Phase 2: Import deliveries → Service Location
|
|
Phase 3: Import devices → Service Equipment
|
|
Phase 4: Link Subscriptions → Service Location (via legacy service.delivery_id)
|
|
Phase 5: Link Issues → Service Location (via legacy ticket.delivery_id)
|
|
"""
|
|
import pymysql
|
|
import psycopg2
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from html import unescape
|
|
|
|
LEGACY = {"host": "legacy-db", "user": "facturation", "password": "VD67owoj",
|
|
"database": "gestionclient", "connect_timeout": 30, "read_timeout": 600}
|
|
PG = {"host": "db", "port": 5432, "user": "postgres", "password": "123",
|
|
"dbname": "_eb65bdc0c4b1b2d6"}
|
|
|
|
ADMIN = "Administrator"
|
|
|
|
# Legacy device category → ERPNext equipment_type
|
|
DEVICE_TYPE_MAP = {
|
|
"cpe": "ONT",
|
|
"ont": "ONT",
|
|
"onu": "ONT",
|
|
"modem": "Modem",
|
|
"routeur": "Routeur",
|
|
"router": "Routeur",
|
|
"switch": "Switch",
|
|
"ap": "AP WiFi",
|
|
"access point": "AP WiFi",
|
|
"decodeur": "Decodeur TV",
|
|
"stb": "Decodeur TV",
|
|
"telephone": "Telephone IP",
|
|
"ata": "Telephone IP",
|
|
"amplificateur": "Amplificateur",
|
|
}
|
|
|
|
def uid(prefix=""):
|
|
return prefix + uuid.uuid4().hex[:10]
|
|
|
|
def ts():
|
|
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f")
|
|
|
|
def clean(val):
|
|
if not val:
|
|
return ""
|
|
return unescape(str(val)).strip()
|
|
|
|
|
|
# ── Address normalization helpers ─────────────────────────────────────────
|
|
# These exist because the legacy `gestionclient` data is full of:
|
|
# - postal codes embedded in address_line (e.g. "12 rue X J0S1B0")
|
|
# - abbreviations ("Ch" instead of "Chemin", "Av." for "Avenue")
|
|
# - Cobol-style capitalization ("1Re-Concession" instead of "1re-Concession")
|
|
# - lowercase or no-space postal codes ("j0s1b0" vs "J0S 1B0")
|
|
# Without normalization these break the RQA address validator (no exact
|
|
# match), Mapbox geocoder fuzzy results, and the human eye on customer
|
|
# cards. We catch them at the import step so re-runs converge on the
|
|
# canonical form.
|
|
import re
|
|
|
|
# Common French / Quebec street-type abbreviations seen in legacy data.
|
|
# Order matters: longer keys first so "Bd." matches before "B."
|
|
# Pattern intent: match the abbreviation as a full word ("\b...\b"),
|
|
# THEN optionally swallow a trailing period. Without the trailing
|
|
# word-boundary inside, "Boul." would match only "Boul" and leave
|
|
# the dot behind ("Boulevard."). The order is the dot AFTER the
|
|
# inner \b so the whole-word check happens first.
|
|
ABBREV_MAP = [
|
|
(r'\bCh\b\.?', 'Chemin'),
|
|
(r'\bRte\b\.?', 'Route'),
|
|
(r'\bAv\b\.?', 'Avenue'),
|
|
(r'\bBd\b\.?', 'Boulevard'),
|
|
(r'\bBoul\b\.?', 'Boulevard'),
|
|
(r'\bSt[\.-]', 'Saint-'),
|
|
(r'\bSte[\.-]', 'Sainte-'),
|
|
(r'\bMtl\b\.?', 'Montréal'),
|
|
]
|
|
|
|
# Words that should stay lowercase even when title-casing the address.
|
|
# RQA standard puts ordinal markers ("1re", "2e", "3e") in lowercase.
|
|
LOWER_WORDS = {
|
|
'de', 'du', 'des', 'la', 'le', 'les', 'l\'', 'd\'',
|
|
'au', 'aux', 'à', # Rivière-aux-Outardes, Pointe-au-Chêne
|
|
'et', 'sur', # Saint-Pierre-et-Miquelon, Bois-sur-Rivière
|
|
'en', # Cap-en-Haut
|
|
}
|
|
ORDINAL_RE = re.compile(r'^(\d+)(re|er|e|ere|eme|ème)$', re.I)
|
|
|
|
|
|
def normalize_postal_code(pc):
|
|
"""Uppercase + strip + insert canonical space: 'j0s1b0' → 'J0S 1B0'."""
|
|
if not pc:
|
|
return None
|
|
s = re.sub(r'\s+', '', str(pc)).upper()
|
|
if not re.match(r'^[A-Z]\d[A-Z]\d[A-Z]\d$', s):
|
|
return s or None # malformed; pass through so the rep sees it
|
|
return s[:3] + ' ' + s[3:]
|
|
|
|
|
|
def clean_address_line(raw, postal_code=None):
|
|
"""Normalize a legacy address_line for ingestion into Service Location.
|
|
|
|
1. Strip the postal code if it leaked into the end of the field
|
|
(legacy bug that caused 48-char address_line on LPB4's neighbour).
|
|
2. Expand common Quebec street-type abbreviations.
|
|
3. Title-case words but keep articles ('de', 'la', 'des') lowercase
|
|
and ordinal markers ('1re', '2e') lowercase too.
|
|
4. Collapse runs of whitespace into single spaces.
|
|
"""
|
|
if not raw:
|
|
return ""
|
|
s = unescape(str(raw)).strip()
|
|
|
|
# 1. Strip embedded postal code (with or without space)
|
|
pc_re = re.compile(r'\s+[A-Z]\d[A-Z]\s?\d[A-Z]\d\s*$', re.I)
|
|
s = pc_re.sub('', s).strip()
|
|
# Sometimes the postal code from the dedicated field is doubled
|
|
if postal_code:
|
|
pc_clean = re.sub(r'\s+', '', postal_code).upper()
|
|
s = re.sub(re.escape(pc_clean) + r'\s*$', '', s, flags=re.I).strip()
|
|
|
|
# 2. Expand abbreviations
|
|
for pat, repl in ABBREV_MAP:
|
|
s = re.sub(pat, repl, s, flags=re.I)
|
|
|
|
# 3. Word-by-word title casing with exceptions
|
|
parts = []
|
|
for i, word in enumerate(s.split(' ')):
|
|
if not word:
|
|
continue
|
|
lw = word.lower()
|
|
# Hyphenated chunks: title-case each piece (and apply ordinal rule)
|
|
if '-' in word:
|
|
sub = []
|
|
for chunk in word.split('-'):
|
|
cl = chunk.lower()
|
|
m = ORDINAL_RE.match(cl)
|
|
if m:
|
|
sub.append(m.group(1) + m.group(2).lower())
|
|
elif cl in LOWER_WORDS:
|
|
sub.append(cl)
|
|
else:
|
|
sub.append(chunk[:1].upper() + chunk[1:].lower())
|
|
parts.append('-'.join(sub))
|
|
continue
|
|
# Articles stay lowercase except as the first word
|
|
if i > 0 and lw in LOWER_WORDS:
|
|
parts.append(lw)
|
|
continue
|
|
# Ordinal markers like "1re", "2e" lowercase
|
|
m = ORDINAL_RE.match(lw)
|
|
if m:
|
|
parts.append(m.group(1) + m.group(2).lower())
|
|
continue
|
|
parts.append(word[:1].upper() + word[1:].lower())
|
|
|
|
# 4. Collapse double spaces
|
|
out = re.sub(r'\s+', ' ', ' '.join(parts)).strip()
|
|
return out
|
|
|
|
|
|
def detect_connection_type(devices_for_delivery):
|
|
"""If the legacy delivery has a fibre device (ONT/CPE/ONU), the SL is on
|
|
fibre. Without devices we leave it empty — the rep will fill in later.
|
|
"""
|
|
if not devices_for_delivery:
|
|
return None
|
|
for d in devices_for_delivery:
|
|
cat = clean(d.get('category', '')).lower()
|
|
nm = clean(d.get('name', '')).lower()
|
|
mdl = clean(d.get('model', '')).lower()
|
|
combined = ' '.join([cat, nm, mdl])
|
|
if any(k in combined for k in ('ont', 'onu', 'cpe', 'fibre', 'gpon', 'ftth')):
|
|
return 'Fibre FTTH'
|
|
return None
|
|
|
|
def log(msg):
|
|
print("[{}] {}".format(datetime.now(timezone.utc).strftime("%H:%M:%S"), msg), flush=True)
|
|
|
|
def guess_device_type(category, name, model):
|
|
"""Map legacy device category/name to ERPNext equipment_type."""
|
|
cat = clean(category).lower()
|
|
nm = clean(name).lower()
|
|
mdl = clean(model).lower()
|
|
combined = "{} {} {}".format(cat, nm, mdl)
|
|
|
|
for key, val in DEVICE_TYPE_MAP.items():
|
|
if key in combined:
|
|
return val
|
|
|
|
# Fallback heuristics
|
|
if "fibre" in combined or "gpon" in combined:
|
|
return "ONT"
|
|
if "wifi" in combined or "wireless" in combined:
|
|
return "AP WiFi"
|
|
|
|
return "Autre"
|
|
|
|
|
|
def main():
|
|
log("=" * 60)
|
|
log("MIGRATE LOCATIONS + EQUIPMENT")
|
|
log("=" * 60)
|
|
|
|
mc = pymysql.connect(**LEGACY)
|
|
pg = psycopg2.connect(**PG)
|
|
pg.autocommit = False
|
|
pgc = pg.cursor()
|
|
now = ts()
|
|
|
|
# ============================
|
|
# Phase 1: Ensure legacy_delivery_id column exists
|
|
# ============================
|
|
log("")
|
|
log("--- Phase 1: Ensure custom fields ---")
|
|
|
|
pgc.execute("""SELECT column_name FROM information_schema.columns
|
|
WHERE table_name = 'tabService Location' AND column_name = 'legacy_delivery_id'""")
|
|
if not pgc.fetchone():
|
|
pgc.execute('ALTER TABLE "tabService Location" ADD COLUMN legacy_delivery_id bigint')
|
|
# Also register as Custom Field so ERPNext knows about it
|
|
try:
|
|
pgc.execute("""
|
|
INSERT INTO "tabCustom Field" (name, creation, modified, modified_by, owner, docstatus, idx,
|
|
dt, label, fieldname, fieldtype, insert_after)
|
|
VALUES (%s, %s, %s, %s, %s, 0, 0,
|
|
'Service Location', 'Legacy Delivery ID', 'legacy_delivery_id', 'Int', 'access_notes')
|
|
""", (uid("CF-"), now, now, ADMIN, ADMIN))
|
|
except:
|
|
pg.rollback()
|
|
pg.commit()
|
|
log(" Added legacy_delivery_id to Service Location")
|
|
else:
|
|
log(" legacy_delivery_id already exists")
|
|
|
|
# Ensure legacy_device_id on Service Equipment
|
|
pgc.execute("""SELECT column_name FROM information_schema.columns
|
|
WHERE table_name = 'tabService Equipment' AND column_name = 'legacy_device_id'""")
|
|
if not pgc.fetchone():
|
|
pgc.execute('ALTER TABLE "tabService Equipment" ADD COLUMN legacy_device_id bigint')
|
|
try:
|
|
pgc.execute("""
|
|
INSERT INTO "tabCustom Field" (name, creation, modified, modified_by, owner, docstatus, idx,
|
|
dt, label, fieldname, fieldtype, insert_after)
|
|
VALUES (%s, %s, %s, %s, %s, 0, 0,
|
|
'Service Equipment', 'Legacy Device ID', 'legacy_device_id', 'Int', 'notes')
|
|
""", (uid("CF-"), now, now, ADMIN, ADMIN))
|
|
except:
|
|
pg.rollback()
|
|
pg.commit()
|
|
log(" Added legacy_device_id to Service Equipment")
|
|
else:
|
|
log(" legacy_device_id already exists")
|
|
|
|
# ============================
|
|
# Phase 2: Import deliveries → Service Location
|
|
# ============================
|
|
log("")
|
|
log("=" * 60)
|
|
log("Phase 2: Deliveries → Service Location")
|
|
log("=" * 60)
|
|
|
|
cur = mc.cursor(pymysql.cursors.DictCursor)
|
|
cur.execute("SELECT * FROM delivery ORDER BY id")
|
|
deliveries = cur.fetchall()
|
|
log(" {} deliveries loaded".format(len(deliveries)))
|
|
|
|
# Customer mapping
|
|
pgc.execute('SELECT legacy_account_id, name FROM "tabCustomer" WHERE legacy_account_id > 0')
|
|
cust_map = {r[0]: r[1] for r in pgc.fetchall()}
|
|
|
|
# Check existing
|
|
pgc.execute('SELECT legacy_delivery_id FROM "tabService Location" WHERE legacy_delivery_id > 0')
|
|
existing_loc = set(r[0] for r in pgc.fetchall())
|
|
log(" {} already imported".format(len(existing_loc)))
|
|
|
|
# Pre-load device → delivery_id mapping from legacy so we can detect
|
|
# fibre availability per Service Location at insert time. Keyed by
|
|
# delivery_id, value = list of devices on that delivery.
|
|
cur.execute("SELECT delivery_id, category, name, model FROM device WHERE delivery_id IS NOT NULL")
|
|
devices_by_delivery = {}
|
|
for row in cur.fetchall():
|
|
did_dev = row.get('delivery_id')
|
|
if did_dev:
|
|
devices_by_delivery.setdefault(did_dev, []).append(row)
|
|
log(" {} deliveries have at least one device".format(len(devices_by_delivery)))
|
|
|
|
# delivery_id → Service Location name mapping (for phases 3-5)
|
|
del_map = {}
|
|
loc_ok = loc_skip = loc_err = 0
|
|
|
|
for i, d in enumerate(deliveries):
|
|
did = d["id"]
|
|
|
|
if did in existing_loc:
|
|
# Still need the mapping for later phases
|
|
loc_skip += 1
|
|
continue
|
|
|
|
cust_id = cust_map.get(d["account_id"])
|
|
if not cust_id:
|
|
loc_err += 1
|
|
continue
|
|
|
|
# ── Normalize address fields BEFORE insert ──
|
|
# postal_code: "j0s1b0" → "J0S 1B0" (canonical form with space).
|
|
# address_line: strip embedded postal codes, expand "Ch."→"Chemin"
|
|
# etc., title-case with French article rules. The migrated dataset
|
|
# has 96+ "1Re-Concession"-style entries that the RQA validator
|
|
# can't match without this pass.
|
|
postal_norm = normalize_postal_code(clean(d.get("zip")))
|
|
addr = clean_address_line(d.get("address1"), postal_norm)
|
|
city_raw = clean(d.get("city"))
|
|
# City: title-case with the same article rules used for road names
|
|
# ("Saint-Louis-de-Gonzague" stays correct, "saint-michel" gets
|
|
# capitalized to "Saint-Michel").
|
|
city = clean_address_line(city_raw) if city_raw else ""
|
|
loc_name_display = clean(d.get("name")) or "{}, {}".format(addr, city) if addr else "Location-{}".format(did)
|
|
loc_id = uid("LOC-")
|
|
|
|
# Parse GPS
|
|
lat = 0
|
|
lon = 0
|
|
try:
|
|
if d.get("latitude"):
|
|
lat = float(d["latitude"])
|
|
if d.get("longitude"):
|
|
lon = float(d["longitude"])
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
# Detect fibre availability from the devices we pre-loaded.
|
|
# Without devices we leave connection_type empty rather than
|
|
# guessing — the rep fills it later.
|
|
conn_type = detect_connection_type(devices_by_delivery.get(did, []))
|
|
|
|
try:
|
|
pgc.execute("""
|
|
INSERT INTO "tabService Location" (
|
|
name, creation, modified, modified_by, owner, docstatus, idx,
|
|
customer, location_name, status,
|
|
address_line, city, postal_code, province,
|
|
latitude, longitude,
|
|
contact_name, contact_phone,
|
|
connection_type,
|
|
legacy_delivery_id
|
|
) VALUES (
|
|
%s, %s, %s, %s, %s, 0, 0,
|
|
%s, %s, 'Active',
|
|
%s, %s, %s, %s,
|
|
%s, %s,
|
|
%s, %s,
|
|
%s,
|
|
%s
|
|
)
|
|
""", (loc_id, now, now, ADMIN, ADMIN,
|
|
cust_id, loc_name_display[:140],
|
|
addr or "N/A", city or "N/A",
|
|
postal_norm,
|
|
clean(d.get("state")) or "QC",
|
|
lat, lon,
|
|
clean(d.get("contact")) or None,
|
|
clean(d.get("tel_home")) or clean(d.get("cell")) or None,
|
|
conn_type,
|
|
did))
|
|
|
|
del_map[did] = loc_id
|
|
loc_ok += 1
|
|
|
|
except Exception as e:
|
|
loc_err += 1
|
|
pg.rollback()
|
|
if loc_err <= 10:
|
|
log(" ERR del#{} -> {}".format(did, str(e)[:100]))
|
|
continue
|
|
|
|
if loc_ok % 1000 == 0:
|
|
pg.commit()
|
|
log(" [{}/{}] ok={} skip={} err={}".format(i+1, len(deliveries), loc_ok, loc_skip, loc_err))
|
|
|
|
pg.commit()
|
|
|
|
# Load mapping for skipped (already existing) locations
|
|
if loc_skip > 0:
|
|
pgc.execute('SELECT legacy_delivery_id, name FROM "tabService Location" WHERE legacy_delivery_id > 0')
|
|
for lid, lname in pgc.fetchall():
|
|
del_map[lid] = lname
|
|
|
|
log(" Service Locations: {} created | {} skipped | {} errors".format(loc_ok, loc_skip, loc_err))
|
|
log(" del_map has {} entries".format(len(del_map)))
|
|
|
|
# ============================
|
|
# Phase 3: Import devices → Service Equipment
|
|
# ============================
|
|
log("")
|
|
log("=" * 60)
|
|
log("Phase 3: Devices → Service Equipment")
|
|
log("=" * 60)
|
|
|
|
cur.execute("SELECT * FROM device ORDER BY id")
|
|
devices = cur.fetchall()
|
|
log(" {} devices loaded".format(len(devices)))
|
|
|
|
pgc.execute('SELECT legacy_device_id FROM "tabService Equipment" WHERE legacy_device_id > 0')
|
|
existing_dev = set(r[0] for r in pgc.fetchall())
|
|
|
|
# device_id → Equipment name mapping (for parent hierarchy)
|
|
dev_map = {}
|
|
dev_ok = dev_skip = dev_err = 0
|
|
|
|
for i, dv in enumerate(devices):
|
|
dvid = dv["id"]
|
|
|
|
if dvid in existing_dev:
|
|
dev_skip += 1
|
|
continue
|
|
|
|
loc_id = del_map.get(dv.get("delivery_id"))
|
|
# Get customer from the location's customer, or from delivery → account
|
|
cust_id = None
|
|
if loc_id:
|
|
pgc.execute('SELECT customer FROM "tabService Location" WHERE name = %s', (loc_id,))
|
|
row = pgc.fetchone()
|
|
if row:
|
|
cust_id = row[0]
|
|
|
|
sn = (clean(dv.get("sn")) or "SN-{}".format(dvid))[:140]
|
|
mac = clean(dv.get("mac"))[:140] if dv.get("mac") else None
|
|
equip_type = guess_device_type(
|
|
dv.get("category"), dv.get("name"), dv.get("model"))
|
|
equip_id = uid("EQ-")
|
|
|
|
try:
|
|
pgc.execute("""
|
|
INSERT INTO "tabService Equipment" (
|
|
name, creation, modified, modified_by, owner, docstatus, idx,
|
|
equipment_type, brand, model, serial_number, mac_address,
|
|
customer, service_location, status, ownership,
|
|
ip_address, login_user, login_password,
|
|
legacy_device_id
|
|
) VALUES (
|
|
%s, %s, %s, %s, %s, 0, 0,
|
|
%s, %s, %s, %s, %s,
|
|
%s, %s, 'Actif', 'Gigafibre',
|
|
%s, %s, %s,
|
|
%s
|
|
)
|
|
""", (equip_id, now, now, ADMIN, ADMIN,
|
|
equip_type,
|
|
clean(dv.get("manufacturier")) or None,
|
|
clean(dv.get("model")) or None,
|
|
sn[:140],
|
|
mac or None,
|
|
cust_id,
|
|
loc_id,
|
|
clean(dv.get("manage")) or None,
|
|
clean(dv.get("user")) or None,
|
|
clean(dv.get("pass")) or None,
|
|
dvid))
|
|
|
|
dev_map[dvid] = equip_id
|
|
dev_ok += 1
|
|
|
|
except Exception as e:
|
|
pg.rollback()
|
|
# Retry with unique SN on duplicate key
|
|
if "unique constraint" in str(e).lower() and "serial_number" in str(e).lower():
|
|
sn = "{}-{}".format(sn[:130], dvid)
|
|
try:
|
|
pgc.execute("""
|
|
INSERT INTO "tabService Equipment" (
|
|
name, creation, modified, modified_by, owner, docstatus, idx,
|
|
equipment_type, brand, model, serial_number, mac_address,
|
|
customer, service_location, status, ownership,
|
|
ip_address, login_user, login_password,
|
|
legacy_device_id
|
|
) VALUES (
|
|
%s, %s, %s, %s, %s, 0, 0,
|
|
%s, %s, %s, %s, %s,
|
|
%s, %s, 'Actif', 'Gigafibre',
|
|
%s, %s, %s,
|
|
%s
|
|
)
|
|
""", (equip_id, now, now, ADMIN, ADMIN,
|
|
equip_type,
|
|
clean(dv.get("manufacturier")) or None,
|
|
clean(dv.get("model")) or None,
|
|
sn, mac,
|
|
cust_id, loc_id,
|
|
clean(dv.get("manage")) or None,
|
|
clean(dv.get("user")) or None,
|
|
clean(dv.get("pass")) or None,
|
|
dvid))
|
|
dev_map[dvid] = equip_id
|
|
dev_ok += 1
|
|
continue
|
|
except Exception as e2:
|
|
pg.rollback()
|
|
|
|
dev_err += 1
|
|
if dev_err <= 10:
|
|
log(" ERR dev#{} -> {}".format(dvid, str(e)[:100]))
|
|
continue
|
|
|
|
if dev_ok % 1000 == 0:
|
|
pg.commit()
|
|
log(" [{}/{}] ok={} skip={} err={}".format(i+1, len(devices), dev_ok, dev_skip, dev_err))
|
|
|
|
pg.commit()
|
|
log(" Equipment: {} created | {} skipped | {} errors".format(dev_ok, dev_skip, dev_err))
|
|
|
|
# Phase 3b: Set parent equipment (device hierarchy)
|
|
log(" Setting device parent hierarchy...")
|
|
parent_set = 0
|
|
for dv in devices:
|
|
if dv.get("parent") and dv["parent"] > 0:
|
|
child_eq = dev_map.get(dv["id"])
|
|
parent_eq = dev_map.get(dv["parent"])
|
|
if child_eq and parent_eq:
|
|
# No native parent field on Service Equipment, store in notes for now
|
|
pgc.execute("""
|
|
UPDATE "tabService Equipment"
|
|
SET notes = COALESCE(notes, '') || 'Parent: ' || %s || E'\n'
|
|
WHERE name = %s
|
|
""", (parent_eq, child_eq))
|
|
parent_set += 1
|
|
pg.commit()
|
|
log(" {} parent links set".format(parent_set))
|
|
|
|
# ============================
|
|
# Phase 4: Link Subscriptions → Service Location
|
|
# ============================
|
|
log("")
|
|
log("=" * 60)
|
|
log("Phase 4: Link Subscriptions → Service Location")
|
|
log("=" * 60)
|
|
|
|
# Get service → delivery mapping from legacy
|
|
cur.execute("SELECT id, delivery_id FROM service WHERE status = 1 AND delivery_id > 0")
|
|
svc_to_del = {r["id"]: r["delivery_id"] for r in cur.fetchall()}
|
|
log(" {} service→delivery mappings".format(len(svc_to_del)))
|
|
|
|
# Get subscriptions with legacy_service_id
|
|
pgc.execute("""
|
|
SELECT name, legacy_service_id FROM "tabSubscription"
|
|
WHERE legacy_service_id > 0
|
|
AND (service_location IS NULL OR service_location = '')
|
|
""")
|
|
subs_to_link = pgc.fetchall()
|
|
log(" {} subscriptions to link".format(len(subs_to_link)))
|
|
|
|
sub_linked = sub_miss = 0
|
|
for sub_name, legacy_svc_id in subs_to_link:
|
|
del_id = svc_to_del.get(legacy_svc_id)
|
|
if not del_id:
|
|
sub_miss += 1
|
|
continue
|
|
loc_id = del_map.get(del_id)
|
|
if not loc_id:
|
|
sub_miss += 1
|
|
continue
|
|
|
|
pgc.execute("""
|
|
UPDATE "tabSubscription"
|
|
SET service_location = %s, modified = NOW()
|
|
WHERE name = %s
|
|
""", (loc_id, sub_name))
|
|
sub_linked += 1
|
|
|
|
if sub_linked % 5000 == 0:
|
|
pg.commit()
|
|
log(" {} linked...".format(sub_linked))
|
|
|
|
pg.commit()
|
|
log(" Subscriptions linked: {} | missed: {}".format(sub_linked, sub_miss))
|
|
|
|
# ============================
|
|
# Phase 5: Link Issues → Service Location
|
|
# ============================
|
|
log("")
|
|
log("=" * 60)
|
|
log("Phase 5: Link Issues → Service Location")
|
|
log("=" * 60)
|
|
|
|
# Get ticket → delivery mapping from legacy
|
|
cur.execute("SELECT id, delivery_id FROM ticket WHERE delivery_id > 0")
|
|
tkt_to_del = {r["id"]: r["delivery_id"] for r in cur.fetchall()}
|
|
log(" {} ticket→delivery mappings".format(len(tkt_to_del)))
|
|
|
|
# Get issues with legacy_ticket_id that need linking
|
|
pgc.execute("""
|
|
SELECT name, legacy_ticket_id FROM "tabIssue"
|
|
WHERE legacy_ticket_id > 0
|
|
AND (service_location IS NULL OR service_location = '')
|
|
""")
|
|
issues_to_link = pgc.fetchall()
|
|
log(" {} issues to link".format(len(issues_to_link)))
|
|
|
|
iss_linked = iss_miss = 0
|
|
for issue_name, legacy_tkt_id in issues_to_link:
|
|
del_id = tkt_to_del.get(legacy_tkt_id)
|
|
if not del_id:
|
|
iss_miss += 1
|
|
continue
|
|
loc_id = del_map.get(del_id)
|
|
if not loc_id:
|
|
iss_miss += 1
|
|
continue
|
|
|
|
pgc.execute("""
|
|
UPDATE "tabIssue"
|
|
SET service_location = %s, modified = NOW()
|
|
WHERE name = %s
|
|
""", (loc_id, issue_name))
|
|
iss_linked += 1
|
|
|
|
if iss_linked % 10000 == 0:
|
|
pg.commit()
|
|
log(" {} linked...".format(iss_linked))
|
|
|
|
pg.commit()
|
|
log(" Issues linked: {} | missed: {}".format(iss_linked, iss_miss))
|
|
|
|
# ============================
|
|
# Summary
|
|
# ============================
|
|
mc.close()
|
|
pg.close()
|
|
|
|
log("")
|
|
log("=" * 60)
|
|
log("MIGRATION LOCATIONS + EQUIPMENT COMPLETE")
|
|
log("=" * 60)
|
|
log(" Service Locations: {} created".format(loc_ok))
|
|
log(" Service Equipment: {} created ({} parent links)".format(dev_ok, parent_set))
|
|
log(" Subscriptions → Location: {} linked".format(sub_linked))
|
|
log(" Issues → Location: {} linked".format(iss_linked))
|
|
log("=" * 60)
|
|
log("")
|
|
log("Next: bench --site erp.gigafibre.ca clear-cache")
|
|
|
|
if __name__ == "__main__":
|
|
main()
|