diff --git a/scripts/populate_asset_company_place.py b/scripts/populate_asset_company_place.py new file mode 100644 index 0000000..88e02af --- /dev/null +++ b/scripts/populate_asset_company_place.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +"""Populate company, place, customer_name on assets from MSFS extraction DB. + +Maps each asset's connect_id โ msdyn_customerasset โ account to get: + - company = account name (customer/location name, e.g. "Jeremy B - 1960 Broadway") + - place = city name (e.g. "Orlando", "Lake Buena Vista") + - customer_name = account name (same as company, for search/filter) + - address = line1 if empty + +Skips assets that already have values in these fields. +""" + +import sqlite3 +import sys +from pathlib import Path + +ASSETS_DB = Path.home() / "projects" / "canteen-asset-tracker" / "assets.db" +EXTRACTION_DB = ( + Path.home() + / "projects" + / "ms-field-service-extraction" + / "data-samples" + / "msfs_backup" + / "fieldservicecanteen.crm.dynamics.com_9fc6c50c-b097-f011-b4cb-7ced8d1b15a2_data.db" +) + + +def get_mapping() -> dict[str, dict]: + """Return {connect_id: {company, place, city, address}} from extraction DB.""" + conn = sqlite3.connect(str(EXTRACTION_DB)) + conn.row_factory = sqlite3.Row + cur = conn.cursor() + + # We want the account info per customerasset. + # If multiple customerassets share the same connect_id, pick the one + # with the most recent createdon date to get freshest account link. + rows = cur.execute(""" + SELECT + ca.hsl_connectid, + COALESCE(ca."msdyn_account!name", a.name, '') AS account_name, + COALESCE(a.address1_city, '') AS city, + COALESCE(a.address1_name, '') AS address1_name, + COALESCE(a.address1_line1, '') AS line1, + COALESCE(a.address1_line2, '') AS line2 + FROM msdyn_customerasset ca + LEFT JOIN account a ON ca."msdyn_account!id" = a.accountid + WHERE ca.hsl_connectid IS NOT NULL AND ca.hsl_connectid != '' + ORDER BY ca.createdon DESC + """).fetchall() + + conn.close() + + # Deduplicate: keep first (most recent createdon) per connect_id + seen: set[str] = set() + result: dict[str, dict] = {} + for r in rows: + cid = r["hsl_connectid"] + if cid in seen: + continue + seen.add(cid) + + # Build address from available parts + addr_parts = [p for p in [r["line1"], r["line2"]] if p] + address = ", ".join(addr_parts) if addr_parts else "" + + result[cid] = { + "company": r["account_name"], + "place": r["city"], + "customer_name": r["account_name"], + "address": address, + } + + print(f"๐ก Loaded {len(result)} connect_id mappings from extraction DB") + return result + + +def main(): + print("๐ Connecting to assets.db...") + conn = sqlite3.connect(str(ASSETS_DB)) + conn.row_factory = sqlite3.Row + + # Count assets with empty fields + stats = conn.execute(""" + SELECT + COUNT(*) AS total, + SUM(CASE WHEN company IS NULL OR company = '' THEN 1 ELSE 0 END) AS empty_company, + SUM(CASE WHEN place IS NULL OR place = '' THEN 1 ELSE 0 END) AS empty_place, + SUM(CASE WHEN customer_name IS NULL OR customer_name = '' THEN 1 ELSE 0 END) AS empty_customer_name, + SUM(CASE WHEN address IS NULL OR address = '' THEN 1 ELSE 0 END) AS empty_address + FROM assets + """).fetchone() + print(f"๐ Before update:") + print(f" Total assets: {stats['total']}") + print(f" Empty company: {stats['empty_company']}") + print(f" Empty place: {stats['empty_place']}") + print(f" Empty customer_name:{stats['empty_customer_name']}") + print(f" Empty address: {stats['empty_address']}") + + mapping = get_mapping() + + # Fetch all assets with connect_id + assets = conn.execute(""" + SELECT id, connect_id, company, place, customer_name, address + FROM assets + WHERE connect_id IS NOT NULL AND connect_id != '' + """).fetchall() + + updates = {"company": 0, "place": 0, "customer_name": 0, "address": 0} + skipped_no_match = 0 + skipped_has_data = 0 + + update_sqls = { + "company": "UPDATE assets SET company = ? WHERE id = ?", + "place": "UPDATE assets SET place = ? WHERE id = ?", + "customer_name": "UPDATE assets SET customer_name = ? WHERE id = ?", + "address": "UPDATE assets SET address = ? WHERE id = ?", + } + + for a in assets: + info = mapping.get(a["connect_id"]) + if not info: + skipped_no_match += 1 + continue + + if a["company"] and a["place"] and a["customer_name"] and a["address"]: + skipped_has_data += 1 + continue + + batch: list[tuple[str, int]] = [] # (sql, (value, id)) + + if (not a["company"] or not a["company"].strip()) and info["company"]: + batch.append((update_sqls["company"], (info["company"], a["id"]))) + updates["company"] += 1 + + if (not a["place"] or not a["place"].strip()) and info["place"]: + batch.append((update_sqls["place"], (info["place"], a["id"]))) + updates["place"] += 1 + + if (not a["customer_name"] or not a["customer_name"].strip()) and info["customer_name"]: + batch.append((update_sqls["customer_name"], (info["customer_name"], a["id"]))) + updates["customer_name"] += 1 + + if (not a["address"] or not a["address"].strip()) and info["address"]: + batch.append((update_sqls["address"], (info["address"], a["id"]))) + updates["address"] += 1 + + for sql, params in batch: + conn.execute(sql, params) + + conn.commit() + conn.close() + + print(f"\nโ Migration complete:") + print(f" Matched & updated: {len(assets) - skipped_no_match - skipped_has_data} assets") + print(f" No match in ext DB: {skipped_no_match}") + print(f" Already had data: {skipped_has_data}") + print(f" Field updates:") + for field, count in updates.items(): + print(f" {field}: {count}") + + # Show some samples + conn2 = sqlite3.connect(str(ASSETS_DB)) + conn2.row_factory = sqlite3.Row + samples = conn2.execute(""" + SELECT machine_id, company, place, customer_name, address + FROM assets + WHERE company != '' AND place != '' + LIMIT 5 + """).fetchall() + conn2.close() + print(f"\n๐ Sample updated rows:") + for s in samples: + print(f" {s['machine_id']:>25} | company='{s['company']}' | place='{s['place']}'") + + +if __name__ == "__main__": + main() diff --git a/server.py b/server.py index a901ea7..977dff4 100644 --- a/server.py +++ b/server.py @@ -1040,6 +1040,7 @@ def list_assets( q: Optional[str] = Query(None), no_dex_days: Optional[int] = Query(None, ge=1), has_gps: Optional[bool] = Query(None), + branch: Optional[str] = Query(None, description="Filter by branch/territory name"), limit: int = Query(100, ge=1, le=2000), offset: int = Query(0, ge=0), ): @@ -1087,10 +1088,25 @@ def list_assets( 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)") + if branch: + cache = _build_asset_branch_cache() + # Find all connect_id prefixes that map to this branch name + matching_prefixes = [ + pfx for pfx, br in cache.items() if br == branch + ] + if matching_prefixes: + placeholders = ",".join(["?"] * len(matching_prefixes)) + conditions.append( + f"SUBSTR(a.connect_id, 1, 8) IN ({placeholders})" + ) + params.extend(matching_prefixes) + else: + # Branch name not found โ return empty result + conditions.append("1=0") where = " AND ".join(conditions) from_clause = "FROM assets a LEFT JOIN customers c ON a.customer_id = c.id" - sql = f"SELECT a.*, c.name AS customer_name {from_clause}" + sql = f"SELECT a.*, COALESCE(c.name, a.customer_name) AS customer_name {from_clause}" if where: sql += f" WHERE {where}" sql += " ORDER BY a.created_at DESC LIMIT ? OFFSET ?" @@ -1121,6 +1137,44 @@ def search_by_machine_id(machine_id: str = Query(...)): return row_to_dict(row) +# โโ Task 5b: GET /api/branches โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + + +@app.get("/api/branches") +def list_branches(): + """Return distinct branch/territory names that assets belong to. + + Uses the connect_id prefix โ branch mapping from the extraction DB. + Returns sorted list of {name, machine_count} objects. + """ + cache = _build_asset_branch_cache() + # Count how many assets per branch + conn = get_db() + prefix_counts: dict[str, int] = {} + try: + cur = conn.execute( + "SELECT SUBSTR(connect_id, 1, 8) AS prefix, COUNT(*) AS cnt " + "FROM assets WHERE connect_id IS NOT NULL AND connect_id != '' " + "GROUP BY prefix" + ) + for r in cur.fetchall(): + prefix_counts[r[0]] = r[1] + finally: + conn.close() + + # Aggregate by branch name + branch_map: dict[str, int] = {} + for prefix, machine_count in prefix_counts.items(): + branch = cache.get(prefix, prefix) + branch_map[branch] = branch_map.get(branch, 0) + machine_count + + branches = sorted( + [{"name": k, "machine_count": v} for k, v in branch_map.items()], + key=lambda b: -b["machine_count"], # type: ignore[arg-type] + ) + return {"branches": branches, "total": len(branches)} + + @app.get("/api/assets/{asset_id}") def get_asset(asset_id: int): conn = get_db() @@ -2595,6 +2649,71 @@ def _get_extraction_db() -> sqlite3.Connection | None: return conn +# โโ Asset Branch Cache (connect_id prefix โ branch/territory name) โโโโโโโโ +_BRANCH_CACHE: dict[str, str] | None = None + + +def _build_asset_branch_cache() -> dict[str, str]: + """Build a mapping from connect_id prefix (first 8 chars) to branch name. + + Queries the extraction DB for each customer asset, linking it through + its account to work order territories (msdyn_serviceterritory!name). + Returns {connect_id_prefix: branch_name}. + """ + global _BRANCH_CACHE + if _BRANCH_CACHE is not None: + return _BRANCH_CACHE + + result: dict[str, str] = {} + conn = _get_extraction_db() + if not conn: + print("โ ๏ธ Extraction DB not available โ branch cache will be empty") + _BRANCH_CACHE = result + return result + + try: + cur = conn.cursor() + # For each customer asset (connect_id + account), find the most common + # work order territory for that account. + cur.execute(""" + SELECT + SUBSTR(ca.hsl_connectid, 1, 8) AS prefix, + wo."msdyn_serviceterritory!name" AS territory, + COUNT(*) AS cnt + FROM msdyn_customerasset ca + LEFT JOIN account a ON ca."msdyn_account!id" = a.accountid + LEFT JOIN msdyn_workorder wo ON a.accountid = wo."msdyn_serviceaccount!id" + WHERE ca.hsl_connectid IS NOT NULL AND ca.hsl_connectid != '' + AND wo."msdyn_serviceterritory!name" IS NOT NULL + AND wo."msdyn_serviceterritory!name" != '' + GROUP BY prefix, wo."msdyn_serviceterritory!name" + ORDER BY cnt DESC + """) + # Take the most frequent territory per prefix + prefix_map: dict[str, list[tuple[str, int]]] = {} + for r in cur.fetchall(): + prefix = r[0] + territory = r[1] + cnt = r[2] + if prefix not in prefix_map: + prefix_map[prefix] = [] + prefix_map[prefix].append((territory, cnt)) + + for prefix, territories in prefix_map.items(): + # Sort by count descending, pick the top one + territories.sort(key=lambda x: -x[1]) + result[prefix] = territories[0][0] + + print(f"๐ก Built branch cache: {len(result)} connect_id prefixes mapped") + except Exception as e: + print(f"โ ๏ธ Failed to build branch cache: {e}") + finally: + conn.close() + + _BRANCH_CACHE = result + return result + + # โโ TSP Solver โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ diff --git a/static/index.html b/static/index.html index c247933..3594a86 100644 --- a/static/index.html +++ b/static/index.html @@ -1091,7 +1091,7 @@
+