diff --git a/scripts/apply_gps_sources.py b/scripts/apply_gps_sources.py new file mode 100644 index 0000000..b699045 --- /dev/null +++ b/scripts/apply_gps_sources.py @@ -0,0 +1,301 @@ +#!/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() diff --git a/scripts/geocode_asset_addresses.py b/scripts/geocode_asset_addresses.py new file mode 100644 index 0000000..c2eac01 --- /dev/null +++ b/scripts/geocode_asset_addresses.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 +""" +Geocode all asset addresses from the assets DB, cache results, and update GPS. + +Sources (priority): + 1. Existing geocache.json + 2. Fresh Nominatim (OpenStreetMap) geocoding — rate-limited to 1/sec + +Usage: + python3 scripts/geocode_asset_addresses.py # dry-run + python3 scripts/geocode_asset_addresses.py --apply # write to DB + python3 scripts/geocode_asset_addresses.py --force # re-geocode cached too +""" +import json +import sqlite3 +import sys +import time +import urllib.parse +import urllib.request +from pathlib import Path + +ASSET_DB = str(Path(__file__).resolve().parent.parent / "assets.db") +GEOCODE_CACHE = str( + Path.home() + / "projects" + / "ms-field-service-extraction" + / "web" + / "static" + / "data" + / "geocache.json" +) + +USER_AGENT = "CanteenAssetTracker/1.0 (internal tool; canteen.ourpad.casa)" +NOMINATIM_URL = "https://nominatim.openstreetmap.org/search" +RATE_LIMIT = 1.1 # seconds between requests + + +def load_geocache(): + """Load existing geocache from file.""" + if Path(GEOCODE_CACHE).exists(): + with open(GEOCODE_CACHE) as f: + return json.load(f) + return {} + + +def save_geocache(cache): + """Save geocache back to file.""" + Path(GEOCODE_CACHE).parent.mkdir(parents=True, exist_ok=True) + with open(GEOCODE_CACHE, "w") as f: + json.dump(cache, f, indent=2) + + +def geocode(address): + """Geocode a single address via Nominatim. Returns (lat, lng) or (None, None).""" + if not address or not address.strip(): + return None, None + + params = urllib.parse.urlencode({ + "q": address.strip(), + "format": "json", + "limit": 1, + "addressdetails": "0", + }) + url = f"{NOMINATIM_URL}?{params}" + req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) + + try: + with urllib.request.urlopen(req, timeout=10) as resp: + data = json.loads(resp.read()) + if data and len(data) > 0: + lat = float(data[0]["lat"]) + lng = float(data[0]["lon"]) + return lat, lng + except Exception as e: + print(f" ⚠ Error geocoding '{address[:60]}': {e}", file=sys.stderr) + + return None, None + + +def make_addr_key(asset): + """Build a normalized address key from asset record.""" + parts = [ + asset.get("address", "") or "", + asset.get("seed_location", "") or "", + ] + # Build full address with city/state + addr = "" + if asset.get("address"): + addr = asset["address"].strip() + if asset.get("seed_city"): + addr += f", {asset['seed_city'].strip()}" + if asset.get("state"): + addr += f", {asset['state'].strip()}" + if asset.get("postal_code"): + addr += f", {asset['postal_code'].strip()}" + elif asset.get("seed_location"): + addr = asset["seed_location"].strip() + + return addr.strip().lower() if addr else None + + +def build_full_address(asset): + """Build the cleanest full address string for geocoding. + + Uses only the street address + city + state + zip. + Excludes seed_location (extra venue/building names confuse Nominatim). + """ + addr = (asset.get("address") or "").strip() + if not addr: + addr = (asset.get("seed_location") or "").strip() + + loc_parts = [] + if asset.get("seed_city"): + loc_parts.append(asset["seed_city"].strip()) + if asset.get("state"): + loc_parts.append(asset["state"].strip()) + if asset.get("postal_code"): + loc_parts.append(asset["postal_code"].strip()) + + if loc_parts: + # Add "FL" if not already present in the state field + if "FL" not in loc_parts and "Florida" not in loc_parts: + loc_parts.insert(0, "FL") + addr += ", " + ", ".join(loc_parts) + elif not addr: + return None + + return addr.strip().strip(",").strip() + + +def main(): + apply_changes = "--apply" in sys.argv + force = "--force" in sys.argv + + mode = "DRY RUN (no writes)" if not apply_changes else "APPLYING changes" + print(f"Assets DB: {ASSET_DB}") + print(f"Geocache: {GEOCODE_CACHE}") + print(f"Mode: {mode}") + if force: + print(" (--force: re-geocode even cached addresses)") + print() + + # 1. Load existing cache + cache = load_geocache() + cached_with_coords = sum(1 for v in cache.values() if v.get("lat")) + cached_misses = sum(1 for v in cache.values() if not v.get("lat")) + print(f"📦 Geocache: {len(cache)} entries ({cached_with_coords} with coords, {cached_misses} misses)") + + # 2. Get all assets without GPS but with addresses + conn = sqlite3.connect(ASSET_DB) + conn.execute("PRAGMA journal_mode=WAL") + cur = conn.cursor() + + cur.execute(""" + SELECT machine_id, name, address, seed_location, seed_city, state, postal_code + FROM assets + WHERE latitude IS NULL + AND ((address IS NOT NULL AND address != '') + OR (seed_location IS NOT NULL AND seed_location != '')) + ORDER BY machine_id + """) + rows = cur.fetchall() + print(f"📋 Assets needing geocoding: {len(rows)}") + + if not rows: + print("✅ No assets need geocoding. Done.") + return + + # 3. Build (addr_key -> machine_ids) map for unique addresses + addr_to_mids = {} + addr_to_full = {} + asset_cols = ["machine_id", "name", "address", "seed_location", "seed_city", "state", "postal_code"] + col = dict(zip(asset_cols, range(len(asset_cols)))) + + for row in rows: + asset = {k: row[col[k]] for k in asset_cols} + addr_key = make_addr_key(asset) + if not addr_key: + continue + if addr_key not in addr_to_mids: + addr_to_mids[addr_key] = [] + addr_to_full[addr_key] = build_full_address(asset) + addr_to_mids[addr_key].append(row[col["machine_id"]]) + + print(f"📍 Unique addresses to resolve: {len(addr_to_mids)}") + + # 4. Resolve each unique address + resolved = {} # addr_key -> (lat, lon) + still_needed = 0 + fresh_calls = 0 + + for addr_key, mids in sorted(addr_to_mids.items()): + # Check cache first + cached = cache.get(addr_key, {}) + if cached and not force: + lat, lng = cached.get("lat"), cached.get("lng") + if lat and lng: + resolved[addr_key] = (lat, lng) + continue + elif cached.get("lat") is None: # previously cached as miss + still_needed += 1 + continue + + # Need to geocode fresh + full_addr = addr_to_full[addr_key] + print(f" 🌐 Geocoding [{fresh_calls+1}]: {full_addr[:70]}...") + lat, lng = geocode(full_addr) + + # Save to cache + cache[addr_key] = {"address": full_addr, "lat": lat, "lng": lng} + save_geocache(cache) + + if lat and lng: + resolved[addr_key] = (lat, lng) + print(f" ✅ ({lat:.5f}, {lng:.5f}) — affects {len(mids)} assets") + else: + print(f" ❌ No result — affects {len(mids)} assets") + still_needed += 1 + + fresh_calls += 1 + if fresh_calls >= 3 and not apply_changes: + # In dry-run mode, only do a few to show what's possible + print(f"\n ... stopping after {fresh_calls} (dry-run). Use --apply to geocode all ~{len(addr_to_mids)} addresses.") + break + time.sleep(RATE_LIMIT) + + # 5. Count updates + total_updatable = 0 + updates = [] + for addr_key, mids in addr_to_mids.items(): + if addr_key in resolved: + lat, lon = resolved[addr_key] + for mid in mids: + updates.append((lat, lon, "geocoded", mid)) + total_updatable += len(mids) + + updates_by_source = sum(len(m) for a, m in addr_to_mids.items() if a in resolved) + print(f"\n{'=' * 60}") + print(f"📊 SUMMARY") + print(f"{'=' * 60}") + print(f" Total assets needing GPS: {len(rows)}") + print(f" Unique addresses to geocode: {len(addr_to_mids)}") + print(f" Resolved from cache/source: {len(resolved) + (cached_with_coords - sum(1 for k in resolved if k not in cache))}") + print(f" Freshly geocoded: {fresh_calls}") + print(f" Still unresolved: {still_needed}") + print(f" Assets that can be updated: {updates_by_source}") + + if apply_changes and updates: + # Apply in batches for speed + 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 from geocoding") + elif updates: + print(f"\n Run with --apply to write {len(updates)} GPS updates to DB.") + + # Final count + cur = conn.execute("SELECT COUNT(*) FROM assets WHERE latitude IS NOT NULL") + final_gps = cur.fetchone()[0] + cur = conn.execute("SELECT COUNT(*) FROM assets") + total = cur.fetchone()[0] + print(f"\n Final: {final_gps} / {total} assets have GPS coordinates") + + conn.close() + + +if __name__ == "__main__": + main() diff --git a/scripts/migrate_asset_names.py b/scripts/migrate_asset_names.py new file mode 100644 index 0000000..f123494 --- /dev/null +++ b/scripts/migrate_asset_names.py @@ -0,0 +1,316 @@ +#!/usr/bin/env python3 +""" +Migrate asset names to readable format and populate location fields. + +Name format: {machine_id} - {customer} / {address} / {building_name}, Floor {floor}, Room {room} + +Also populates missing location fields (building_name, floor, etc.) +from seed_location, customer_name, and address data. + +Usage: + python3 scripts/migrate_asset_names.py # dry-run + python3 scripts/migrate_asset_names.py --apply # write to DB + python3 scripts/migrate_asset_names.py --force # overwrite existing location fields too +""" +import re +import sqlite3 +import sys +from pathlib import Path + +ASSET_DB = str(Path(__file__).resolve().parent.parent / "assets.db") + + +# ─── Building name extraction patterns ────────────────────────────────────── + +# Disney hotel/resort name extraction from customer_name +# "D-All Star Music GUEST" → "All Star Music" +# "D-Port Orleans Rvsd GUEST" → "Port Orleans Riverside" +DISNEY_RESORT_PATTERNS = [ + # "D-All Star Music GUEST" -> "All Star Music" + (re.compile(r'^D[- ](.*?)\s+(?:GUEST|CAST|GUEST ROOM|GST)$', re.I), lambda m: m.group(1).strip()), + # "D-All Star Music GUEST - MOV10" -> "All Star Music" + (re.compile(r'^D[- ](.*?)\s+GUEST\s*-\s*\w+$', re.I), lambda m: m.group(1).strip()), + # "D-CORONADO SPRINGS GUEST" -> "Coronado Springs" + (re.compile(r'^D[- ](.*?)\s+(?:GUEST|CAST|ROOM|GUEST ROOM)$', re.I), lambda m: m.group(1).strip().title()), + # Universal Studios / Valencia / etc. + (re.compile(r'^Universal Studios (.+)$', re.I), lambda m: f'Universal Studios {m.group(1).strip()}'), +] + +# Manual overrides +BUILDING_NAME_MAP = { + "D-Port Orleans Rvsd GUEST": "Port Orleans Riverside", + "D-Port Orleans Frrnc GUEST": "Port Orleans French Quarter", + "D-CORONADO SPRINGS GUEST": "Coronado Springs", + "D-All Star Music GUEST": "All Star Music", + "D-All Star Movie Guest": "All Star Movies", + "D-All Star Sports Guest": "All Star Sports", + "D-ART OF ANIMATION Guest": "Art of Animation", + "D-POP CENTURY GUEST": "Pop Century", + "D-CARIBBEAN BEACH GUEST": "Caribbean Beach", + "D-Saratoga Springs GUEST": "Saratoga Springs", + "D-POLYNESIAN RESORT GUEST": "Polynesian Village Resort", + "D-Animal Kngdm LodgeGuest": "Animal Kingdom Lodge", + "D-Contemporary Guest": "Contemporary Resort", + "D-Grand Floridian Guest": "Grand Floridian Resort", + "D-WILDERNESS LODGE GUEST": "Wilderness Lodge", + "D-Yacht & Beach Guest": "Yacht & Beach Club", + "D-BoardWalk Guest": "BoardWalk Inn", + "D-Riviera Resort Guest": "Riviera Resort", + "D-Island Tower Polynesian": "Polynesian Village Resort - Island Tower", + "D-DISNEY VENDING CAST": "Disney Vending", + "D-Disney Springs CAST": "Disney Springs", + "D-Magic Kingdom CAST": "Magic Kingdom", + "D-Epcot CAST": "Epcot", + "D-Hollywood Studios CAST": "Hollywood Studios", + "D-Animal Kingdom CAST": "Animal Kingdom", + "D-Celebration CAST": "Celebration", + "D-WIDE WORLD SPORTS GUEST": "ESPN Wide World of Sports", + "D-DISNEY WORLD SS CAST": "Disney World Shared Services", +} + + +def clean_customer_name(raw: str) -> str: + """Clean a customer name for display. + + D-All Star Music GUEST → All Star Music Guest + Home Depot #0232 Southlan → Home Depot #0232 Southlan + """ + if not raw: + return "" + name = raw + # Remove D- prefix (Disney identifier) + if name.startswith("D-") or name.startswith("D "): + name = name[2:] + # Remove GUEST/CAST/ROOM suffixes for cleaner display + name = re.sub(r'\s+(?:GUEST|CAST|GUEST ROOM|GST|GUEST?)\s*$', '', name, flags=re.I) + name = name.strip() + return name + + +def best_customer_for_name(asset: dict) -> str: + """Get the best customer string for the asset name.""" + c = asset.get("customer_name", "") or "" + if c: + return clean_customer_name(c) + return "" + + +def build_location_string(asset: dict) -> str: + """Build the location/address part for the asset name.""" + # Use address if available + addr = (asset.get("address") or "").strip() + # If no address, try seed_location minus customer prefix + if not addr: + seed = (asset.get("seed_location") or "").strip() + if seed and " - " in seed: + addr = seed.split(" - ", 1)[1].strip() + elif seed: + addr = seed + return addr + + +def build_building_details(asset: dict) -> str: + """Build building details string (building_name, floor, etc.).""" + parts = [] + + bldg = (asset.get("building_name") or "").strip() + floor = (asset.get("floor") or "").strip() + room = (asset.get("room") or "").strip() + bldg_num = (asset.get("building_number") or "").strip() + trailer = (asset.get("trailer_number") or "").strip() + + if bldg: + parts.append(bldg) + if bldg_num and (bldg not in bldg_num and bldg_num not in bldg): + parts.append(f"Bldg {bldg_num}") + if trailer: + parts.append(f"Trailer {trailer}") + if floor: + parts.append(f"Floor {floor}") + if room: + parts.append(f"Room {room}") + + return ", ".join(parts) + + +def build_new_name(asset: dict) -> str: + """Build the new asset name.""" + mid = (asset.get("machine_id") or "").strip() + + # Customer — cleaned + customer = best_customer_for_name(asset) + + # Location (street address or seed_location minus customer prefix) + location = build_location_string(asset) + loc_clean = "" + if location: + loc_clean = location.split(",")[0].strip() + + # Building details + building = build_building_details(asset) + + # Avoid dupes: if building starts with loc_clean, drop the building + if building and location: + bldg_lower = building.lower() + loc_lower = loc_clean.lower() + if bldg_lower.startswith(loc_lower) or loc_lower.startswith(bldg_lower): + # Dupes — keep whichever is more descriptive + if len(building) > len(loc_clean): + loc_clean = "" + else: + building = "" + + # Assemble + parts = [mid] + + if customer: + parts.append(f" - {customer}") + + if location and loc_clean: + parts.append(f" / {loc_clean}") + + if building: + parts.append(f" / {building}") + + return "".join(parts) + + +def infer_building_from_customer(customer_name: str) -> str: + """Try to extract a building/resort name from the customer name.""" + if not customer_name: + return "" + if customer_name in BUILDING_NAME_MAP: + return BUILDING_NAME_MAP[customer_name] + for pat, func in DISNEY_RESORT_PATTERNS: + m = pat.search(customer_name) + if m: + return func(m) + return "" + + +def main(): + apply_changes = "--apply" in sys.argv + force = "--force" in sys.argv + mode = "DRY RUN" if not apply_changes else "APPLYING" + + print(f"DB: {ASSET_DB}") + print(f"Mode: {mode}") + if force: + print(" (--force: overwrite existing location fields)") + print() + + conn = sqlite3.connect(ASSET_DB) + conn.execute("PRAGMA journal_mode=WAL") + + # Load all assets + cur = conn.execute(""" + SELECT id, machine_id, name, customer_name, seed_location, address, + building_name, floor, room, trailer_number, building_number, + location_area, place + FROM assets ORDER BY id + """) + rows = cur.fetchall() + total = len(rows) + print(f"Loaded {total} assets") + + cols = ["id", "machine_id", "name", "customer_name", "seed_location", "address", + "building_name", "floor", "room", "trailer_number", "building_number", + "location_area", "place"] + + stats = { + "name_changed": 0, + "building_name_filled": 0, + "floor_filled": 0, + "building_number_filled": 0, + } + + name_updates = [] + bldg_updates = [] + + for row in rows: + asset = dict(zip(cols, row)) + + # ── 1. Infer building_name from customer_name if empty ── + old_bldg = (asset["building_name"] or "").strip() + new_bldg = old_bldg + if not old_bldg or force: + inferred = infer_building_from_customer(asset["customer_name"] or "") + if inferred and inferred != old_bldg: + new_bldg = inferred + if not old_bldg: + stats["building_name_filled"] += 1 + if apply_changes: + bldg_updates.append((new_bldg, "building_name", asset["id"])) + if mode == "DRY RUN" and not old_bldg: + print(f" [{asset['machine_id']}] building_name '{inferred}' ← from '{asset['customer_name']}'") + + # ── 2. Build new name ── + old_name = (asset["name"] or "").strip() + new_name = build_new_name(asset) + + if new_name != old_name: + stats["name_changed"] += 1 + name_updates.append((new_name, asset["id"])) + if mode == "DRY RUN" and stats["name_changed"] <= 10: + print(f" Name: {old_name[:60]}") + print(f" → {new_name[:80]}") + print() + + # ── Apply ── + if apply_changes: + if name_updates: + conn.executemany( + "UPDATE assets SET name=?, updated_at=datetime('now') WHERE id=?", + name_updates, + ) + # Apply location updates + for value, column, aid in bldg_updates: + conn.execute(f"UPDATE assets SET {column}=?, updated_at=datetime('now') WHERE id=?", (value, aid)) + conn.commit() + + # Verify + cur = conn.execute("SELECT COUNT(*) FROM assets WHERE building_name != ''") + bldg_count = cur.fetchone()[0] + cur = conn.execute("SELECT COUNT(*) FROM assets WHERE floor != ''") + floor_count = cur.fetchone()[0] + print(f"\n ✅ Applied {len(name_updates)} name changes, {len(bldg_updates)} location updates") + else: + # Show some sample new names + bldg_filled = stats["building_name_filled"] + if name_updates: + print(f"\n Sample new names:") + conn2 = sqlite3.connect(ASSET_DB) + for mid in ["95339", "95417", "98271", "90009", "95301", "68601"]: + cur2 = conn2.execute( + "SELECT id, machine_id, name, customer_name, address, building_name, floor FROM assets WHERE machine_id=?", + (mid,) + ) + for r2 in cur2.fetchall(): + a = dict(zip(cols, r2)) + print(f"\n {a['machine_id']}") + print(f" Old: {a['name'][:70]}") + print(f" New: {build_new_name(a)[:70]}") + if a.get("customer_name"): + print(f" Bldg inferred: '{infer_building_from_customer(a['customer_name'])}'") + conn2.close() + + # Summary + print(f"\n{'=' * 60}") + print(f"SUMMARY") + print(f"{'=' * 60}") + print(f" Names changed: {stats['name_changed']}") + print(f" Building names filled: {stats['building_name_filled']}") + + if apply_changes: + cur = conn.execute("SELECT COUNT(*) FROM assets WHERE building_name != ''") + print(f" Total with building_name: {cur.fetchone()[0]}") + cur = conn.execute("SELECT COUNT(*) FROM assets WHERE floor != ''") + print(f" Total with floor: {cur.fetchone()[0]}") + cur = conn.execute("SELECT COUNT(*) FROM assets WHERE building_name != '' OR floor != '' OR building_number != '' OR room != ''") + print(f" Total with ANY location data: {cur.fetchone()[0]}") + + conn.close() + + +if __name__ == "__main__": + main() diff --git a/server.py b/server.py index a2e1163..4812811 100644 --- a/server.py +++ b/server.py @@ -1039,6 +1039,7 @@ def list_assets( disney_filter: Optional[str] = Query(None), q: Optional[str] = Query(None), no_dex_days: Optional[int] = Query(None, ge=1), + has_gps: Optional[bool] = Query(None), limit: int = Query(100, ge=1, le=2000), offset: int = Query(0, ge=0), ): @@ -1081,6 +1082,11 @@ def list_assets( if no_dex_days is not None: conditions.append("(dex_report_date IS NULL OR REPLACE(dex_report_date, 'T', ' ') < datetime('now', '-' || ? || ' days'))") params.append(no_dex_days) + if has_gps is not None: + if has_gps: + conditions.append("a.latitude IS NOT NULL AND a.longitude IS NOT NULL") + else: + conditions.append("(a.latitude IS NULL OR a.longitude IS NULL)") where = " AND ".join(conditions) from_clause = "FROM assets a LEFT JOIN customers c ON a.customer_id = c.id" diff --git a/static/index.html b/static/index.html index 8d3a365..296bb40 100644 --- a/static/index.html +++ b/static/index.html @@ -3608,9 +3608,10 @@ if (a.place) locParts.push(esc(a.place)); if (locParts.length) parts.push(`