Files
shawn 1db6475f83 feat: asset naming, location extraction, and location display
- New asset naming: {MID} - {Customer} / {Address} / {Building details}
- Extracted 660 building_names from customer data (Disney resorts, hotels)
- 1,704 assets now have location data (building_name, floor, etc.)
- Location section in detail view now shows building, floor, trailer, room
- Added has_gps filter to API for map loading
- Added location_source column to track GPS origin
2026-05-28 23:58:56 -04:00

302 lines
10 KiB
Python

#!/usr/bin/env python3
"""
Apply GPS coordinates from multiple sources to canteen assets DB.
Sources (priority order):
1. Booking GPS (MSFS technician check-in) — most reliable
2. Photo EXIF GPS (OCR'd technician photos)
3. exif-test-dev GPS (iPhone uploads)
Usage:
python3 scripts/apply_gps_sources.py # dry-run
python3 scripts/apply_gps_sources.py --apply # write to DB
python3 scripts/apply_gps_sources.py --force # overwrite existing GPS too
"""
import json
import sqlite3
import sys
from collections import defaultdict
from pathlib import Path
ASSET_DB = str(Path(__file__).resolve().parent.parent / "assets.db")
MSFS_DATA_DIR = "/home/oplabs/projects/ms-field-service-extraction/web/static/data"
MSFS_BACKUP_DB = "/home/oplabs/projects/ms-field-service-extraction/data-samples/msfs_backup/fieldservicecanteen.crm.dynamics.com_9fc6c50c-b097-f011-b4cb-7ced8d1b15a2_data.db"
def load_merged_assets() -> dict:
"""Load merged MSFS<->canteen asset mappings. Returns guid->machine_id."""
with open(f"{MSFS_DATA_DIR}/merged-assets.json") as f:
merged = json.load(f)
guid_to_mid = {}
for a in merged["assets"]:
cmid = a.get("canteen_machine_id")
guid = a.get("msfs_canteen_connect_guid", "")
if cmid and guid:
guid_to_mid[guid.upper()] = cmid
return guid_to_mid
def get_booking_gps(guid_to_mid: dict) -> dict:
"""Extract booking GPS from MSFS backup, grouped by machine_id."""
db = sqlite3.connect(MSFS_BACKUP_DB)
# Work order -> customer asset
wo_to_asset = {}
for w in db.execute(
'SELECT "msdyn_workorderid", "msdyn_customerasset!id" FROM "msdyn_workorder" WHERE "msdyn_customerasset!id" IS NOT NULL'
):
wo_to_asset[w[0]] = w[1]
# Customer asset -> GUID
asset_info = {}
for c in db.execute(
'SELECT "msdyn_customerassetid", "hsl_canteenconnectguid" FROM "msdyn_customerasset"'
):
asset_info[c[0]] = (c[1] or "").upper() if c[1] else ""
# Collect booking GPS points
booking_by_mid = defaultdict(list)
for r in db.execute(
"""
SELECT b."msdyn_latitude", b."msdyn_longitude", b."msdyn_workorder!id"
FROM "bookableresourcebooking" b
WHERE b."msdyn_latitude" IS NOT NULL AND b."msdyn_longitude" IS NOT NULL
"""
):
lat, lon, wo_id = r
if not wo_id or wo_id not in wo_to_asset:
continue
asset_id = wo_to_asset[wo_id]
if not asset_id or asset_id not in asset_info:
continue
guid = asset_info[asset_id]
if guid in guid_to_mid:
mid = guid_to_mid[guid]
booking_by_mid[mid].append((lat, lon))
# Average per machine
result = {}
for mid, points in booking_by_mid.items():
result[mid] = {
"lat": sum(p[0] for p in points) / len(points),
"lon": sum(p[1] for p in points) / len(points),
"source": "booking",
"count": len(points),
}
return result
def get_photo_gps() -> dict:
"""Extract GPS from OCR'd technician photos."""
try:
with open(f"{MSFS_DATA_DIR}/ocr_results.json") as f:
ocr = json.load(f)
except FileNotFoundError:
return {}
photo_by_mid = defaultdict(list)
for k, v in ocr.items():
if v.get("has_gps") and v.get("machine_id") and v.get("gps_lat") and v.get("gps_lon"):
mid = v["machine_id"]
# Normalize: strip leading zeros from last part
parts = mid.split("-")
last_part = parts[-1]
if last_part.startswith("0") and len(last_part) > 5:
last_part = last_part.lstrip("0")
photo_by_mid[last_part].append((v["gps_lat"], v["gps_lon"]))
result = {}
for mid, points in photo_by_mid.items():
result[mid] = {
"lat": sum(p[0] for p in points) / len(points),
"lon": sum(p[1] for p in points) / len(points),
"source": "photo",
"count": len(points),
}
return result
def get_exif_test_gps() -> dict:
"""Extract GPS from exif-test-dev iPhone uploads."""
exif_dev_db = str(
Path.home() / "projects" / "exif-test-dev" / "photos.db"
)
if not Path(exif_dev_db).exists():
return {}
db = sqlite3.connect(exif_dev_db)
result = {}
for r in db.execute(
"SELECT machine_id, gps_lat, gps_lng FROM photos WHERE machine_id IS NOT NULL AND gps_lat IS NOT NULL"
):
mid, lat, lng = r
if mid not in result:
result[mid] = {"points": [], "source": "exif_dev"}
result[mid]["points"].append((lat, lng))
averaged = {}
for mid, data in result.items():
pts = data["points"]
averaged[mid] = {
"lat": sum(p[0] for p in pts) / len(pts),
"lon": sum(p[1] for p in pts) / len(pts),
"source": "exif_dev",
"count": len(pts),
}
return averaged
def get_seed_gps(conn: sqlite3.Connection) -> dict:
"""Extract GPS from Cantaloupe seed data if available."""
try:
cur = conn.execute(
"SELECT machine_id, json_extract(data, '$.Latitude'), json_extract(data, '$.Longitude') "
"FROM seed_data "
"WHERE json_extract(data, '$.Latitude') IS NOT NULL "
"AND json_extract(data, '$.Longitude') IS NOT NULL"
)
result = {}
for row in cur.fetchall():
mid, lat, lon = row
if lat and lon:
try:
result[mid] = {
"lat": float(lat),
"lon": float(lon),
"source": "seed",
"count": 1,
}
except (ValueError, TypeError):
pass
return result
except sqlite3.OperationalError:
return {}
def main():
apply_changes = "--apply" in sys.argv
force_update = "--force" in sys.argv
mode = "DRY RUN (no writes)" if not apply_changes else "APPLYING changes"
print(f"Assets DB: {ASSET_DB}")
print(f"Mode: {mode}")
if force_update:
print(" (--force: will overwrite existing GPS coordinates too)")
print()
# 1. Load all GPS sources
print("Loading GPS sources...")
guid_to_mid = load_merged_assets()
print(f" GUID→machine_id mappings: {len(guid_to_mid)}")
booking_gps = get_booking_gps(guid_to_mid)
print(f" Booking GPS: {len(booking_gps)} machine IDs")
photo_gps = get_photo_gps()
print(f" Photo EXIF GPS: {len(photo_gps)} machine IDs")
exif_gps = get_exif_test_gps()
print(f" EXIF test GPS: {len(exif_gps)} machine IDs")
# Connect to assets DB
conn = sqlite3.connect(ASSET_DB)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=OFF")
seed_gps = get_seed_gps(conn)
print(f" Seed (Cantaloupe) GPS: {len(seed_gps)} machine IDs")
# 2. Get current GPS state from assets DB
cur = conn.execute("SELECT machine_id, latitude, longitude, name, company FROM assets")
existing = {}
for r in cur.fetchall():
existing[r[0]] = {"lat": r[1], "lon": r[2], "name": r[3], "company": r[4]}
existing_count = sum(1 for v in existing.values() if v["lat"] is not None)
print(f"\nCurrent assets with GPS: {existing_count} / {len(existing)}")
# 3. Prioritize and merge sources
all_mids = set()
all_mids.update(booking_gps.keys())
all_mids.update(photo_gps.keys())
all_mids.update(exif_gps.keys())
all_mids.update(seed_gps.keys())
updates = [] # (lat, lon, source, machine_id)
skipped_existing = []
no_match = []
for mid in sorted(all_mids):
# Determine best GPS: booking > photo > exif_dev > seed
best = None
for src_dict, src_name in [
(booking_gps, "booking"),
(photo_gps, "photo"),
(exif_gps, "exif_dev"),
(seed_gps, "seed"),
]:
if mid in src_dict:
best = src_dict[mid]
break
if not best:
continue
# Check if asset exists in DB
if mid not in existing:
no_match.append(mid)
continue
existing_gps = existing[mid]
has_gps = existing_gps["lat"] is not None and existing_gps["lon"] is not None
if has_gps and not force_update:
# Code for checking distance removed — just skip
skipped_existing.append((mid, existing_gps["lat"], existing_gps["lon"], best["lat"], best["lon"]))
continue
old_label = f" ({existing_gps['lat']:.6f},{existing_gps['lon']:.6f})" if has_gps else " (no GPS)"
updates.append((best["lat"], best["lon"], best["source"], mid))
print(
f" {mid:>6s} {old_label} → ({best['lat']:.6f}, {best['lon']:.6f}) [{best['source']:>7s}] "
f"{existing_gps['name'][:40]}"
)
# 4. Apply
if apply_changes and updates:
conn.executemany(
"UPDATE assets SET latitude=?, longitude=?, location_source=?, updated_at=datetime('now') WHERE machine_id=?",
[(lat, lon, src, mid) for lat, lon, src, mid in updates],
)
conn.commit()
print(f"\n ✅ Applied {len(updates)} GPS updates")
elif updates:
print(f"\n Would update {len(updates)} assets. Run with --apply to write.")
else:
print("\n No updates needed.")
# Summary
print(f"\n{'=' * 60}")
print(f"SUMMARY")
print(f"{'=' * 60}")
print(f" Total GPS sources combined: {len(all_mids)}")
print(f" Updates to apply: {len(updates)}")
print(f" Skipped (already has GPS): {len(skipped_existing)}")
print(f" No matching asset in DB: {len(no_match)}")
# Verify final count
if apply_changes:
cur = conn.execute("SELECT COUNT(*) FROM assets WHERE latitude IS NOT NULL")
final_count = cur.fetchone()[0]
print(f"\n Final assets with GPS: {final_count} / {len(existing)}")
# Print a few no-match examples
if no_match:
print(f"\n Sample unmapped machine IDs: {sorted(no_match)[:5]}")
conn.close()
if __name__ == "__main__":
main()