feat: OCR auto-saves GPS to asset + MSFS enrichment in detail view
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
This commit is contained in:
@@ -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")))
|
UPLOADS_DIR = Path(os.environ.get("CANTEEN_UPLOADS_DIR", str(Path(__file__).parent / "uploads")))
|
||||||
STATIC_DIR = Path(__file__).parent / "static"
|
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:
|
def _load_categories() -> set:
|
||||||
"""Load valid category names from the categories lookup table."""
|
"""Load valid category names from the categories lookup table."""
|
||||||
try:
|
try:
|
||||||
@@ -1118,6 +1139,25 @@ def get_asset(asset_id: int):
|
|||||||
result["location_name"] = loc["name"] if loc else None
|
result["location_name"] = loc["name"] if loc else None
|
||||||
|
|
||||||
conn.close()
|
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
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -2135,6 +2175,27 @@ async def ocr_sticker(file: UploadFile = File(...), exif_data: str = Form(None))
|
|||||||
|
|
||||||
if exif_gps:
|
if exif_gps:
|
||||||
result["exif_gps"] = 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:
|
if saved_path:
|
||||||
result["path"] = saved_path
|
result["path"] = saved_path
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -1309,6 +1309,12 @@
|
|||||||
<div id="detailSEFields"></div>
|
<div id="detailSEFields"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- MSFS Data (Dynamics 365 Field Service enrichment) -->
|
||||||
|
<div class="card" id="detailMsfsCard" style="display:none;">
|
||||||
|
<div class="card-title">📊 Field Service Data</div>
|
||||||
|
<div id="detailMsfsFields"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Check-in History -->
|
<!-- Check-in History -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-title">Check-in History <span id="checkinCount" style="color:var(--accent2);"></span></div>
|
<div class="card-title">Check-in History <span id="checkinCount" style="color:var(--accent2);"></span></div>
|
||||||
@@ -1688,6 +1694,7 @@
|
|||||||
gpsAcc: null,
|
gpsAcc: null,
|
||||||
currentTab: 'tabAddAsset',
|
currentTab: 'tabAddAsset',
|
||||||
currentAssetId: null,
|
currentAssetId: null,
|
||||||
|
ocrGpsSaved: false, // true when OCR photo GPS was auto-saved to asset
|
||||||
};
|
};
|
||||||
|
|
||||||
function isLoggedIn() { return !!AppState.authToken; }
|
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));
|
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 ─────────────────────────────
|
// ── OCR: Auto-search asset by machine_id ─────────────────────────────
|
||||||
@@ -2793,6 +2803,10 @@
|
|||||||
doAutoCheckin(asset.id);
|
doAutoCheckin(asset.id);
|
||||||
el.innerHTML += `<div style="margin-top:8px;font-size:12px;color:var(--green);">✓ Auto check-in recorded</div>`;
|
el.innerHTML += `<div style="margin-top:8px;font-size:12px;color:var(--green);">✓ Auto check-in recorded</div>`;
|
||||||
}
|
}
|
||||||
|
if (AppState.ocrGpsSaved) {
|
||||||
|
el.innerHTML += `<div style="margin-top:8px;font-size:12px;color:var(--green);">📍 GPS saved to asset</div>`;
|
||||||
|
AppState.ocrGpsSaved = false;
|
||||||
|
}
|
||||||
el.innerHTML += `<div style="margin-top:8px;">
|
el.innerHTML += `<div style="margin-top:8px;">
|
||||||
<button class="btn btn-outline btn-sm" onclick="switchTab('tabAssets');viewAsset(${asset.id});" style="width:100%;">View Details</button>
|
<button class="btn btn-outline btn-sm" onclick="switchTab('tabAssets');viewAsset(${asset.id});" style="width:100%;">View Details</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -3791,6 +3805,35 @@
|
|||||||
// Service Entrances
|
// Service Entrances
|
||||||
await loadServiceEntrances(a);
|
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);
|
await loadCheckinHistory(id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(e.message, true);
|
showToast(e.message, true);
|
||||||
|
|||||||
Reference in New Issue
Block a user