- 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>
152 lines
4.8 KiB
Python
152 lines
4.8 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Geocode Service Locations using the rqa_addresses (Adresses Québec) table.
|
|
Matches by extracting numero from address_line, then fuzzy-matching
|
|
against rqa_addresses using city + street similarity.
|
|
|
|
Run inside erpnext-backend-1:
|
|
nohup python3 /tmp/geocode_locations.py > /tmp/geocode_locations.log 2>&1 &
|
|
"""
|
|
import psycopg2
|
|
import re
|
|
from datetime import datetime, timezone
|
|
|
|
PG = {"host": "db", "port": 5432, "user": "postgres", "password": "123",
|
|
"dbname": "_eb65bdc0c4b1b2d6"}
|
|
|
|
# Common city name normalizations for matching
|
|
CITY_NORMALIZE = {
|
|
"st-": "saint-",
|
|
"ste-": "sainte-",
|
|
"st ": "saint-",
|
|
"ste ": "sainte-",
|
|
"st.": "saint-",
|
|
"ste.": "sainte-",
|
|
}
|
|
|
|
def log(msg):
|
|
print("[{}] {}".format(datetime.now(timezone.utc).strftime("%H:%M:%S"), msg), flush=True)
|
|
|
|
def normalize_city(city):
|
|
"""Normalize city name for matching."""
|
|
c = city.lower().strip()
|
|
for old, new in CITY_NORMALIZE.items():
|
|
if c.startswith(old):
|
|
c = new + c[len(old):]
|
|
# Remove accents would be ideal but keep it simple
|
|
# Remove " de " variants
|
|
c = re.sub(r'\s+de\s+', '-', c)
|
|
c = re.sub(r'\s+', '-', c)
|
|
return c
|
|
|
|
def extract_numero(address):
|
|
"""Extract civic number from address string."""
|
|
addr = address.strip()
|
|
# Try to find number at start: "1185 Route 133" → "1185"
|
|
m = re.match(r'^(\d+[A-Za-z]?)\s*[,\s]', addr)
|
|
if m:
|
|
return m.group(1)
|
|
# Just digits at start
|
|
m = re.match(r'^(\d+)', addr)
|
|
if m:
|
|
return m.group(1)
|
|
return None
|
|
|
|
def main():
|
|
log("=== Geocode Service Locations via AQ ===")
|
|
|
|
pg = psycopg2.connect(**PG)
|
|
pg.autocommit = False
|
|
pgc = pg.cursor()
|
|
|
|
# Get locations needing GPS
|
|
pgc.execute("""
|
|
SELECT name, address_line, city, postal_code
|
|
FROM "tabService Location"
|
|
WHERE (latitude = 0 OR latitude IS NULL)
|
|
AND address_line NOT IN ('N/A', '', 'xxx')
|
|
AND city NOT IN ('N/A', '')
|
|
""")
|
|
locations = pgc.fetchall()
|
|
log(" {} locations to geocode".format(len(locations)))
|
|
|
|
matched = missed = 0
|
|
|
|
for i, (loc_name, addr, city, postal) in enumerate(locations):
|
|
lat = lon = None
|
|
|
|
# Strategy 1: Match by postal code + numero (most precise)
|
|
numero = extract_numero(addr)
|
|
if postal and len(postal) >= 6:
|
|
postal_clean = postal.strip().upper().replace(" ", "")
|
|
if numero:
|
|
pgc.execute("""
|
|
SELECT latitude, longitude FROM rqa_addresses
|
|
WHERE REPLACE(UPPER(code_postal), ' ', '') = %s AND numero = %s
|
|
LIMIT 1
|
|
""", (postal_clean, numero))
|
|
row = pgc.fetchone()
|
|
if row:
|
|
lat, lon = row
|
|
|
|
# Strategy 2: Match by numero + city + fuzzy street
|
|
if not lat and numero and city:
|
|
city_norm = normalize_city(city)
|
|
# Build search string for trigram matching
|
|
search = "{} {}".format(numero, addr.lower())
|
|
|
|
pgc.execute("""
|
|
SELECT latitude, longitude,
|
|
similarity(search_text, %s) as sim
|
|
FROM rqa_addresses
|
|
WHERE numero = %s
|
|
AND LOWER(ville) %% %s
|
|
ORDER BY similarity(search_text, %s) DESC
|
|
LIMIT 1
|
|
""", (search, numero, city_norm, search))
|
|
row = pgc.fetchone()
|
|
if row and row[2] > 0.15:
|
|
lat, lon = row[0], row[1]
|
|
|
|
# Strategy 3: Full address fuzzy match against address_full
|
|
if not lat and city:
|
|
full_addr = "{}, {}".format(addr, city).lower()
|
|
pgc.execute("""
|
|
SELECT latitude, longitude,
|
|
similarity(address_full, %s) as sim
|
|
FROM rqa_addresses
|
|
WHERE address_full %% %s
|
|
ORDER BY similarity(address_full, %s) DESC
|
|
LIMIT 1
|
|
""", (full_addr, full_addr, full_addr))
|
|
row = pgc.fetchone()
|
|
if row and row[2] > 0.25:
|
|
lat, lon = row[0], row[1]
|
|
|
|
if lat and lon:
|
|
pgc.execute("""
|
|
UPDATE "tabService Location"
|
|
SET latitude = %s, longitude = %s, modified = NOW()
|
|
WHERE name = %s
|
|
""", (lat, lon, loc_name))
|
|
matched += 1
|
|
else:
|
|
missed += 1
|
|
|
|
if (matched + missed) % 500 == 0:
|
|
pg.commit()
|
|
log(" [{}/{}] matched={} missed={}".format(i+1, len(locations), matched, missed))
|
|
|
|
pg.commit()
|
|
pg.close()
|
|
|
|
log("")
|
|
log("=" * 60)
|
|
log("GEOCODE COMPLETE")
|
|
log(" Matched: {} ({:.1f}%)".format(matched, 100*matched/len(locations) if locations else 0))
|
|
log(" Missed: {}".format(missed))
|
|
log("=" * 60)
|
|
|
|
if __name__ == "__main__":
|
|
main()
|