"""Generate reconciliation SQL for the locations table. Reads a TSV export (locationid, locationname, description, locationtypeid, parentlocationid) and emits a single transaction-wrapped SQL script that: - Treats bare grid coordinates ([A-V][0-9]{1,2}W? and LD[0-9]+W?) as the canonical identity for a cell. - Folds coordinate-plus-suffix rows into the canonical coordinate, moving the suffix text into the canonical row's description (never the name). - Folds named-area suffix variants ("NDE - 6409") into the parent named area ("NDE"); named areas are kept as-is and never forced into coordinates. - Repoints assets.locationid, subnets.locationid and child locations.parentlocationid onto the canonical row, then deletes duplicates. Bare coordinates with no variants are left untouched. The trailing W (west column, e.g. G2W, K3W) is part of the coordinate and is never stripped, so G2W is kept distinct from a hypothetical G2. """ import re import sys COORD = re.compile(r'^(LD|[ABCDEFGHJKLMNPQRSTUV])(\d{1,2})(W)?(?=$|[ \-])') def esc(s): return s.replace("'", "''") def load(path): rows = [] with open(path, encoding='utf-8-sig') as fh: for line in fh: line = line.rstrip('\n').rstrip('\r') if not line: continue parts = line.split('\t') if len(parts) < 5: parts += [''] * (5 - len(parts)) lid, name, desc, typeid, parentid = parts[:5] rows.append({ 'id': int(lid), 'name': name, 'desc': desc, 'typeid': typeid, 'parentid': parentid, }) return rows def classify(name): """Return (canonical, suffix). canonical is the bare coordinate or named parent; suffix is the trailing detail (may be '').""" m = COORD.match(name) if m: prefix, digits, west = m.group(1), m.group(2), m.group(3) or '' canonical = f"{prefix}{digits}{west}" suffix = name[m.end():].strip() suffix = re.sub(r'^[\-\s]+', '', suffix).strip() return canonical, suffix, 'coord' # named area: fold on ' - ' separator only if ' - ' in name: base, _, rest = name.partition(' - ') return base.strip(), rest.strip(), 'named' return name.strip(), '', 'named' def main(tsv, out): rows = load(tsv) # Build canonical groups. Key is casefolded canonical for grouping, but we # preserve a display canonical (prefer an existing exact bare/standalone). groups = {} for r in rows: canonical, suffix, kind = classify(r['name']) key = (kind, canonical.casefold()) g = groups.setdefault(key, { 'canonical': canonical, 'kind': kind, 'members': [], 'exact_id': None, }) r['_suffix'] = suffix r['_canonical'] = canonical g['members'].append(r) # exact row: name equals canonical (no suffix) if suffix == '' and r['name'].strip() == canonical: g['exact_id'] = r['id'] g['canonical'] = r['name'].strip() lines = [] lines.append('-- Auto-generated locations reconciliation. Review before running.') lines.append('START TRANSACTION;') touched = 0 for key, g in sorted(groups.items(), key=lambda kv: kv[0][1]): members = sorted(g['members'], key=lambda r: r['id']) # nothing to do: single member that is already the exact canonical if len(members) == 1 and g['exact_id'] == members[0]['id']: continue touched += 1 canonical = g['canonical'] # choose target id: prefer exact bare row, else promote smallest id if g['exact_id'] is not None: target = g['exact_id'] promote = None else: target = members[0]['id'] promote = members[0] # accumulate suffix/description text from all members (dedup, ordered) details = [] for m in members: for chunk in (m['_suffix'], m['desc']): chunk = (chunk or '').strip() if chunk and chunk not in details: details.append(chunk) desc_val = '; '.join(details) lines.append('') lines.append(f"-- {canonical} (target locationid {target}) <= " + ', '.join(str(m['id']) for m in members)) if promote is not None: lines.append( f"UPDATE locations SET locationname='{esc(canonical)}' " f"WHERE locationid={target};") if desc_val: lines.append( f"UPDATE locations SET description='{esc(desc_val)}' " f"WHERE locationid={target};") others = [m['id'] for m in members if m['id'] != target] if others: ids = ','.join(str(i) for i in others) lines.append( f"UPDATE assets SET locationid={target} " f"WHERE locationid IN ({ids});") lines.append( f"UPDATE subnets SET locationid={target} " f"WHERE locationid IN ({ids});") lines.append( f"UPDATE locations SET parentlocationid={target} " f"WHERE parentlocationid IN ({ids});") lines.append( f"DELETE FROM locations WHERE locationid IN ({ids});") lines.append('') lines.append('COMMIT;') lines.append(f'-- groups touched: {touched}') with open(out, 'w', encoding='utf-8') as fh: fh.write('\n'.join(lines) + '\n') print(f'wrote {out}: {touched} groups touched, {len(rows)} source rows') if __name__ == '__main__': main(sys.argv[1], sys.argv[2])