- 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>
203 lines
9.0 KiB
Python
203 lines
9.0 KiB
Python
"""
|
|
Import additional customer details from legacy DB into ERPNext.
|
|
|
|
Adds custom fields and populates:
|
|
- invoice_delivery_method: Email/Paper/Both
|
|
- is_commercial: Commercial account flag
|
|
- is_bad_payer: Mauvais payeur flag
|
|
- tax_category_legacy: Tax group
|
|
- contact_name_legacy: Contact person
|
|
- tel_home/tel_office/cell: Phone numbers
|
|
- mandataire: Authorized representative
|
|
- exclude_fees: Frais exclusion flag
|
|
- notes_internal: Internal notes (misc)
|
|
- date_created_legacy: Account creation date
|
|
|
|
Run inside erpnext-backend-1:
|
|
/home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/import_customer_details.py
|
|
"""
|
|
import frappe
|
|
import pymysql
|
|
import os
|
|
from datetime import datetime, timezone
|
|
|
|
os.chdir("/home/frappe/frappe-bench/sites")
|
|
frappe.init(site="erp.gigafibre.ca", sites_path=".")
|
|
frappe.connect()
|
|
print("Connected:", frappe.local.site)
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# STEP 1: Create custom fields on Customer
|
|
# ═══════════════════════════════════════════════════════════════
|
|
print("Creating custom fields on Customer...")
|
|
|
|
CUSTOM_FIELDS = [
|
|
{"fieldname": "billing_section", "label": "Facturation", "fieldtype": "Section Break", "insert_after": "legacy_section"},
|
|
{"fieldname": "invoice_delivery_method", "label": "Envoi facture", "fieldtype": "Select", "options": "\nEmail\nPapier\nEmail + Papier", "insert_after": "billing_section"},
|
|
{"fieldname": "is_commercial", "label": "Compte commercial", "fieldtype": "Check", "insert_after": "invoice_delivery_method"},
|
|
{"fieldname": "is_bad_payer", "label": "Mauvais payeur", "fieldtype": "Check", "insert_after": "is_commercial"},
|
|
{"fieldname": "exclude_fees", "label": "Exclure frais", "fieldtype": "Check", "insert_after": "is_bad_payer"},
|
|
{"fieldname": "billing_col_break", "label": "", "fieldtype": "Column Break", "insert_after": "exclude_fees"},
|
|
{"fieldname": "tax_category_legacy", "label": "Groupe taxe", "fieldtype": "Select", "options": "\nFederal + Provincial (9.5%)\nFederal seulement\nExempté", "insert_after": "billing_col_break"},
|
|
{"fieldname": "contact_section", "label": "Contact détaillé", "fieldtype": "Section Break", "insert_after": "tax_category_legacy"},
|
|
{"fieldname": "contact_name_legacy", "label": "Contact", "fieldtype": "Data", "insert_after": "contact_section"},
|
|
{"fieldname": "mandataire", "label": "Mandataire", "fieldtype": "Data", "insert_after": "contact_name_legacy"},
|
|
{"fieldname": "tel_home", "label": "Téléphone maison", "fieldtype": "Data", "insert_after": "mandataire"},
|
|
{"fieldname": "contact_col_break", "label": "", "fieldtype": "Column Break", "insert_after": "tel_home"},
|
|
{"fieldname": "tel_office", "label": "Téléphone bureau", "fieldtype": "Data", "insert_after": "contact_col_break"},
|
|
{"fieldname": "cell_phone", "label": "Cellulaire", "fieldtype": "Data", "insert_after": "tel_office"},
|
|
{"fieldname": "fax", "label": "Fax", "fieldtype": "Data", "insert_after": "cell_phone"},
|
|
{"fieldname": "notes_section", "label": "Notes", "fieldtype": "Section Break", "insert_after": "fax"},
|
|
{"fieldname": "notes_internal", "label": "Notes internes", "fieldtype": "Small Text", "insert_after": "notes_section"},
|
|
{"fieldname": "email_billing", "label": "Email facturation", "fieldtype": "Data", "insert_after": "notes_internal"},
|
|
{"fieldname": "email_publipostage", "label": "Email publipostage", "fieldtype": "Data", "insert_after": "email_billing"},
|
|
{"fieldname": "date_created_legacy", "label": "Date création (legacy)", "fieldtype": "Date", "insert_after": "email_publipostage"},
|
|
]
|
|
|
|
for cf in CUSTOM_FIELDS:
|
|
existing = frappe.db.exists("Custom Field", {"dt": "Customer", "fieldname": cf["fieldname"]})
|
|
if existing:
|
|
print(" {} — already exists".format(cf["fieldname"]))
|
|
continue
|
|
try:
|
|
doc = frappe.get_doc({
|
|
"doctype": "Custom Field",
|
|
"dt": "Customer",
|
|
**cf,
|
|
})
|
|
doc.insert(ignore_permissions=True)
|
|
print(" {} — created".format(cf["fieldname"]))
|
|
except Exception as e:
|
|
print(" {} — ERR: {}".format(cf["fieldname"], str(e)[:80]))
|
|
|
|
frappe.db.commit()
|
|
print("Custom fields done.")
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# STEP 2: Load legacy data
|
|
# ═══════════════════════════════════════════════════════════════
|
|
print("\nLoading legacy data...")
|
|
conn = pymysql.connect(
|
|
host="10.100.80.100", user="facturation", password="VD67owoj",
|
|
database="gestionclient", cursorclass=pymysql.cursors.DictCursor
|
|
)
|
|
with conn.cursor() as cur:
|
|
cur.execute("""
|
|
SELECT id, customer_id, invoice_delivery, commercial, mauvais_payeur,
|
|
tax_group, contact, mandataire, tel_home, tel_office, cell, fax,
|
|
misc, email, email_autre, date_orig, frais, ppa, notes_client,
|
|
address1, address2, city, state, zip
|
|
FROM account
|
|
WHERE status = 1
|
|
""")
|
|
accounts = cur.fetchall()
|
|
conn.close()
|
|
print("Active legacy accounts: {}".format(len(accounts)))
|
|
|
|
# Customer mapping
|
|
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)))
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# STEP 3: Update customers
|
|
# ═══════════════════════════════════════════════════════════════
|
|
INVOICE_DELIVERY = {1: "Email", 2: "Papier", 3: "Email + Papier"}
|
|
TAX_GROUP = {1: "Federal + Provincial (9.5%)", 2: "Federal seulement", 3: "Exempté"}
|
|
|
|
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
|
|
|
|
print("\nUpdating customers...")
|
|
updated = 0
|
|
skipped = 0
|
|
errors = 0
|
|
|
|
for a in accounts:
|
|
acct_id = a["id"]
|
|
cust_name = cust_map.get(acct_id)
|
|
if not cust_name:
|
|
skipped += 1
|
|
continue
|
|
|
|
updates = {}
|
|
if a["invoice_delivery"]:
|
|
updates["invoice_delivery_method"] = INVOICE_DELIVERY.get(a["invoice_delivery"], "")
|
|
if a["commercial"]:
|
|
updates["is_commercial"] = 1
|
|
if a["mauvais_payeur"]:
|
|
updates["is_bad_payer"] = 1
|
|
if a["frais"]:
|
|
updates["exclude_fees"] = 1
|
|
if a["tax_group"]:
|
|
updates["tax_category_legacy"] = TAX_GROUP.get(a["tax_group"], "")
|
|
if a["contact"]:
|
|
updates["contact_name_legacy"] = a["contact"]
|
|
if a["mandataire"]:
|
|
updates["mandataire"] = a["mandataire"]
|
|
if a["tel_home"]:
|
|
updates["tel_home"] = a["tel_home"]
|
|
if a["tel_office"]:
|
|
updates["tel_office"] = a["tel_office"]
|
|
if a["cell"]:
|
|
updates["cell_phone"] = a["cell"]
|
|
if a["fax"]:
|
|
updates["fax"] = a["fax"]
|
|
if a["misc"]:
|
|
updates["notes_internal"] = a["misc"]
|
|
if a["email"]:
|
|
updates["email_billing"] = a["email"]
|
|
if a["email_autre"]:
|
|
updates["email_publipostage"] = a["email_autre"]
|
|
created = ts_to_date(a["date_orig"])
|
|
if created:
|
|
updates["date_created_legacy"] = created
|
|
|
|
if not updates:
|
|
continue
|
|
|
|
# Truncate long values for Data fields (varchar 140)
|
|
for field in ["contact_name_legacy", "mandataire", "tel_home", "tel_office",
|
|
"cell_phone", "fax", "email_billing", "email_publipostage"]:
|
|
if field in updates and updates[field] and len(str(updates[field])) > 140:
|
|
updates[field] = str(updates[field])[:140]
|
|
|
|
# Build SET clause
|
|
set_parts = []
|
|
values = []
|
|
for field, val in updates.items():
|
|
set_parts.append('"{}" = %s'.format(field))
|
|
values.append(val)
|
|
values.append(cust_name)
|
|
|
|
try:
|
|
frappe.db.sql(
|
|
'UPDATE "tabCustomer" SET {} WHERE name = %s'.format(", ".join(set_parts)),
|
|
values
|
|
)
|
|
updated += 1
|
|
except Exception as e:
|
|
errors += 1
|
|
if errors <= 5:
|
|
print(" ERR {}: {}".format(cust_name, str(e)[:100]))
|
|
frappe.db.rollback()
|
|
|
|
if updated % 1000 == 0:
|
|
frappe.db.commit()
|
|
print(" Progress: {}/{}".format(updated, len(accounts)))
|
|
|
|
frappe.db.commit()
|
|
print("\nUpdated: {} customers".format(updated))
|
|
print("Skipped (no mapping): {}".format(skipped))
|
|
|
|
print("\n" + "=" * 70)
|
|
print("DONE")
|
|
print("=" * 70)
|