gigafibre-fsm/scripts/migration/migrate_tickets.py
louispaulb 101faa21f1 feat: inline editing, search, notifications + full repo cleanup
- InlineField component + useInlineEdit composable for Odoo-style dblclick editing
- Client search by name, account ID, and legacy_customer_id (or_filters)
- SMS/Email notification panel on ContactCard via n8n webhooks
- Ticket reply thread via Communication docs
- All migration scripts (51 files) now tracked
- Client portal and field tech app added to monorepo
- README rewritten with full feature list, migration summary, architecture
- CHANGELOG updated with all recent work
- ROADMAP updated with current completion status
- Removed hardcoded tokens from docs (use $ERP_SERVICE_TOKEN)
- .gitignore updated (docker/, .claude/, exports/, .quasar/)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 07:34:41 -04:00

341 lines
12 KiB
Python

#!/usr/bin/env python3
"""
Phase 6: Import tickets → ERPNext Issue + Communication.
- Creates Issue Types from ticket_dept
- Imports open+pending tickets (1,395) as Issues
- Imports closed tickets (241K) as closed Issues
- Imports ticket_msg as Communication
- Maps parent/child (incident pattern)
Direct PostgreSQL. Detached.
Log: /tmp/migrate_tickets.log
"""
import pymysql
import psycopg2
import uuid
from datetime import datetime, timezone
from html import unescape
LEGACY = {"host": "10.100.80.100", "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"
COMPANY = "TARGO"
# Priority mapping: legacy (0=urgent,1=high,2=normal,3=low)
PRIORITY_MAP = {0: "Urgent", 1: "High", 2: "Medium", 3: "Low"}
STATUS_MAP = {"open": "Open", "pending": "On Hold", "closed": "Closed"}
def uid(prefix=""):
return prefix + uuid.uuid4().hex[:10]
def now():
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f")
def ts_to_date(unix_ts):
if not unix_ts or unix_ts <= 0:
return None
try:
return datetime.fromtimestamp(int(unix_ts), tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
except (ValueError, OSError):
return None
def ts_to_dateonly(unix_ts):
if not unix_ts or unix_ts <= 0:
return None
try:
return datetime.fromtimestamp(int(unix_ts), tz=timezone.utc).strftime("%Y-%m-%d")
except (ValueError, OSError):
return None
def clean(val):
if not val:
return ""
return unescape(str(val)).strip()
def log(msg):
print(msg, flush=True)
def main():
ts = now()
log("=== Phase 6: Tickets Migration ===")
# 1. Read legacy data
log("Reading legacy data...")
mc = pymysql.connect(**LEGACY)
cur = mc.cursor(pymysql.cursors.DictCursor)
cur.execute("SELECT * FROM ticket_dept ORDER BY id")
depts = cur.fetchall()
cur.execute("SELECT id, username, first_name, last_name, email FROM staff ORDER BY id")
staff_list = cur.fetchall()
# ALL tickets (open, pending, closed) — only needed columns (avoid wizard/wizard_fibre blobs)
cur.execute("""SELECT id, account_id, delivery_id, subject, status, priority,
dept_id, date_create, parent, open_by, assign_to, important
FROM ticket ORDER BY id""")
tickets = cur.fetchall()
# Messages for open/pending tickets + last message for closed (for context)
cur.execute("""
SELECT m.id, m.ticket_id, m.staff_id, m.msg, m.date_orig FROM ticket_msg m
JOIN ticket t ON m.ticket_id = t.id
WHERE t.status IN ('open', 'pending')
ORDER BY m.ticket_id, m.id
""")
open_msgs = cur.fetchall()
# For closed tickets: just count (we'll import messages in batches later)
cur.execute("SELECT COUNT(*) as cnt FROM ticket_msg WHERE ticket_id IN (SELECT id FROM ticket WHERE status = 'closed')")
closed_msg_count = cur.fetchone()["cnt"]
mc.close()
log(" {} depts, {} staff, {} tickets".format(len(depts), len(staff_list), len(tickets)))
log(" {} messages for open/pending tickets".format(len(open_msgs)))
log(" {} messages for closed tickets (deferred)".format(closed_msg_count))
# 2. Connect ERPNext PG
log("Connecting to ERPNext PostgreSQL...")
pg = psycopg2.connect(**PG)
pgc = pg.cursor()
# 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()}
# Staff → User mapping (by email)
pgc.execute('SELECT name, email FROM "tabUser" WHERE enabled = 1')
user_by_email = {r[1]: r[0] for r in pgc.fetchall() if r[1]}
staff_to_user = {}
for s in staff_list:
email = s.get("email", "")
if email and email in user_by_email:
staff_to_user[s["id"]] = user_by_email[email]
# Existing Issue Types
pgc.execute('SELECT name FROM "tabIssue Type"')
existing_types = set(r[0] for r in pgc.fetchall())
# Existing Issues by legacy_ticket_id
pgc.execute('SELECT legacy_ticket_id FROM "tabIssue" WHERE legacy_ticket_id IS NOT NULL AND legacy_ticket_id > 0')
existing_issues = set(r[0] for r in pgc.fetchall())
# Issue Priority
pgc.execute('SELECT name FROM "tabIssue Priority"')
existing_priorities = set(r[0] for r in pgc.fetchall())
log(" {} customers mapped, {} staff→user mapped".format(len(cust_map), len(staff_to_user)))
# 3. Create Issue Priorities
for pname in ["Urgent", "High", "Medium", "Low"]:
if pname not in existing_priorities:
pgc.execute("""
INSERT INTO "tabIssue Priority" (name, creation, modified, modified_by, owner, docstatus, idx)
VALUES (%s, %s, %s, %s, %s, 0, 0)
""", (pname, ts, ts, ADMIN, ADMIN))
pg.commit()
log(" Issue Priorities created")
# 4. Create Issue Types from ticket_dept
log("")
log("--- Creating Issue Types ---")
dept_map = {} # dept_id → Issue Type name
types_created = 0
for d in depts:
name = clean(d["name"])
if not name:
continue
dept_map[d["id"]] = name
if name in existing_types:
continue
pgc.execute("""
INSERT INTO "tabIssue Type" (name, creation, modified, modified_by, owner, docstatus, idx)
VALUES (%s, %s, %s, %s, %s, 0, 0)
""", (name, ts, ts, ADMIN, ADMIN))
existing_types.add(name)
types_created += 1
pg.commit()
log(" {} Issue Types created".format(types_created))
# 5. Import Tickets as Issues
log("")
log("--- Importing Tickets → Issues ---")
# Build message lookup by ticket_id
msgs_by_ticket = {}
for m in open_msgs:
msgs_by_ticket.setdefault(m["ticket_id"], []).append(m)
# Track legacy_ticket_id → ERPNext issue name for parent mapping
ticket_to_issue = {}
i_ok = i_skip = i_err = 0
comm_ok = 0
for i, t in enumerate(tickets):
tid = t["id"]
if tid in existing_issues:
i_skip += 1
continue
subject = clean(t.get("subject")) or "Ticket #{}".format(tid)
status = STATUS_MAP.get(t.get("status", "open"), "Open")
priority = PRIORITY_MAP.get(t.get("priority", 2), "Medium")
dept_name = dept_map.get(t.get("dept_id"), None)
cust_name = cust_map.get(t.get("account_id"))
is_important = 1 if t.get("important") else 0
opening_date = ts_to_dateonly(t.get("date_create"))
opening_time = None
if t.get("date_create") and t["date_create"] > 0:
try:
opening_time = datetime.fromtimestamp(int(t["date_create"]), tz=timezone.utc).strftime("%H:%M:%S")
except (ValueError, OSError):
pass
# Is this an incident (parent)?
is_incident = 0
# Check if other tickets reference this one as parent
# We'll do a second pass for this
issue_name = uid("ISS-")
try:
# Savepoint so errors only rollback THIS ticket, not the whole batch
pgc.execute("SAVEPOINT sp_ticket")
pgc.execute("""
INSERT INTO "tabIssue" (
name, creation, modified, modified_by, owner, docstatus, idx,
naming_series, subject, status, priority, issue_type,
customer, company, opening_date, opening_time,
legacy_ticket_id, is_incident, is_important,
parent_incident, service_location
) VALUES (
%s, %s, %s, %s, %s, 0, 0,
'ISS-.YYYY.-', %s, %s, %s, %s,
%s, %s, %s, %s,
%s, %s, %s, %s, %s
)
""", (issue_name, ts, ts, ADMIN, ADMIN,
subject[:255], status, priority, dept_name,
cust_name, COMPANY, opening_date, opening_time,
tid, 0, is_important, None, None))
ticket_to_issue[tid] = issue_name
i_ok += 1
# Import messages for open/pending tickets
for m in msgs_by_ticket.get(tid, []):
msg_text = m.get("msg") or ""
if not msg_text.strip():
continue
sender = staff_to_user.get(m.get("staff_id"), ADMIN)
msg_date = ts_to_date(m.get("date_orig"))
comm_name = uid("COM-")
try:
pgc.execute("""
INSERT INTO "tabComment" (
name, creation, modified, modified_by, owner, docstatus, idx,
comment_type, comment_by, content,
reference_doctype, reference_name
) VALUES (
%s, %s, %s, %s, %s, 0, 0,
'Comment', %s, %s,
'Issue', %s
)
""", (comm_name, msg_date or ts, msg_date or ts, ADMIN, ADMIN,
sender, msg_text,
issue_name))
comm_ok += 1
except Exception:
pgc.execute("ROLLBACK TO SAVEPOINT sp_ticket")
pgc.execute("SAVEPOINT sp_ticket")
# Skip message but keep the Issue
pgc.execute("RELEASE SAVEPOINT sp_ticket")
except Exception as e:
i_err += 1
pgc.execute("ROLLBACK TO SAVEPOINT sp_ticket")
pgc.execute("RELEASE SAVEPOINT sp_ticket")
if i_err <= 20:
log(" ERR ticket#{} -> {}".format(tid, str(e)[:100]))
continue
if i_ok % 2000 == 0:
pg.commit()
log(" [{}/{}] issues={} comm={} skip={} err={}".format(
i+1, len(tickets), i_ok, comm_ok, i_skip, i_err))
pg.commit()
# 6. Second pass: set parent_incident for sub-tickets
log("")
log("--- Setting parent/child relationships ---")
parent_set = 0
incident_set = 0
for t in tickets:
if t.get("parent") and t["parent"] > 0:
child_issue = ticket_to_issue.get(t["id"])
parent_issue = ticket_to_issue.get(t["parent"])
if child_issue and parent_issue:
pgc.execute("""
UPDATE "tabIssue" SET parent_incident = %s WHERE name = %s
""", (parent_issue, child_issue))
parent_set += 1
# Mark parent as incident
pgc.execute("""
UPDATE "tabIssue" SET is_incident = 1 WHERE name = %s AND is_incident = 0
""", (parent_issue,))
incident_set += 1
# Update affected_clients count for incidents
pgc.execute("""
UPDATE "tabIssue" i SET affected_clients = (
SELECT COUNT(*) FROM "tabIssue" child WHERE child.parent_incident = i.name
) WHERE i.is_incident = 1
""")
pg.commit()
log(" {} parent links set, {} incidents identified".format(parent_set, incident_set))
# 7. Update is_important on ALL tickets (existing + new)
log("")
log("--- Updating is_important flag ---")
imp_map = {t["id"]: 1 for t in tickets if t.get("important")}
imp_updated = 0
if imp_map:
imp_ids = list(imp_map.keys())
pgc.execute("""
UPDATE "tabIssue" SET is_important = 1
WHERE legacy_ticket_id = ANY(%s) AND (is_important IS NULL OR is_important = 0)
""", (imp_ids,))
imp_updated = pgc.rowcount
pg.commit()
log(" {} tickets marked as important".format(imp_updated))
pg.close()
log("")
log("=" * 60)
log("Issue Types: {} created".format(types_created))
log("Issues: {} created, {} skipped, {} errors".format(i_ok, i_skip, i_err))
log("Communications: {} (open/pending tickets only)".format(comm_ok))
log("Parent/child: {} links, {} incidents".format(parent_set, incident_set))
log("Closed msgs: {} deferred (import separately if needed)".format(closed_msg_count))
log("=" * 60)
log("")
log("Next: bench --site erp.gigafibre.ca clear-cache")
if __name__ == "__main__":
main()