From a8158566ac956c5969cd08383fb35ee6c9c0a8d5 Mon Sep 17 00:00:00 2001 From: Shawn Date: Thu, 28 May 2026 22:28:34 -0400 Subject: [PATCH] feat: OCR auto-saves GPS to asset + MSFS enrichment in detail view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OCR endpoint (/api/ocr) now auto-saves EXIF GPS from the photo to the asset's latitude/longitude fields when they are blank, so the first photo scan at a machine permanently records its location. GET /api/assets/{id} now includes an 'msfs' block enriched from the merged Dynamics 365 Field Service data (merged-assets.json) loaded at server start (1,834 matched assets). Fields shown: equipment_id, connect_id, compass_asset_number, manufacturer, model_text, dex_model, install_date, software_version, etc. Frontend updates: - New 'Field Service Data' card in asset detail view when MSFS data is available (manufacturer, Connect ID, install date, etc.) - '📍 GPS saved to asset' confirmation shown after OCR auto-save - AppState.ocrGpsSaved flag to track the auto-save event --- server.py | 61 +++++++++++++++++++++++++++++++++++++++++++++++ static/index.html | 43 +++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/server.py b/server.py index 43ca681..9d53711 100644 --- a/server.py +++ b/server.py @@ -43,6 +43,27 @@ DB_PATH = os.environ.get("CANTEEN_DB_PATH", str(Path(__file__).parent / "assets. UPLOADS_DIR = Path(os.environ.get("CANTEEN_UPLOADS_DIR", str(Path(__file__).parent / "uploads"))) STATIC_DIR = Path(__file__).parent / "static" +# ─── MSFS Data (merged asset enrichment from Dynamics 365 Field Service) ─── +MSFS_DATA_PATH = os.environ.get( + "MSFS_DATA_PATH", + str(Path.home() / "projects/ms-field-service-extraction/web/static/data/merged-assets.json"), +) +_MSFS_LOOKUP: dict[str, dict] = {} # keyed by canteen_machine_id +_MSFS_BY_ID: dict[int, dict] = {} # keyed by canteen_id (assets.id) +if os.path.exists(MSFS_DATA_PATH): + try: + with open(MSFS_DATA_PATH, "r") as _f: + _msfs_all = _json.load(_f) + for _entry in _msfs_all.get("assets", []): + _mid = _entry.get("canteen_machine_id") + _cid = _entry.get("canteen_id") + if _mid: + _MSFS_LOOKUP[_mid] = _entry + if _cid: + _MSFS_BY_ID[_cid] = _entry + except Exception: + pass # Non-fatal — MSFS enrichment just won't be available + def _load_categories() -> set: """Load valid category names from the categories lookup table.""" try: @@ -1118,6 +1139,25 @@ def get_asset(asset_id: int): result["location_name"] = loc["name"] if loc else None conn.close() + + # Enrich with MSFS data (from Dynamics 365 Field Service) + msfs = _MSFS_LOOKUP.get(result.get("machine_id", "")) or _MSFS_BY_ID.get(asset_id) + if msfs: + result["msfs"] = { + "equipment_id": msfs.get("msfs_equipment_id"), + "connect_id": msfs.get("msfs_connect_id"), + "compass_asset_number": msfs.get("msfs_compass_asset_number"), + "manufacturer": msfs.get("msfs_manufacturer"), + "model_text": msfs.get("msfs_model_text"), + "dex_model": msfs.get("msfs_dex_model"), + "install_date": msfs.get("msfs_install_date"), + "manufacture_date": msfs.get("msfs_manufacture_date"), + "software_version": msfs.get("msfs_software_version"), + "canteen_connect_guid": msfs.get("msfs_canteen_connect_guid"), + "account_name": msfs.get("msfs_account_name"), + "work_order_count": msfs.get("work_order_count", 0), + } + return result @@ -2135,6 +2175,27 @@ async def ocr_sticker(file: UploadFile = File(...), exif_data: str = Form(None)) if exif_gps: result["exif_gps"] = exif_gps + + # Auto-save GPS to asset if lat/lng is blank + machine_id = result.get("machine_id") + if machine_id: + try: + conn = get_db() + asset = conn.execute( + "SELECT id, latitude, longitude FROM assets WHERE machine_id = ?", + (machine_id,), + ).fetchone() + if asset and (asset["latitude"] is None or asset["longitude"] is None): + conn.execute( + "UPDATE assets SET latitude = ?, longitude = ?, updated_at = datetime('now') WHERE id = ?", + (exif_gps["lat"], exif_gps["lng"], asset["id"]), + ) + conn.commit() + result["gps_saved"] = True + conn.close() + except Exception: + pass # Non-critical — don't fail OCR if GPS save fails + if saved_path: result["path"] = saved_path return result diff --git a/static/index.html b/static/index.html index 1c5f0a6..76ca0c1 100644 --- a/static/index.html +++ b/static/index.html @@ -1309,6 +1309,12 @@
+ + +
Check-in History
@@ -1688,6 +1694,7 @@ gpsAcc: null, currentTab: 'tabAddAsset', currentAssetId: null, + ocrGpsSaved: false, // true when OCR photo GPS was auto-saved to asset }; function isLoggedIn() { return !!AppState.authToken; } @@ -2773,6 +2780,9 @@ } showToast('📍 GPS extracted from photo: ' + data.exif_gps.lat.toFixed(6) + ', ' + data.exif_gps.lng.toFixed(6)); } + if (data.gps_saved) { + AppState.ocrGpsSaved = true; + } } // ── OCR: Auto-search asset by machine_id ───────────────────────────── @@ -2793,6 +2803,10 @@ doAutoCheckin(asset.id); el.innerHTML += `
✓ Auto check-in recorded
`; } + if (AppState.ocrGpsSaved) { + el.innerHTML += `
📍 GPS saved to asset
`; + AppState.ocrGpsSaved = false; + } el.innerHTML += `
`; @@ -3791,6 +3805,35 @@ // Service Entrances await loadServiceEntrances(a); + // MSFS Data (Dynamics 365 enrichment) + const msfsCard = document.getElementById('detailMsfsCard'); + const msfsFields = document.getElementById('detailMsfsFields'); + if (a.msfs) { + const m = a.msfs; + const msfsRows = [ + df('Equipment ID', m.equipment_id, true), + df('Connect ID', m.connect_id, true), + df('Compass #', m.compass_asset_number, true), + df('Manufacturer', m.manufacturer), + df('Model', m.model_text), + df('DEX Model', m.dex_model), + df('Canteen Connect GUID', m.canteen_connect_guid, true), + df('Account Name', m.account_name), + df('Install Date', m.install_date), + df('Manufacture Date', m.manufacture_date), + df('Software Version', m.software_version, true), + df('Work Orders', m.work_order_count ? `${m.work_order_count} work orders` : null), + ].filter(Boolean).join(''); + if (msfsRows) { + msfsFields.innerHTML = msfsRows; + msfsCard.style.display = 'block'; + } else { + msfsCard.style.display = 'none'; + } + } else { + msfsCard.style.display = 'none'; + } + await loadCheckinHistory(id); } catch (e) { showToast(e.message, true);