- 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>
292 lines
11 KiB
Python
292 lines
11 KiB
Python
"""
|
|
Import employees from legacy staff table into ERPNext Employee doctype.
|
|
Run inside erpnext-backend-1:
|
|
/home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/import_employees.py
|
|
|
|
Maps legacy group_ad → Department, status → Active/Inactive.
|
|
Idempotent: deletes existing employees before reimporting.
|
|
"""
|
|
import frappe
|
|
import pymysql
|
|
import os
|
|
import time
|
|
from datetime import datetime
|
|
|
|
os.chdir("/home/frappe/frappe-bench/sites")
|
|
frappe.init(site="erp.gigafibre.ca", sites_path=".")
|
|
frappe.connect()
|
|
frappe.local.flags.ignore_permissions = True
|
|
print("Connected:", frappe.local.site)
|
|
|
|
T_TOTAL = time.time()
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# CONFIG
|
|
# ═══════════════════════════════════════════════════════════════
|
|
COMPANY = "TARGO"
|
|
|
|
# Legacy group_ad → ERPNext Department
|
|
DEPT_MAP = {
|
|
"admin": "Management - T",
|
|
"sysadmin": "Operations - T",
|
|
"tech": "Operations - T",
|
|
"support": "Customer Service - T",
|
|
"comptabilite": "Accounts - T",
|
|
"facturation": "Accounts - T",
|
|
"": None,
|
|
"none": None,
|
|
}
|
|
|
|
# Legacy group_ad → ERPNext Designation
|
|
DESIG_MAP = {
|
|
"admin": "Manager",
|
|
"sysadmin": "Engineer",
|
|
"tech": "Technician",
|
|
"support": "Customer Service Representative",
|
|
"comptabilite": "Accountant",
|
|
"facturation": "Accountant",
|
|
}
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# PHASE 0: Ensure prerequisite records exist
|
|
# ═══════════════════════════════════════════════════════════════
|
|
print("\n" + "="*60)
|
|
print("PHASE 0: PREREQUISITES")
|
|
print("="*60)
|
|
|
|
# Gender "Prefer not to say"
|
|
if not frappe.db.exists("Gender", "Prefer not to say"):
|
|
frappe.get_doc({"doctype": "Gender", "gender": "Prefer not to say"}).insert()
|
|
frappe.db.commit()
|
|
print("Created Gender: Prefer not to say")
|
|
|
|
# Designation "Technician" — insert via SQL
|
|
for desig_name in ["Technician"]:
|
|
if not frappe.db.exists("Designation", desig_name):
|
|
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")
|
|
frappe.db.sql("""
|
|
INSERT INTO "tabDesignation" (name, creation, modified, modified_by, owner, docstatus, idx)
|
|
VALUES (%(n)s, %(now)s, %(now)s, 'Administrator', 'Administrator', 0, 0)
|
|
""", {"n": desig_name, "now": now})
|
|
frappe.db.commit()
|
|
print("Created Designation:", desig_name)
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# PHASE 1: CLEANUP existing employees
|
|
# ═══════════════════════════════════════════════════════════════
|
|
print("\n" + "="*60)
|
|
print("PHASE 1: CLEANUP")
|
|
print("="*60)
|
|
|
|
existing = frappe.db.sql('SELECT COUNT(*) FROM "tabEmployee"')[0][0]
|
|
if existing > 0:
|
|
frappe.db.sql('DELETE FROM "tabEmployee"')
|
|
frappe.db.commit()
|
|
print("Deleted {} existing employees".format(existing))
|
|
else:
|
|
print("No existing employees to delete")
|
|
|
|
# Reset naming series counter
|
|
frappe.db.sql("""
|
|
DELETE FROM "tabSeries" WHERE name = 'HR-EMP-'
|
|
""")
|
|
frappe.db.commit()
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# PHASE 2: FETCH legacy staff
|
|
# ═══════════════════════════════════════════════════════════════
|
|
print("\n" + "="*60)
|
|
print("PHASE 2: FETCH LEGACY STAFF")
|
|
print("="*60)
|
|
|
|
conn = pymysql.connect(
|
|
host="10.100.80.100",
|
|
user="facturation",
|
|
password="*******",
|
|
database="gestionclient",
|
|
cursorclass=pymysql.cursors.DictCursor
|
|
)
|
|
|
|
with conn.cursor() as cur:
|
|
cur.execute("""
|
|
SELECT id, status, username, first_name, last_name, email, ext, cell,
|
|
group_ad, date_embauche, fete, matricule_desjardins, ldap_id
|
|
FROM staff ORDER BY id
|
|
""")
|
|
staff = cur.fetchall()
|
|
|
|
conn.close()
|
|
print("Fetched {} staff records".format(len(staff)))
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# PHASE 3: INSERT employees via bulk SQL
|
|
# ═══════════════════════════════════════════════════════════════
|
|
print("\n" + "="*60)
|
|
print("PHASE 3: INSERT EMPLOYEES")
|
|
print("="*60)
|
|
|
|
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")
|
|
counter = 0
|
|
skipped = 0
|
|
|
|
for s in staff:
|
|
# Skip system/bot accounts with no real name
|
|
if not s["first_name"] or s["first_name"].strip() == "":
|
|
skipped += 1
|
|
continue
|
|
|
|
counter += 1
|
|
emp_name = "HR-EMP-{}".format(counter)
|
|
first_name = (s["first_name"] or "").replace("'", "'").strip()
|
|
last_name = (s["last_name"] or "").replace("'", "'").strip()
|
|
full_name = "{} {}".format(first_name, last_name).strip()
|
|
|
|
# Status: legacy 1 = Active, -1 = Inactive/Left
|
|
status = "Active" if s["status"] == 1 else "Left"
|
|
|
|
# Department
|
|
group = (s["group_ad"] or "").strip().lower()
|
|
dept = DEPT_MAP.get(group)
|
|
|
|
# Designation
|
|
desig = DESIG_MAP.get(group)
|
|
|
|
# Date of joining from unix timestamp
|
|
doj = None
|
|
if s["date_embauche"]:
|
|
try:
|
|
ts = int(s["date_embauche"])
|
|
if ts > 0:
|
|
doj = datetime.fromtimestamp(ts).strftime("%Y-%m-%d")
|
|
except (ValueError, OSError):
|
|
pass
|
|
if not doj:
|
|
doj = "2020-01-01" # placeholder
|
|
|
|
# Date of birth from fete (DD|MM or MM|DD format)
|
|
dob = None
|
|
if s["fete"]:
|
|
parts = s["fete"].split("|")
|
|
if len(parts) == 2:
|
|
try:
|
|
day = int(parts[0])
|
|
month = int(parts[1])
|
|
# Format is DD|MM based on sample data (e.g. "06|05" = June 5th)
|
|
# But "30|12" = 30th of December — day|month
|
|
if day > 12:
|
|
# day is definitely the day
|
|
dob = "1990-{:02d}-{:02d}".format(month, day)
|
|
elif month > 12:
|
|
# month field is actually the day
|
|
dob = "1990-{:02d}-{:02d}".format(day, month)
|
|
else:
|
|
# Ambiguous — use DD|MM interpretation
|
|
dob = "1990-{:02d}-{:02d}".format(month, day)
|
|
except (ValueError, IndexError):
|
|
pass
|
|
if not dob:
|
|
dob = "1990-01-01" # placeholder
|
|
|
|
# Email
|
|
email = (s["email"] or "").strip()
|
|
company_email = email if email.endswith("@targointernet.com") else None
|
|
|
|
# Cell phone
|
|
cell = (s["cell"] or "").strip()
|
|
|
|
# Employee number = legacy staff ID
|
|
emp_number = str(s["id"])
|
|
|
|
frappe.db.sql("""
|
|
INSERT INTO "tabEmployee" (
|
|
name, creation, modified, modified_by, owner, docstatus, idx,
|
|
naming_series, first_name, last_name, employee_name,
|
|
gender, date_of_birth, date_of_joining,
|
|
status, company, department, designation,
|
|
employee_number, cell_number, company_email, personal_email,
|
|
prefered_contact_email,
|
|
lft, rgt
|
|
) VALUES (
|
|
%(name)s, %(now)s, %(now)s, 'Administrator', 'Administrator', 0, 0,
|
|
'HR-EMP-', %(first_name)s, %(last_name)s, %(full_name)s,
|
|
'Prefer not to say', %(dob)s, %(doj)s,
|
|
%(status)s, %(company)s, %(dept)s, %(desig)s,
|
|
%(emp_number)s, %(cell)s, %(company_email)s, %(personal_email)s,
|
|
%(pref_email)s,
|
|
0, 0
|
|
)
|
|
""", {
|
|
"name": emp_name,
|
|
"now": now_str,
|
|
"first_name": first_name,
|
|
"last_name": last_name,
|
|
"full_name": full_name,
|
|
"dob": dob,
|
|
"doj": doj,
|
|
"status": status,
|
|
"company": COMPANY,
|
|
"dept": dept,
|
|
"desig": desig,
|
|
"emp_number": emp_number,
|
|
"cell": cell if cell else None,
|
|
"company_email": company_email,
|
|
"personal_email": email if email and not email.endswith("@targointernet.com") else None,
|
|
"pref_email": "Company Email" if company_email else None,
|
|
})
|
|
|
|
frappe.db.commit()
|
|
print("Inserted {} employees ({} skipped - no name)".format(counter, skipped))
|
|
|
|
# Set the naming series counter
|
|
frappe.db.sql("""
|
|
INSERT INTO "tabSeries" (name, current) VALUES ('HR-EMP-', %(counter)s)
|
|
ON CONFLICT (name) DO UPDATE SET current = %(counter)s
|
|
""", {"counter": counter})
|
|
frappe.db.commit()
|
|
|
|
# Rebuild tree (Employee is a tree doctype with lft/rgt)
|
|
try:
|
|
frappe.rebuild_tree("Employee", "reports_to")
|
|
print("Rebuilt employee tree")
|
|
except Exception as e:
|
|
print("Tree rebuild skipped:", e)
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# PHASE 4: VERIFY
|
|
# ═══════════════════════════════════════════════════════════════
|
|
print("\n" + "="*60)
|
|
print("PHASE 4: VERIFY")
|
|
print("="*60)
|
|
|
|
total = frappe.db.sql('SELECT COUNT(*) FROM "tabEmployee"')[0][0]
|
|
active = frappe.db.sql('SELECT COUNT(*) FROM "tabEmployee" WHERE status = %s', ("Active",))[0][0]
|
|
left = frappe.db.sql('SELECT COUNT(*) FROM "tabEmployee" WHERE status = %s', ("Left",))[0][0]
|
|
|
|
print("Total employees: {}".format(total))
|
|
print(" Active: {}".format(active))
|
|
print(" Left: {}".format(left))
|
|
|
|
by_dept = frappe.db.sql("""
|
|
SELECT department, COUNT(*) as cnt FROM "tabEmployee"
|
|
GROUP BY department ORDER BY cnt DESC
|
|
""", as_dict=True)
|
|
print("\nBy department:")
|
|
for d in by_dept:
|
|
print(" {}: {}".format(d["department"] or "(none)", d["cnt"]))
|
|
|
|
# Sample
|
|
sample = frappe.db.sql("""
|
|
SELECT name, employee_name, status, department, designation, employee_number, date_of_joining
|
|
FROM "tabEmployee" ORDER BY name LIMIT 10
|
|
""", as_dict=True)
|
|
print("\nSample:")
|
|
for e in sample:
|
|
print(" {} {} [{}] dept={} desig={} legacy_id={} joined={}".format(
|
|
e["name"], e["employee_name"], e["status"],
|
|
e["department"], e["designation"], e["employee_number"], e["date_of_joining"]))
|
|
|
|
elapsed = time.time() - T_TOTAL
|
|
print("\n" + "="*60)
|
|
print("DONE in {:.1f}s".format(elapsed))
|
|
print("="*60)
|