Compare commits

...

73 Commits

Author SHA1 Message Date
shawn 874e15c01e Add disney_group grouped park filter categories
- New disney_group query param on /api/assets (park, resort,
  office, springs, other)
- Both list and map filter dropdowns show group options above
  individual parks
- Backend uses IN queries for grouped park values
- Map view handles groups via client-side PARK_GROUP_MAP

Closes #69
2026-06-03 00:57:55 -04:00
shawn c7ebef7cc1 docs: update FIELD_MAP — GPS policy: never from seed/MSFS/Cantaloupe 2026-06-02 22:56:20 -04:00
shawn 27cfa6d68e docs: add FIELD_MAP.md — complete field origin reference for all pipelines 2026-06-02 22:51:05 -04:00
shawn b8ea7374e4 feat: local ZXing fallback, Register New Asset button, OCR serial search
Three fixes:
1. Barcode scanner: load ZXing locally with CDN fallback so it works
   even when CDN is unreachable.
2. Scan result: add 'Register' button to create a new asset from a
   scanned machine_id — works for both found and not-found results.
3. OCR serial search: when extracted machine_id isn't found, also try
   searching all numeric strings from OCR text as serial numbers; add
   'Register as New Asset' button on OCR not-found result.
2026-06-02 19:08:58 -04:00
shawn 95a368163a fix: remove duplicate doAutoCheckin call from handleBarcode
showScannedAsset (called on the very next line) already triggers the
auto-checkin. handleBarcode was calling doAutoCheckin immediately before
showScannedAsset, resulting in two check-in records per barcode scan.
2026-06-02 18:50:57 -04:00
shawn 52f801675e fix: replace AbortController with Promise.race to avoid SW hanging fetch bug
Root cause: AbortController signals don't properly propagate through
Service Worker fetch handlers (known Chrome/Safari bug), causing the
fetch to hang indefinitely on mobile browsers instead of timing out.

Fix: replace AbortController-based timeout in api() with Promise.race
against a simple setTimeout. Also:
- Stop all MediaStream tracks explicitly in stopScanning()
- Add 200ms yield point before API call for hardware release
- Bypass SW for API requests entirely (no caching needed for lookups)
- Bump SW cache to v11
- Add outer try/catch safety net in handleBarcode
2026-06-02 18:43:17 -04:00
shawn dd63dc97d5 feat: register service worker in index.html so PWA cache busting works 2026-06-02 18:34:01 -04:00
shawn c8ffab2c06 feat: connect-id suffix matching for OCR + bump SW cache to v10 2026-06-02 18:32:41 -04:00
shawn 3fa76cc74b Fix barcode scan hanging: stop camera before API call + add 15s fetch timeout
- Stop camera scanner (stopScanning) BEFORE making the API call in
  handleBarcode — active camera stream on mobile browsers can throttle
  concurrent fetch requests, causing the lookup to hang indefinitely
- Add 15s AbortController timeout to the api() wrapper so any network
  issue shows 'Request timed out — check your connection' instead of
  hanging forever on 'looking up...'
- Both fixes together ensure the barcode lookup never hangs
2026-06-02 18:24:31 -04:00
shawn 3859d9d555 fix: barcode scanner — hints not applied, per-frame errors killing scanner
Root cause #1: BrowserMultiFormatReader constructor takes 0 args so hints
and timing options (passed as args) were silently ignored. Hints are now
applied as reader.reader.hints, and timing as instance properties.

Root cause #2: ZXing per-frame 'no barcode' exceptions have message=undefined
causing .toLowerCase() to crash in old message-text filter. Switched to
instanceof checks against NotFoundException, ChecksumException, FormatException
which properly distinguishes normal 'no barcode' frames from real errors.

Tested in browser — hint setting and error filtering both verified working.
2026-06-02 18:09:03 -04:00
shawn 2c3ae1de22 feat: add Scan New button to OCR matched-assets result view
When OCR detects serial numbers matching assets in the DB but no
machine_id pattern (XXXXX-XXXXXX) is found, the result now shows:
- 'Asset found via serial/ID match' heading
- Matched assets with name, serial_number, machine_id, matched_on
- View Details button per matched asset
- Scan New + Retake buttons at the bottom

When machine_id IS found AND matched_assets also has entries, the
matched assets are shown as supplementary info below the main result.

The OCR vision prompt already asked for 'serial numbers' (no change
needed on server side).
2026-06-02 18:05:54 -04:00
shawn dd4744df05 Scan/OCR improvements: Scan New Asset button, barcode scanner fix, serial number OCR search
- Add 'Scan New Asset' button after scanning/OCR'ing an existing asset
  in both barcode and OCR modes (with resetBarcodeScanner/resetOcrScanner)
- Show scan results in-place instead of navigating away from Find Asset tab
- Fix barcode scanner: remove TRY_HARDER hint (too slow on mobile), reduce
  from 13 formats to 7 focused 1D formats, add ZXing library load check
- Extend camera fail timeout from 5s to 10s for slow mobile browsers
- Show matched_assets from OCR serial number cross-reference even when
  no machine_id pattern is detected (previously showed 'No machine ID')
- Display serial/ID DB matches alongside machine_id results when both exist
- Add null-safety to all new reset functions
2026-06-02 18:05:24 -04:00
shawn 1c63ec231f feat: add Scan New Asset buttons to barcode and OCR results
- handleBarcode now shows result in-place via showScannedAsset
  instead of navigating to Assets tab
- showScannedAsset gets a 'Scan New' button that calls
  resetBarcodeScanner()
- resetBarcodeScanner(): clears scan result, restarts barcode camera
- lookupOcrAsset gets a 'Scan New' button that calls
  resetOcrScanner()
- resetOcrScanner(): clears OCR result, restarts OCR camera
- Retake still works for re-capturing the same sticker
2026-06-02 18:03:16 -04:00
shawn e59b0fd6b6 fix: activity log now shows machine_id/name, 164 Disney assets updated with park data, WO date range defaults to week 2026-06-01 23:47:30 -04:00
shawn 1e7576e7d0 chore: bump SW cache to v9 2026-06-01 23:28:45 -04:00
shawn e3295afe73 fix: show GPS coords on map popups + asset list; toast on auto-checkin 2026-06-01 23:28:37 -04:00
shawn 120f52a29a chore: bump service worker cache to v8 to force refresh
Old cached index.html was likely serving stale JS on mobile devices.
v8 purge ensures all clients fetch the latest code on next load.
2026-06-01 23:09:17 -04:00
shawn 0e53da09c7 fix: barcode scan rate 50ms→300ms — decoder was thrashing, missed barcodes
delayBetweenScanAttempts was set to 50ms (20 scans/sec) but with
TRY_HARDER enabled and 13 barcode formats configured, the ZXing decoder
couldn't keep up — frames were skipped and barcodes visible in the
viewfinder were never detected. 300ms gives each frame enough processing
time while still feeling real-time.
2026-06-01 22:59:00 -04:00
shawn 111008a858 fix: ZXing per-frame 'no detection' errors were being treated as camera failures
ZXing's decodeFromVideoDevice fires its callback on every video frame
(~30fps). When no barcode is in view, it passes `(null, err)` where err
is 'No MultiFormat Readers were able to detect the code' — this is
NORMAL, not a hardware failure.

The error branch now filters out 'multiformat' and 'unable to detect'
messages, only calling handleCameraError for real device-level issues
(permission denied, camera not found, stream broken).
2026-06-01 22:56:54 -04:00
shawn 6322786fd2 fix: stop keyboard-loop on mobile camera failure + guard drawer behind login
- handleCameraError() now sets scanningActive=false and resets codeReader
  so ZXing can't fire more callbacks that re-create the manual input and
  re-focus it — mobile keyboard was popping repeatedly
- openDrawer() now checked loginOverlay.hidden before opening, so drawer
  can't appear behind the login screen
2026-06-01 22:46:38 -04:00
shawn 0f0aa1021a Fix UX-015: ZXing async callback silently swallows camera errors - add handleCameraError(), 5s cameraFailTimer, error branch in decodeFromVideoDevice callback 2026-06-01 22:11:12 -04:00
shawn d092abd91a UX fixes: admin link gating, GPS pulse timeout, ? shortcut overlay, offline queue, native confirm→modal 2026-06-01 22:01:23 -04:00
shawn a7b1887274 chore: Remove stale bak files from index 2026-06-01 21:23:07 -04:00
shawn b020665088 fix: Default to OCR mode on initial page load
- Fixed HTML initial state: OCR toggle button has 'active', OCR panel has 'active', barcode does not
- switchTab calls setAddAssetMode() which syncs visual panels AND starts the right camera
- Previously the JS variable was 'ocr' but HTML hardcoded barcode as active — visual mismatch
2026-06-01 21:22:25 -04:00
shawn 5ae9763f9a chore: Ignore .bak files, remove from tracking 2026-06-01 19:17:14 -04:00
shawn 50ba02e79e fix: Make work order detail modal scrollable
- Added max-height: 85vh to modal container
- Made modal-body scrollable (overflow-y: auto, flex shrink)
- Title and action buttons stay fixed while body scrolls
- Fixes 'too tall and not scrollable' when WO details have many items
2026-06-01 19:17:10 -04:00
shawn 4e4632ab3d chore: Remove accidentally committed DB backups 2026-06-01 19:12:24 -04:00
shawn 86975e2e2b feat: Default to OCR scanner, auto-start camera, hide viewport after capture
- Default add-asset mode changed from barcode to OCR
- Auto-start OCR camera when switching to OCR mode or Add Asset tab
- Camera viewport hidden entirely after photo capture (no placeholder showing)
- Retake button added to all OCR result states (found, not-found, error)
- 'Retake' resets UI and re-starts camera for a new photo
2026-06-01 19:12:01 -04:00
shawn 03cf4b9281 feat: Route tab overhaul + work order filters + Next Stop + Google Maps
- Added work order filter bar to Route tab matching the Work Orders tab UX:
  status, priority, work type dropdowns + date range selector
- Filter params passed to /api/workorders/today endpoint (backend updated
  with new query params: status, priority, work_type, date_range)
- Added Google Maps Navigate button on every route stop
- Added Mark Done & Next Stop button — tracks current stop index,
  marks completed stops with green check, advances to next with
  auto-prompt to open Google Maps navigation
- Animated stop progress: done stops dimmed with green ✓, current
  stop highlighted with accent border
- Updated /api/workorders/today to support date_range (week/month/all)
  and additional filter WHERE clauses
2026-06-01 13:10:37 -04:00
shawn 2d232941da Fix single checkin GET to include username via JOIN 2026-06-01 00:27:29 -04:00
shawn e325981b17 Fix check-in user tracking
- Backend: create_checkin now auto-resolves user_id from auth token
  when not provided in the request body
- Backend: list_checkins now JOINs users table to return username
  alongside each check-in
- Frontend: all 4 check-in functions now pass user_id from
  AppState.currentUser
- Frontend: check-in history shows 👤 username next to each entry
- Also made work order count clickable: shows WO detail modal
- Added work_orders list to MSFS asset response

Closes #62
2026-05-31 23:54:13 -04:00
shawn 07539b683e feat: add script to import machines from Cantaloupe Excel export
Adds scripts/import_from_excel.py that reads Cantaloupe .xlsx exports
and imports missing machines into the canteen-asset-tracker DB.
- Dry-run by default, --write to commit, --prod for production DB
- Maps all 62 spreadsheet columns to assets table schema
- Stores full raw row as JSON in seed_data table
- Backs up DB before import
2026-05-31 11:09:27 -04:00
shawn 8713169e84 feat: add My GPS button to asset detail GPS editor
Adds a '📍 My GPS' button to the GPS Map Editor card in the asset
detail screen that:
- Grabs device GPS via navigator.geolocation.getCurrentPosition
- Places map marker at current device position
- Auto-unlocks the GPS editor if locked, shows Save button
- Displays accuracy in status text
- Provides clear error messages for denied/timed-out/unavailable

Includes enableHighAccuracy: true, 15s timeout, 60s cache.
2026-05-31 09:53:23 -04:00
shawn 2a92c4e71b Swap all UI icons to game-icons SVGs via sprite
- Created SVG sprite with 49 game-icons symbols (58KB)
- Added .gi CSS class for icon sizing/fill
- Replaced 77 emoji in static HTML with <use href='#gi-{name}'>
- Skip <option> elements (can't render SVG there)
- Added JS gi() helper for 33 dynamic icon assignments
- Replaced 📍🔒🔓📷📋↕️⚠ in JS-driven content
- Zero network requests, zero console errors
- Pairs with existing CATEGORY_MAP game-icons SVGs
2026-05-31 02:40:52 -04:00
shawn cc4f3b7dcf Swap all category icons to game-icons
All 8 categories now use Game Icons from game-icons.net:
- Bev:     game-icons:soda-bottle (glass soda bottle)
- Snack:   game-icons:chips-bag (crinkly chip bag)
- Food:    game-icons:hamburger (stacked burger)
- Equip:   game-icons:wrench (adjustable wrench)
- Appl:    game-icons:cooler (ice chest)
- Markets: game-icons:shop (storefront with awning)
- Coffee:  game-icons:coffee-cup (paper cup with steam)
- Other:   game-icons:cargo-crate (industrial crate)

Zero external deps — inline SVGs from Iconify CDN
2026-05-31 02:26:01 -04:00
shawn dc11ac75d2 Swap Bev and Snack icons to game-icons
- Bev: hugeicons:soda-can → game-icons:soda-bottle
- Snack: mdi:chips → game-icons:chips-bag
- Other categories unchanged
2026-05-31 02:24:21 -04:00
shawn b444009593 Replace emoji icons with Iconify SVG icons
- Bev: hugeicons:soda-can (soda can with pull tab, outline style)
- Snack: mdi:chips (bag of chips)
- Food: mdi:hamburger
- Equipment: mdi:wrench
- Appliances: mdi:fridge-outline
- Markets: mdi:store
- Coffee: mdi:coffee
- Other: mdi:package-variant
- Removed stale <img guard in map sidebar rendering
- Added SVG sizing CSS for asset list icons
- Zero external deps — inline SVGs from Iconify CDN
2026-05-31 02:15:49 -04:00
shawn 18743ffdde Add GPS Map Editor to asset detail view
Add a GPS map editor card at the top of asset details, allowing users to
manually set an asset's GPS coordinates via an interactive Leaflet map.

- GPS card with Leaflet map and draggable marker
- 🔒 Lock/unlock toggle (locked by default when GPS exists)
- 💾 Save GPS button (visible only when unlocked)
- Saves via PUT /api/assets/{id} with lat/lng
- Click on map or drag marker to set position
- Fallback to Orlando coords (28.5383, -81.3792) when no GPS
- Auto-refresh detail view after save

Closes #61
2026-05-31 01:47:54 -04:00
shawn 064fc43fe3 fix: remove all address-geocoded GPS, keep only original check-ins
All GPS from MSFS account addresses and Cantaloupe seed data removed.
Only 5 authentic app check-in GPS entries remain.
Reverts the bulk restore from earlier commit (gps restore was address-based).
2026-05-31 01:45:53 -04:00
shawn 616769e805 feat: restore GPS data from pre-MSFS backup + MSFS account data
- 221 GPS entries restored via serial_number match from pre-MSFS backup
- 5,730 GPS entries restored via MSFS account address data
- Coverage improved from 1.6% (124) to 80.4% (6,075) of 7,560 assets
- Remaining 1,485 have no GPS in MSFS account records
2026-05-31 01:40:03 -04:00
shawn 55a21e981f Recategorize: Markets (🏪) replaces Kiosk; 106 coffee machines Bev→Coffee
- Renamed Kiosk category → Markets (includes 365 Retail Markets,
  Intuitivo, Panoptyc, Smart Market, Micro-Market-Ave C)
- Moved 106 coffee brands from Bev to Coffee: Nespresso (36),
  Curtis (23), Krea (14), EVERSYS (12), Bloomfield (6),
  Segafredo (4), Bravilor (4), Simonelli (2), Starbucks (2),
  Marco (2), Cafection Digi-cup (1)
- Updated CATEGORY_MAP
2026-05-31 01:09:40 -04:00
shawn fff2b6a52a feat: add Coffee category () — move 63 De Jong Duke / Impressions vending machines from Equipment
- New Coffee category with amber (#a16207) branding
- 63 coffee vending machines recategorized from Equipment
- CATEGORY_MAP updated in main app
2026-05-31 00:52:02 -04:00
shawn 955e489977 Add Kiosk category, re-categorize 207 assets, fix .gitignore
- New Kiosk category (🏪, purple) for 365 Retail Markets, Panoptyc, Intuitivo,
  Smart Market, Micro-Market — 1,100 assets moved from Equipment/Other
- CRANE (114) → Equipment (was wrongly 'Other')
- Vendo, Royal, DN, DIXIE NARCO (52) → Bev (beverage vending machines)
- USI (2) → Snack
- Cafection Digi-cup, Echostream, Purelogix, Avalon (4) → Bev
- CoolServ, Cold Snap, KoolTek (3) → Appliances (cooling/refrigeration)
- De Jong Duke, AC, Impressions, Lektro-Vend (11) → Equipment
- Remaining 'Other' reduced from 221 to 14 genuine unknowns
- Zero unmapped categories — Category Health clean
2026-05-31 00:40:21 -04:00
shawn f1a3bb9f00 feat: revised category icons + unknown type detection
- Cleaned CATEGORY_MAP: removed dead entries (Furniture, GF Bev,
  Snack/bev, Snack/food), fixed duplicate 'Food' key, proper
  distinct icons for all 6 real categories
- Added categoryInfo() helper: 🥤/🍿/🍔/🔧/🔌/📦 for known cats,
   (red) for unmapped categories, 📦 for null
- Updated all render sites (list, map pins, map list, detail view,
  scan result, OCR result) to use categoryInfo()
- Added category icons to detail view and scan/OCR result headers
- Synced categories DB table: added Equipment/Appliances with icons,
  removed unused Unknown
- Updated seed data in both server.py and admin_server.py to match
  real asset categories
- Added category_health to /api/stats with unmapped category detection
- Added Category Health card to admin dashboard (shown only when
  unmapped categories exist)
- Updated CSS backgrounds for all real categories + .cat-unmapped
2026-05-31 00:28:03 -04:00
shawn 5c2e15e28b ux: persist help overlay dismissed state via localStorage 2026-05-30 21:20:50 -04:00
shawn 474a618f79 UX fixes: fix Map tab crash, remove Edit/Delete buttons, update Help dialog and menu labels 2026-05-30 20:56:28 -04:00
shawn 9ae0f271ea chore: remove accidentally committed gps backup file 2026-05-29 23:17:53 -04:00
shawn 17b870e4cc feat: wire vision/OCR results back into asset records
- Auto-save photo_path to matched assets when photo uploaded via /api/ocr
- Auto-extract and save serial_number from OCR text to blank-serial matched assets
- Add _extract_serial_from_text helper for serial number pattern matching
- Create scripts/backfill_vision_photos.py for batch-processing unlinked photos
- Add docs/OCR_PIPELINE.md documenting the full OCR/vision pipeline
- Fix test DB schema: add connect_id, equipment_id, barcode, customer_name
- Fix OCR test assertions to match actual endpoint behavior
2026-05-29 10:03:51 -04:00
shawn 99cef94153 Cross-reference photos against assets DB
- Scanned all 15 photos in uploads/photos/ via vision + Tesseract OCR
- Matched keurig_label.jpg → Asset #5144 (machine_id 636671)
- Updated Asset #5144 photo_path in assets.db
- Created scripts/crossref_photos.py for reusable batch matching
- Reported unmatched IDs for coca_cola_label.jpg and telemetry_device.jpg
- Generated crossref_report.md with full findings
2026-05-29 09:56:49 -04:00
shawn e2db719fd7 docs: update OCR description to reflect Ollama vision primary 2026-05-29 09:00:27 -04:00
shawn b80ed4b483 feat: Ollama vision OCR via Windows PC for label photo matching
- Add persistent SSH tunnel (systemd service) to Windows PC Ollama
- Add _HAS_OLLAMA check at server startup (qwen2.5vl:3b)
- Replace Tesseract-only OCR with Ollama-first strategy
- Ollama (qwen2.5vl:3b) handles dark/complex labels Tesseract can't read
- Keurig serial 2500.0100.0025534 now correctly matched as Asset #5144
- Add ocr_source field to /api/ocr response ('ollama' or 'tesseract')
- Fix match_label_photo.py argparse bug (image_paths -> images)
- Update match_label_photo.py CLI to read vision key from Hermes config
2026-05-29 08:59:53 -04:00
shawn 8022c77b70 feat: add normalized matching for label photos → asset DB lookup
- normalize_identifier() strips dots/dashes/prefixes, keeps alphanumeric
- find_asset_by_normalized_id() searches serial_number, connect_id,
  equipment_id, barcode with normalized comparison
- /api/ocr now returns matched_assets in addition to legacy machine_id
- New /api/match-text endpoint for client-side text matching
- scripts/match_label_photo.py CLI tool for OCR + DB matching
- Vision model fixed (mimo-v2-omni at opencode.ai, was using
  truncated placeholder key)
2026-05-29 08:37:06 -04:00
shawn e10e226743 Add serial_number to barcode search
- /api/assets/search now also matches serial_number column
  so scanning a sticker with just the serial number works
2026-05-29 07:59:37 -04:00
shawn 8c8f8a261c Add 2D barcode scanning + connect_id search
- Add QR_CODE, DATA_MATRIX, PDF_417, AZTEC to barcode scanner hints
  so serial number stickers with 2D barcodes can be scanned
- Update /api/assets/search to also match connect_id so full
  Connect IDs (e.g. VM-05912-0000068454) work when scanned
- Bump SW cache v5→v6, footer v3.2→v3.3
2026-05-29 07:55:44 -04:00
shawn 2bdcfe0bae feat: branch fallback for assets without account linkage
- 1,606 more assets now get company from branch/territory name
  (e.g. 'Canteen Corp/Orlando, FL') when the connect_id exists
  in extraction DB but has no linked account name
- Coverage: 7,349/7,488 (98.1%) have company + customer_name
- Only 139 remain empty (4 prefixes not in branch cache)
- Updated AGENTS.md with data population docs
2026-05-29 07:51:02 -04:00
shawn 4f9c0d0b75 feat: populate company/place/customer_name from MSFS extraction DB
- New script: scripts/populate_asset_company_place.py maps connect_id →
  account name (company/customer_name) + city (place) + address via
  extraction DB (msdyn_customerasset + account join)
- 5,743/7,488 assets now have company + customer_name populated
- 5,647/7,488 assets now have place (city) populated
- Fix: SELECT COALESCE(c.name, a.customer_name) so stored customer_name
  isn't overridden to NULL when customer_id FK is missing
- Bump SW v4→v5, version v3.1→v3.2 to force cache refresh
2026-05-29 07:45:01 -04:00
shawn 5180d811a8 fix: add place/building/floor fields to main detail view
The detail view's Asset Details card was missing location fields
(place, building_name, building_number, floor, room, trailer_number)
that were visible in the list cards. Added them alongside Model
so building/floor info appears in the main card rather than only
in the collapsed Directions & Access section below.
2026-05-29 00:25:10 -04:00
shawn f1eb453a19 fix: keep GUEST/CAST visible in asset names
- clean_customer_name() no longer strips GUEST/CAST suffixes
- Re-ran migration: 967 asset names updated with GUEST/CAST preserved
- Format: '95301 - Animal Kingdom CAST / address / building'
  vs previous: '95301 - Animal Kingdom / address / building'
2026-05-29 00:13:08 -04:00
shawn 9ee8de8578 fix: add/find tab now lookup-only, nav fix, route techs fix
- Add/Find tab: removed check-in card and add-asset form, now pure lookup
- OCR: stop camera after capture, added GPS capture at photo time
- OCR: increased timeout from 8s to 15s to reduce 500 errors
- Nav tab: added GPS prompt + asset search when navigating directly
- Route: include all 26 techs from bookableresource table (not just Shawn)
2026-05-29 00:10:30 -04:00
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
shawn ae3b114ebc feat: import Seed (mycantaloupe.com) data — 1,655 assets enriched
- Added import_seed.py — reads Machine List(8).xlsx, matches by Asset ID → machine_id
- 51 new columns on assets table (route, barcode, sales, contact info, etc.)
- seed_data table with full 62-field JSON per asset for detail enrichment
- Seed data card in frontend asset detail view
- GET /api/assets/{id} now returns result.seed and result.seed_imported_at
- GPS columns from Seed ignored per request
- 1,654 matched, 199 unmatched (Seed-only assets not in MSFS)

Refs #47
2026-05-28 23:28:08 -04:00
shawn 908c67a26c docs: update msfs-data-import.md after clean-slate reset
- Cleared all MSFS-derived (address-geocoded) GPS
- Only real field GPS (28 assets from photo EXIF) remains
- Documented backup locations and clean-slate process

Refs #46
2026-05-28 23:01:17 -04:00
shawn a8158566ac 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
2026-05-28 22:28:34 -04:00
shawn 3a8f89432a fix: stray closing brace broke entire JS script + remove login gate for guest mode 2026-05-28 22:16:43 -04:00
shawn b07c30da5e docs: MSFS data import pipeline — extraction → merge → assets.db 2026-05-28 22:07:14 -04:00
shawn 77ffb0ab2a Remove unused exifr CDN from index.html 2026-05-28 22:04:27 -04:00
shawn 7c1ceafc6b Remove auth: fall back to default admin user when no token provided (field tool mode) 2026-05-28 22:03:58 -04:00
shawn f3d176116a Major app overhaul: MSFS data import, lookup-only Find tab, OSRM nav, cleanup 2026-05-28 21:57:58 -04:00
shawn a9f2f88604 ux: Increase map max-height from 60vh to 70vh for better square ratio 2026-05-27 19:51:45 -04:00
shawn 855976e2b6 ux: Map tab enhancements + toast replacements
- Replace 9 native alert()/confirm() calls with showToast()
- Add search bar over map (name, machine_id, category, description)
- Add filter chips with category counts (Bev, Food, Snack, etc.)
- Make map container square (aspect-ratio 1/1)
- Add asset list below map showing filtered results
- Increase asset limit from 1000 to 2000
- Fix missing closing brace in .geofence-panel CSS
- Zero console errors on all tabs
2026-05-27 19:33:43 -04:00
shawn e66bf2638b fix: route planner fetch calls now use api() wrapper with auth token
Root cause: rpLoadTechs() and all other route planner API calls used
raw fetch() instead of the api() wrapper, so no Authorization header
was attached. Auth middleware returned 401, technician list never
loaded, users saw 'Select at least one technician'.

Fixed by replacing all 6 fetch('/api/...') calls in the route planner
with api('/api/...') and removing redundant .json() parsing (api()
returns parsed data directly).

Tested in browser: technician checkboxes populate, Load Today's Route
returns 3 optimized stops with 12.5km route, map renders correctly,
search tab queries return Disney WO results via auth.

Closes: #issue-to-be-created
2026-05-27 19:08:58 -04:00
shawn 3eb09bd861 ux: 5.0 heuristic scorecard — loading states, help modal, keyboard shortcuts, guided empty states, error recovery 2026-05-27 18:57:28 -04:00
shawn b2ceac1594 ux: improve login loading state, toast timing, tab label consistency, enable location filter 2026-05-27 18:35:49 -04:00
38 changed files with 8589 additions and 1551 deletions
+6 -4
View File
@@ -1,11 +1,13 @@
cert.pem
*.db *.db
*.db-journal *.db-journal
*.db-shm *.db-shm
*.db-wal *.db-wal
uploads/*.jpg
uploads/*.png
key.pem key.pem
cert.pem *.pre-msfs-import
__pycache__/ __pycache__/
.venv/ uploads/*.jpg
uploads/photos/ uploads/photos/
uploads/*.png
.venv/
*.bak
+14 -2
View File
@@ -1,6 +1,18 @@
# Canteen Asset Tracker — Agent Guide # Canteen Asset Tracker
Mobile-friendly webapp for tracking physical assets with barcode scanning, OCR sticker reading, and GPS check-ins. The main technician-facing application in the Canteen stack. Mobile-friendly webapp for tracking physical assets with barcode scanning, OCR sticker reading, and GPS check-ins. The main technician-facing application.
...
## Company & Place Data
Asset company, customer_name, and place fields are populated from the MSFS Dynamics 365 extraction DB. The migration script `scripts/populate_asset_company_place.py` maps each asset's `connect_id``msdyn_customerasset``account` table to get:
- **company** = account name (e.g. "Jeremy B - 1960 Broadway")
- **place** = city name (e.g. "Orlando")
- **customer_name** = account name (same as company, for search/filter)
5,743/7,488 assets (~77%) have company data; remaining 1,745 have connect_ids with no account linkage in the extraction DB.
## Stack ## Stack
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+128
View File
@@ -23,6 +23,134 @@ from typing import Optional, Tuple
DB_PATH = str(Path(__file__).parent / "assets.db") DB_PATH = str(Path(__file__).parent / "assets.db")
# ─── Universal identifier normalization (for photo→DB matching) ───────────
def normalize_identifier(raw: str) -> str:
"""
Normalize any asset identifier (serial number, barcode, equipment ID,
connect ID, machine ID) for comparison.
- Strips leading label prefixes (S/N:, ID#, Machine ID:, Monyx ID, etc.)
- Removes dots, dashes, spaces, slashes, colons
- Uppercases
- Returns just the alphanumeric core for matching
Examples:
'2500.0100.0025534''2500010000255534'
'201037BA00039''201037BA00039'
'S/N: 2500.0100.0025534''2500010000255534'
'ID# 4434331624226353''4434331624226353'
'Monyx ID 48602143''48602143'
'RY10006338''RY10006338'
'201037BA00039''201037BA00039'
"""
if not raw:
return ''
s = raw.strip().upper()
# Strip common label prefixes
s = re.sub(
r'^(S/N|SN|SERIAL|SERIAL\s*NO|ID|UID|MACHINE\s*ID|MACHINE|'
r'EQUIPMENT\s*ID|EQ\s*ID|ASSET\s*ID|ITEM|MODEL|PART\s*NO|'
r'MONYX\s*ID|PROPERTY\s*OF|BARCODE)\s*[:=#]\s*',
'', s, flags=re.IGNORECASE
)
# Strip leading non-alphanumeric (leftover label debris)
s = re.sub(r'^[^A-Z0-9]+', '', s)
# Remove all non-alphanumeric (dots, dashes, spaces, etc.)
s = re.sub(r'[^A-Z0-9]', '', s)
return s
def find_asset_by_normalized_id(db_path: str, normalized: str) -> list:
"""
Search assets.db for any asset whose serial_number, machine_id, connect_id,
equipment_id, or barcode matches the given normalized identifier.
Supports:
- Exact match: normalized string equals the DB field (after normalization)
- Suffix match: if the normalized value is all digits and >= 7 chars,
match against the last N digits of connect_id and equipment_id (for
partial OCR reads that capture only the numeric tail of a longer ID).
Returns a list of matching rows (dicts).
"""
if not normalized or len(normalized) < 3:
return []
import sqlite3
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
# Suffix matching: if the scanned text is purely numeric and >= 7 chars,
# search for assets whose connect_id or equipment_id ends with those digits.
# Connect IDs often look like VM-05064-0000099387; OCR might only read "0000099387".
suffix_conditions = ''
suffix_params = []
if len(normalized) >= 7 and normalized.isdigit():
# Match against the last N digits of connect_id and equipment_id.
# Use LIKE with rightmost-anchored pattern: %<digits>
suffix_conditions = """
OR replace(replace(replace(replace(upper(connect_id), '-', ''), '.', ''), ' ', ''), '/', '') LIKE ?
OR replace(replace(replace(replace(upper(equipment_id), '-', ''), '.', ''), ' ', ''), '/', '') LIKE ?
"""
suffix_params = ['%' + normalized, '%' + normalized]
rows = conn.execute(f"""
SELECT id, machine_id, name, serial_number, connect_id, equipment_id,
barcode, make, model, category
FROM assets
WHERE replace(replace(replace(replace(upper(serial_number), '-', ''), '.', ''), ' ', ''), '/', '') = ?
OR replace(replace(replace(replace(upper(connect_id), '-', ''), '.', ''), ' ', ''), '/', '') = ?
OR replace(replace(replace(replace(upper(equipment_id), '-', ''), '.', ''), ' ', ''), '/', '') = ?
OR replace(replace(replace(replace(upper(machine_id), '-', ''), '.', ''), ' ', ''), '/', '') = ?
OR replace(replace(replace(replace(upper(barcode), '-', ''), '.', ''), ' ', ''), '/', '') = ?
{suffix_conditions}
""", (normalized, normalized, normalized, normalized, normalized, *suffix_params)).fetchall()
conn.close()
return [dict(r) for r in rows]
def find_assets_by_scanned_text(db_path: str, raw_text: str) -> list:
"""
Given raw OCR text from a label photo, extract all plausible identifiers
and search the DB for matches. Returns list of (normalized, field, asset) tuples.
"""
if not raw_text:
return []
results = []
# 1. Try each line as a potential identifier
lines = raw_text.strip().split('\n')
for line in lines:
line = line.strip()
if not line or len(line) < 4:
continue
norm = normalize_identifier(line)
if len(norm) >= 4:
matches = find_asset_by_normalized_id(db_path, norm)
for m in matches:
results.append((norm, line.strip(), m))
# 2. Also try individual number-like tokens on each line (space-separated values on a line)
for line in lines:
tokens = re.findall(r'[A-Z0-9]{4,}', line.upper())
for token in tokens:
if len(token) >= 4:
matches = find_asset_by_normalized_id(db_path, token)
for m in matches:
results.append((token, line.strip(), m))
# Deduplicate by asset id
seen = set()
unique = []
for norm, src, asset in results:
if asset['id'] not in seen:
seen.add(asset['id'])
unique.append({'normalized': norm, 'source_text': src, 'asset': asset})
return unique
# ─── Character substitution (human entry errors) ────────────────────────── # ─── Character substitution (human entry errors) ──────────────────────────
def normalise_serial(sn: str) -> str: def normalise_serial(sn: str) -> str:
+39
View File
@@ -0,0 +1,39 @@
# Photo Cross-Reference Report
**Generated:** 2026-05-29
## Database
- assets.db: 7,488 assets
## Photos Scanned
15 files in uploads/photos/
## Results
### Matched (1 photo → 1 asset update)
| Photo | Asset ID | Machine ID | Name | Action |
|-------|----------|------------|------|--------|
| keurig_label.jpg | 5144 | 636671 | 636671 / 956 Cypress Way | ✅ photo_path set to /uploads/photos/keurig_label.jpg |
### Unmatched — For Investigation (2 photos)
**coca_cola_label.jpg** — Coca-Cola branded label
- Extracted IDs: RY10006338, RVCC-660-8, 201037BA00039
- RY10006338 is Royal-pattern serial but not in DB
- 201037BA00039 is Royal serial format; closest DB match: Asset #6815 (A201037BA00030)
- Likely a pulled/retired or unregistered machine
**telemetry_device.jpg** — 4G telemetry device
- Extracted IDs: 4434331624226353 (ICCID), 48602143 (Monyx ID)
- These are telemetry/sim identifiers, not vending machine assets
- Consider tracking in a separate telemetry devices table
### Test Data (3 photos)
- 557ba8d1..., 59dd8174..., a8c7e553...: "Machine 1D 12845-678901" — app test pattern, no DB match
### No Text (9 photos)
- Icon-sized images (100x100 to 200x100): no readable text
## Files Changed
- `/home/oplabs/projects/canteen-asset-tracker/assets.db` — Asset #5144 photo_path updated
- `/home/oplabs/projects/canteen-asset-tracker/scripts/crossref_photos.py` — reusable batch matching script
+2 -2
View File
@@ -22,7 +22,7 @@ Three ways to add an asset:
| Method | Description | | Method | Description |
|--------|-------------| |--------|-------------|
| **Barcode** | Scan barcodes via device camera (ZXing library). Found assets redirect to detail view; unknown codes prompt to create new. | | **Barcode** | Scan barcodes via device camera (ZXing library). Found assets redirect to detail view; unknown codes prompt to create new. |
| **OCR** | Upload a photo of a machine ID sticker; text is extracted via Tesseract OCR. | || **OCR** | Upload a photo of a machine ID sticker; text is extracted via Tesseract OCR or Ollama vision model (qwen2.5vl:3b on Windows PC). When Tesseract fails (dark/complex labels), falls back to Ollama for much more accurate text extraction. |
| **Manual** | Full form with all fields. | | **Manual** | Full form with all fields. |
![Manual Entry Form](images/manual-entry.png) ![Manual Entry Form](images/manual-entry.png)
@@ -199,6 +199,6 @@ Returns all geofence service areas assigned to a specific user — useful for fi
| **Maps** | Leaflet 1.9.4 + OpenStreetMap | | **Maps** | Leaflet 1.9.4 + OpenStreetMap |
| **Geofences** | Leaflet Draw + point-in-polygon (Turf.js) | | **Geofences** | Leaflet Draw + point-in-polygon (Turf.js) |
| **Scanner** | ZXing (CDN) | | **Scanner** | ZXing (CDN) |
| **OCR** | Tesseract via pytesseract | | **OCR** | Ollama qwen2.5vl:3b (primary) + Tesseract (fallback) — via SSH tunnel to Windows PC |
| **TLS** | Self-signed cert on port 8901 | | **TLS** | Self-signed cert on port 8901 |
| **Reverse Proxy** | Nginx Proxy Manager Plus | | **Reverse Proxy** | Nginx Proxy Manager Plus |
+225
View File
@@ -0,0 +1,225 @@
# Field Map — Canteen Asset Tracker
Every column in `assets.db`, how each pipeline touches it.
## Source Legend
| Source | Pipeline | File(s) | How to Run |
|--------|----------|---------|------------|
| **MSFS** | ADB pull → Dynamics 365 extraction | `pull_msfs_data.py``sync_assets_to_canteen.py` | Button on extraction viewer `:8910` **Manual** |
| **CANT** | Cantaloupe staged import | `cantaloupe_sync.py` in admin server | Dashboard upload **Manual** |
| **SEED** | Seed Excel import | `parser.py``db_writer.py``sync.py` | CLI `--mode=seed` or `--mode=update` **Manual** |
| **APP** | Main app (technician check-in, photo, GPS) | `server.py` | Used by techs in field |
| **ADMIN** | Admin dashboard edits | `admin_server.py` | Dashboard Cleanup/Mass Edit |
## Policy Legend
- **always** — pipeline overwrites this field every run
- **if_empty** — only filled when currently NULL/empty
- **never** — pipeline never touches this column
- **if_numeric** — only overwritten if value is bare number (Cantaloupe default ID)
---
## 🔵 MSFS Sync — Field Policy
| Column | Policy | Dynamics 365 Source |
|--------|--------|---------------------|
| `serial_number` | always | `hsl_serialnumber` |
| `connect_id` | always | `hsl_connectid` |
| `equipment_id` | always | `hsl_equipmentid` |
| `canteen_connect_guid` | always | `hsl_canteenconnectguid` |
| `barcode` | always | `hsl_barcode` |
| `manufacturer` | always | `hsl_manufacturertext` |
| `model` | always | `hsl_modeltext` or `hsl_modelname` |
| `route_name` | always | `hsl_routeidname` |
| `install_date` | always | `hsl_opendate` |
| `device` | always | `msdyn_deviceid` |
| `name` | if_numeric | `msdyn_name` or `hsl_name` |
| `company` | if_empty | Account `name` |
| `customer_name` | if_empty | Account `name` |
| `place` | if_empty | Account `address1_city` |
| `address` | if_empty | Account `address1_line1` + `line2` |
| `make` | **never** | _(uses `manufacturer` → separate column)_ |
| `building_name` | **never** | — |
| `floor` | **never** | — |
| `room` | **never** | — |
| `building_number` | **never** | — |
| `trailer_number` | **never** | — |
| `location_area` | **never** | — |
| `latitude` | **never** | _(mapped but only if NULL in code)_ |
| `longitude` | **never** | _(mapped but only if NULL in code)_ |
| `photo_path` | **never** | — |
| `category` | **never** | — |
| `status` | **never** | — |
| `description` | **never** | — |
> All other columns (sales data, alerts, disney flags, etc.) are **never** touched by MSFS sync.
---
## 🟡 Cantaloupe Sync — Field Policy
| Column | Policy | Notes |
|--------|--------|-------|
| `machine_id` | never (match key) | — |
| `serial_number` | always | — |
| `name` | always | — |
| `description` | always | — |
| `category` | always | Validated against categories table |
| `status` | always | Validated against statuses |
| `make` | always | — |
| `model` | always | — |
| `address` | always | — |
| `building_name` | always | — |
| `building_number` | always | — |
| `floor` | always | — |
| `room` | always | — |
| `trailer_number` | always | — |
| `walking_directions` | always | — |
| `map_link` | always | — |
| `parking_location` | always | — |
| `photo_path` | always | — |
| `latitude` | always | — |
| `longitude` | always | — |
| `geofence_radius_meters` | always | — |
| `dex_report_date` | always | — |
| `deployed` | always | — |
| `pulled_date` | always | — |
| `customer_id` | always | Via customer name lookup/creation |
| `location_id` | always | Via location name lookup/creation |
| `is_disney` | always | Derived from customer name prefix `D-` |
> **No preserve logic.** Any non-empty field in the import file overwrites the existing value.
---
## 🟢 Seed Import — Field Policy (db_writer.py)
### Seed Mode (`--mode=seed`)
**DESTROYS AND RE-INSERTS** — `DELETE FROM assets` first, then inserts all rows fresh.
### Update Mode (`--mode=update`)
Upsert by `machine_id`. **MSFS is primary data source** — seed fills gaps only.
**ALL fields** are treated as ONLY_IF_EMPTY for existing records:
| Column | Policy | Excel Source / Generation |
|--------|--------|--------------------------|
| `serial_number` | if_empty | Serial Number column + OCR corrections |
| `name` | if_empty | Generated: `{make} @ {place}` |
| `make` | if_empty | Parsed from Type column |
| `model` | if_empty | Parsed from Type column |
| `class` | if_empty | Parsed from Type column |
| `is_glass_front` | if_empty | Parsed from Type column |
| `company` | if_empty | Parsed from Customer column |
| `place` | if_empty | Parsed from Place column |
| `building_name` | if_empty | Parsed from Place column |
| `suite` | if_empty | Parsed from Place column |
| `room` | if_empty | Parsed from Place column |
| `trailer` | if_empty | Parsed from Place column |
| `floor` | if_empty | Parsed from Place column |
| `zone` | if_empty | Parsed from Place column |
| `zone_type` | if_empty | Parsed from Place column |
| `address` | if_empty | Address column |
| `location_area` | if_empty | City column |
| `latitude` | if_empty | Excel (existing GPS preserved) |
| `longitude` | if_empty | Excel (existing GPS preserved) |
| `photo_path` | if_empty | Excel (existing photo preserved) |
| `install_date` | if_empty | Added Date (existing date preserved) |
| `deployed` | if_empty | Deployed column (existing preserved) |
| `pulled_date` | if_empty | Pulled Date (existing preserved) |
| `dex_report_date` | if_empty | Last Dex Report Time |
| `disney_park` | if_empty | Parsed from Customer column |
| `disney_area` | if_empty | Derived from disney_park |
| `remote_pricing_status` | if_empty | Status column |
| `alerts` | if_empty | Alerts column + Coil/Product Alerts |
| `telemetry_provider` | if_empty | Parsed from Device column |
| `card_reader_brand` | if_empty | Parsed from Device column |
| `priority` | if_empty | Computed from yearly_sales |
| `yearly_sales` | if_empty | Yearly Sales column |
| `monthly_sales` | if_empty | Monthly Sales column |
| `weekly_sales` | if_empty | Weekly Sales column |
| `days_since_restock` | if_empty | Days Since Restock column |
| `prepick_group` | if_empty | Prepick Group column |
| `has_cashless` | if_empty | Has Cashless column |
> **New assets** (not found in DB by machine_id) are inserted with full seed data.
> **Existing assets**: only empty/null fields are filled. Any field already populated by MSFS, cleanup, or field use is preserved.
---
## 🟠 Seed Import — Sync (`sync.py` → main app DB)
After `db_writer.py`, this script pushes the seed DB into the main app's schema.
**Now follows ONLY_IF_EMPTY policy** for existing assets — same as db_writer update mode.
| Column | Policy |
|--------|--------|
| `machine_id` | never (match key) |
| `serial_number` | if_empty |
| `name` | if_empty |
| `description` | if_empty |
| `category` | if_empty |
| `status` | if_empty |
| `make` | if_empty |
| `model` | if_empty |
| `address` | if_empty |
| `building_name` | if_empty |
| `building_number` | if_empty |
| `floor` | if_empty |
| `room` | if_empty |
| `trailer_number` | if_empty |
| `walking_directions` | if_empty |
| `map_link` | if_empty |
| `parking_location` | if_empty |
| `photo_path` | if_empty |
| `latitude` | if_empty |
| `longitude` | if_empty |
| `geofence_radius_meters` | if_empty |
| `disney_park` | if_empty |
| `is_disney` | if_empty |
| `dex_report_date` | if_empty |
| `install_date` | if_empty |
| `deployed` | if_empty |
| `pulled_date` | if_empty |
| `customer_id` | if_empty (via upsert customers table) |
| `location_id` | if_empty (via upsert locations table) |
> **Also creates/updates** `customers` and `locations` tables.
---
## ⚪ Admin Tools — Policy
### Batch Update (Cleanup page)
- `make`, `model`, `name`, `company`, `place`, `building_name`, `building_number`, `floor`, `room`, `location_area`, `address`, `category`, `status`, `customer_name`
- **Always overwrites** with user-provided value.
### Clear GPS
- `latitude`, `longitude` — set to NULL
### Mass Edit (existing tool)
- Same allowed fields as Batch Update.
---
## ⚫ Main App (Tech Field) — What Touches What
| Action | Fields Affected |
|--------|----------------|
| Barcode scan check-in | `updated_at` |
| GPS check-in | `latitude`, `longitude`, `updated_at` |
| Photo upload | `photo_path`, `updated_at` |
| OCR scan (name search) | (read-only) |
| Register new asset | All columns with defaults |
---
## Pipeline Activity Status
| Pipeline | Currently Scheduled? | Frequency | Risk to Cleanup |
|----------|--------------------|-----------|-----------------|
| **MSFS Sync** | 🔴 **Manual only** | On-demand via extraction viewer | ✅ GPS never touched. Preserves name/company/place/building/floor. Only `model` overwritten. |
| **Cantaloupe Sync** | 🔴 **No cron** | Manual upload on admin dashboard | ⚠️ Preserves GPS now (fixed — none from Cantaloupe). Still overwrites other fields. |
| **Seed Import** `--mode=update` | 🔴 **Manual CLI only** | `python3 main.py -m update` | ✅ **GPS never touched** (check-in only). Fills gaps only for other fields. |
| **Seed Import** `--mode=seed` | 🔴 **Manual CLI only** | `python3 main.py -m seed` | 🔥 **NUCLEAR**`DELETE FROM assets`. Full re-insert. GPS set to NULL. |
+174
View File
@@ -0,0 +1,174 @@
# OCR / Vision Pipeline
> End-to-end: camera capture → text extraction → asset matching → auto-update.
The Canteen Asset Tracker has a multi-stage OCR pipeline that reads machine ID sticker photos, cross-references extracted identifiers against the assets database, and automatically updates matched assets with photo paths and serial numbers.
## Pipeline Stages
```
Camera/Gallery Photo
┌──────────────────┐ Stage 1: Vision (Ollama qwen2.5vl:3b)
│ Ollama Vision │ Runs first — most accurate on complex labels
│ (Python API) │ Runs on Windows PC (RTX 2080), tunneled via SSH
└────────┬─────────┘ to localhost:11434
│ (fallback if text < 10 clean chars)
┌──────────────────┐ Stage 2: OCR (Tesseract)
│ Tesseract OCR │ Fallback — works on clean, high-contrast labels
│ (pytesseract) │ Uses `--psm 6` (uniform block of text)
└────────┬─────────┘
┌──────────────────┐ Stage 3: Identifier Extraction
│ Normalize & │ - Strips label prefixes (S/N:, ID#, Machine ID, etc.)
│ Extract IDs │ - Removes punctuation, uppercases
└────────┬─────────┘ - Extracts full lines + individual tokens
┌──────────────────┐ Stage 4: DB Cross-Reference
│ Match against │ - Matches normalized IDs against serial_number,
│ assets.db │ machine_id, connect_id, equipment_id, barcode
└────────┬─────────┘ - Deduplicates by asset id
┌──────────────────┐ Stage 5: Auto-Update
│ Write Back to │ - photo_path → set on matched assets (if blank)
│ Matched Assets │ - serial_number → extracted from OCR text (if blank)
└──────────────────┘ - GPS coords → set on matched asset (if blank, from EXIF)
```
## API Endpoints
### POST `/api/ocr` — Full OCR pipeline
Accepts an image upload (multipart/form-data). Returns extracted text, matched assets, and auto-updates the database.
**Request:**
```
POST /api/ocr
Content-Type: multipart/form-data
file: <image> # Required: the sticker photo
exif_data: <JSON string> # Optional: client-side EXIF data for gallery uploads
```
**Response fields:**
| Field | Description |
|-------|-------------|
| `raw_text` | Raw text extracted by Ollama or Tesseract |
| `ocr_source` | `"ollama"`, `"tesseract"`, or `"none"` |
| `machine_id` | 5-digit machine ID from Connect-ID pattern (XXXXX-XXXXXX) |
| `confidence` | `"high"` (exact pattern match), `"low"` (loose match), `"none"` |
| `matched_assets` | Array of assets matched via identifier cross-reference |
| `exif_gps` | GPS coordinates from photo EXIF (if present) |
| `gps_saved` | `true` if GPS was auto-saved to the matched asset |
| `path` | Saved photo URL path (when `exif_data` provided) |
| `photo_saved` | Number of matched assets whose `photo_path` was auto-updated |
| `serial_saved` | Serial number value that was auto-saved to blank-serial matched assets |
### POST `/api/upload/photo` — Photo-only upload
Saves a photo without OCR. Returns the saved path and any EXIF GPS data.
Use this when the photo was already matched to an asset client-side.
### POST `/api/match-text` — Text matching only
Accepts raw text and finds matching assets. Does not run OCR.
Use for barcode scanner input, QR codes, or client-side vision results.
## Auto-Update Logic
When a photo is uploaded through `/api/ocr` and matches database assets:
1. **photo_path** — If the matched asset has no `photo_path` set, the saved photo's URL path is written to the asset. This links the photo directly to the asset record for display in the asset detail view.
2. **serial_number** — If the matched asset has a blank `serial_number` and the OCR text contains a serial-number pattern (prefixed with S/N, Serial#, Equipment ID, etc.), the extracted serial is written to all matched blank-serial assets.
3. **GPS coordinates** — If the photo has EXIF GPS data and the matched asset has no lat/lng coordinates, they are auto-saved.
All auto-updates are best-effort and non-critical — they don't fail the OCR endpoint if a DB write errors.
## Backfill Script
### `scripts/backfill_vision_photos.py`
Processes all photos in `uploads/photos/` that aren't already linked to an asset via `photo_path`. Useful for:
- Processing photos that were uploaded before the auto-link feature was added
- Retrying photos that failed to match initially
- Bulk-linking a directory of asset photos
```bash
# Dry-run: see what would be updated
python3 scripts/backfill_vision_photos.py
# Apply changes
python3 scripts/backfill_vision_photos.py --apply
# Force overwrite existing photo_path values
python3 scripts/backfill_vision_photos.py --apply --force
```
Options:
| Flag | Description |
|------|-------------|
| `--apply` | Write changes to DB (default is dry-run) |
| `--force` | Overwrite existing `photo_path` values on assets |
| `--db` | Custom database path |
| `--photos-dir` | Custom photos directory |
## `scripts/match_label_photo.py`
CLI utility to OCR a single image and display matched assets. Supports `--text` flag to skip OCR and pass raw text directly.
```bash
# OCR a photo
python3 scripts/match_label_photo.py path/to/photo.jpg
# Match against pre-extracted text
python3 scripts/match_label_photo.py --text "S/N: 2500.0100.0025534"
```
## OCR Services
### Primary: Ollama Vision (qwen2.5vl:3b)
- **Host:** Windows gaming PC (RTX 2080), tunneled to `localhost:11434`
- **Accuracy:** High — can read text from complex labels, dark backgrounds, angled photos
- **Speed:** ~5-15 seconds per image (depends on GPU load)
- **Endpoint:** `/api/generate` with prompt focused on text extraction
### Fallback: Tesseract OCR
- **Installation:** `sudo apt-get install -y tesseract-ocr`
- **Python:** `pip install pytesseract Pillow`
- **Accuracy:** Good on clean, high-contrast, well-lit labels
- **Mode:** `--psm 6` (assume uniform block of text)
### Availability Check
The server checks both services at startup:
- `_HAS_OLLAMA` — set if `http://127.0.0.1:11434/api/generate` responds
- `_HAS_TESSERACT` — set if `pytesseract` is importable
## Identifier Matching
The `normalize_identifier()` function in `classify_makes.py` handles identifier normalization:
1. Strips label prefixes (`S/N:`, `ID#`, `Machine ID`, `Monyx ID`, etc.)
2. Removes dots, dashes, spaces, slashes, colons
3. Uppercases everything
4. Returns just the alphanumeric core
This normalized string is then searched across `serial_number`, `connect_id`, `equipment_id`, `machine_id`, and `barcode` columns in the assets table.
## Photo Storage
- **Directory:** `uploads/photos/`
- **Naming:** UUID hex (avoids filename collisions)
- **Extensions:** `.png`, `.jpg`, `.jpeg`, `.dng` (configurable via `PHOTO_ALLOWED_EXTS`)
- **Max size:** 20 MB (configurable via `PHOTO_MAX_SIZE`)
- **Serving:** Mounted at `/uploads` via FastAPI StaticFiles
- **EXIF round-trip:** Client reads EXIF before upload, server re-embeds it into saved JPEG
+327
View File
@@ -0,0 +1,327 @@
# MSFS Data Import — End-to-End Pipeline
> How Microsoft Dynamics 365 Field Service data was extracted, merged with Cantaloupe data, and imported into the Canteen Asset Tracker.
>
> **Date:** 2026-05-28 (initial), 2026-05-28 (clean-slate reset)
> **Assets in DB:** 9,636
> **Assets with real GPS:** 28 (field-collected, not address-geocoded)
> **GPS backup:** `/tmp/gps_restore.sql` (28 UPDATE statements by machine_id)
> **Pre-import backup:** `assets.db.20260528_225934.pre-msfs-import`
> **Note:** All GPS from MSFS (address-geocoded) was cleared during the clean-slate reset. Only real field GPS (from technician photo EXIF) remains.
---
## 1. Extraction from Dynamics 365
The Dynamics 365 Field Service mobile app (Android) on a rooted Pixel 4a was the data source. The offline SQLite databases stored on the device contain full schema snapshots — `msdyn_customerasset`, `msdyn_workorder`, `account`, `bookableresourcebooking`, and 167+ other tables.
### 1.1 Phone → Disk
```bash
# Pull the main offline DB from the device
adb shell "su -c cp /data/data/com.microsoft.crm.crmphone.fieldServices/databases/msfs_backup.db /sdcard/"
adb pull /sdcard/msfs_backup.db data-samples/
# 1.7 GB SQLite database, pulled 2026-05-28
```
### 1.2 Dynamics 365 API Extraction (Live)
A Service Principal application was registered in Entra ID (same tenant as Field Service) with client credentials flow. The `dynamics_token.py` manager auto-refreshes tokens stored in `pass`.
```bash
# Get a token and pull equipment, accounts, work orders via Dataverse WebAPI
python3 dynamics_token.py # Outputs Bearer token
python3 query_booking_gps.py # Booking GPS from live API
```
**Results from live API:**
| Entity | Records |
|---|---|
| Equipment (`msdyn_customerasset`) | 14,440 |
| Accounts (`account`) | 5,305 |
| Work Orders (`msdyn_workorder`) | 15,531 |
### 1.3 Photo Extraction
3,703 technician photos were extracted from the offline DB's `annotation` / `activitymimeattachment` binary blobs to disk at `web/static/photos/`. These are actual field photos taken by technicians on their phones, containing:
- **EXIF GPS** from the phone camera (176 photos)
- **ConnectID stickers** and barcode images
- Work order photos of machines at customer sites
---
## 2. Merge (MSFS + Canteen)
### 2.1 The Merge Script
`standalone-merge` joins MSFS and Canteen records by **serial number** (`hsl_serialnumber`). No other common key exists.
**Source tables from MSFS:**
- `msdyn_customerasset` — equipment records with serials, GPS, manufacturer, model, ConnectID
- `msdyn_workorder` — work order history linked to assets
- `account` — customer account details with geocoded addresses
**Source from Canteen:**
- Old `assets.db` (1,848 records from Cantaloupe CSV import)
### 2.2 Join Logic
```python
# standalone-merge → merge()
all_serials = set(msfs_assets) | set(canteen_assets)
for serial in sorted(all_serials):
if msfs and canteen: match = "joined" # Both sources have this asset
elif msfs: match = "msfs_only" # Only MSFS knows about it
else: match = "canteen_only" # Only Cantaloupe import has it
```
Fields are prefixed by source: `msfs_*` for MS Field Service fields, `canteen_*` for Cantaloupe import fields. Work orders are attached per-asset.
### 2.3 Merge Results
| Metric | Count |
|---|---|
| **Total merged records** | 10,038 |
| **Joined** (both sources, matched by serial) | 1,832 |
| **MSFS-only** (no Cantaloupe match) | 8,204 |
| **Canteen-only** (no MSFS match) | 2 |
| **Work orders linked** to assets | 9,652 |
| **Assets with work orders** | 3,199 |
| **Accounts resolved** | 3,486 |
| **Customers resolved** | 284 |
| **Locations resolved** | 364 |
**Timing:** The "MSFS-only" dominance means the old Cantaloupe import had very limited coverage. Most of the 8,204 MSFS-only assets are from Dynamics-only equipment not managed in the Cantaloupe system.
---
## 3. Field Mapping
### 3.1 GPS Resolution (Priority Order)
When importing into the assets table, GPS coordinates are resolved in this order:
1. **MSFS latitude/longitude** from `msdyn_customerasset` (if present)
2. **Canteen latitude/longitude** from old imports (if MSFS missing)
3. If both missing → `NULL` (no GPS — 9,608 assets fall here)
The GPS in `msdyn_customerasset` itself is **geocoded account addresses**, not real device GPS. Real EXIF GPS from technician photos would need the OCR pipeline (see §4).
### 3.2 Field Mapping Spec
| `assets` column | Source | MSFS field |
|---|---|---|
| `machine_id` | msfs_machine_id → canteen_machine_id | Extracted from `hsl_equipmentid` suffix |
| `serial_number` | serial_number | `hsl_serialnumber` |
| `name` | msfs_name → canteen_name | `msdyn_name` |
| `make` | msfs_manufacturer | `hsl_manufacturertext` |
| `model` | msfs_dex_model | `hsl_dexmodel` |
| `latitude` / `longitude` | msfs → canteen | `msdyn_latitude` / `msdyn_longitude` |
| `address` | account.address | From `account` table |
| `connect_id` | msfs_connect_id | `hsl_connectid` |
| `canteen_connect_guid` | msfs_canteen_connect_guid | `hsl_canteenconnectguid` |
| `manufacturer` | msfs_manufacturer | `hsl_manufacturertext` |
| `equipment_id` | msfs_equipment_id | `hsl_equipmentid` |
| `company` | canteen_company | Old Cantaloupe import |
| `category` | canteen_category | Old Cantaloupe import |
| `install_date` | canteen → msfs | `hsl_opendate` |
### 3.3 Output JSON
`web/static/data/merged-assets.json` — 10,038 records (159 MB) with full field mapping, work order attachments, account/customer/location resolution, and GPS coordinates.
---
## 4. OCR & Photo EXIF GPS Pipeline
### 4.1 Problem
- 9,608 of 9,636 assets have no real GPS
- Existing GPS is geocoded addresses (account-based, not actual machine location)
- 176 photos contain real EXIF GPS from technician phone cameras
### 4.2 Approach
The plan in `docs/plans/2026-05-28-photo-exif-gps-pipeline.md` outlines a multi-pass OCR pipeline:
1. **Tesseract OCR** on all 3,703 photos — 4-tier cascade (scales, preprocessing modes, PSM variants)
2. **Vision API fallback** (`mimo-v2-omni`) for Tesseract failures
3. **Machine ID extraction** from ConnectID stickers (`XXXXX-YYYYYY`), serial plates, barcodes
4. **Cross-reference** OCR results with photo EXIF GPS
5. **Write high-confidence** updates to `assets.db` with `gps_source = 'photo_exif'`
**Current status:** 201 photos OCR'd as proof of concept (5.4%), 14 unique machine IDs identified.
### 4.3 Key Scripts
| Script | Purpose |
|---|---|
| `ocr_local.py` | Tesseract 4-tier local OCR cascade |
| `ocr_batch.py` | Vision API batch OCR (OpenCode Go) |
| `ocr_mapping.py` | OCR results → machine ID → canteen asset mapping |
| `consolidate_gps.py` | Multi-source GPS consolidation |
| `_ocr_analyze.py` | OCR failure analysis |
| `standalone-merge` | MSFS → Canteen merge |
---
## 5. Import Script
### `import_msfs.py`
The stand-alone import script located at `~/projects/canteen-asset-tracker/import_msfs.py` performs:
1. **Backup** existing `assets.db``assets.db.{timestamp}.pre-msfs-import`
2. **Read** `merged-assets.json` (10,038 records)
3. **Create fresh** `assets.db` with full v2 schema (18 tables, indexes, triggers)
4. **Map** merged fields to assets table columns per the mapping spec above
5. **Deduplicate** by `machine_id` with priority: joined > msfs_only > canteen_only
6. **Insert** categories, customers, locations from merged data
7. **Insert** default users (admin/technician)
8. **Verify** with table/row/index counts
### Run
```bash
cd ~/projects/canteen-asset-tracker
python3 import_msfs.py
```
### DB Stats After Import
```sql
Assets total: 9,636
Assets with GPS: 28
Categories: 21
Customers: 284
Locations: 364
Users: 2 (admin, tech)
Table count: 18
Index count: 6
DB size: ~11 MB (WAL mode)
```
---
## 6. Extraction DB (Live Route Planning)
The extraction DB is linked from the canteen asset tracker server for live work order and route optimization queries:
```python
# server.py → _get_extraction_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"
)
```
Five endpoints use this:
| Endpoint | Purpose |
|---|---|
| `GET /api/workorders/search` | Paginated search by name/account/city |
| `POST /api/workorders/lookup` | Bulk lookup by work order ID/name |
| `GET /api/workorders/today` | Today's active bookings by technician |
| `GET /api/workorders/technicians` | Distinct technician list |
| `POST /api/route/optimize` | TSP route optimization (nearest-neighbor + 2-opt) |
---
## 7. File Locations
| Artifact | Path |
|---|---|
| MSFS backup DB | `~/projects/ms-field-service-extraction/data-samples/msfs_backup/` |
| Merged JSON | `~/projects/ms-field-service-extraction/web/static/data/merged-assets.json` |
| Live API data | `data-samples/dynamics_equipment.json` (14,440), `dynamics_accounts.json` (5,305), `dynamics_workorders.json` (15,531) |
| Photos | `~/projects/ms-field-service-extraction/web/static/photos/` (3,703 files) |
| OCR results | `web/static/data/ocr_results.json` |
| Merge script | `~/projects/ms-field-service-extraction/standalone-merge` |
| Import script | `~/projects/canteen-asset-tracker/import_msfs.py` |
| Canteen SQLite DB | `~/projects/canteen-asset-tracker/assets.db` |
| Pre-import backup | `assets.db.20260528_214316.pre-msfs-import` |
| GPS consolidation | `~/projects/ms-field-service-extraction/consolidate_gps.py` |
| GPS consolidated JSON | `web/static/data/gps_consolidated_update.json` |
| Data catalog | `~/projects/ms-field-service-extraction/DATA_CATALOG.md` |
| Photo EXIF GPS plan | `docs/plans/2026-05-28-photo-exif-gps-pipeline.md` |
---
## 8. Architecture Diagram
```
┌─────────────────────────────────────┐
│ Rooted Pixel 4a │
│ (Shawn Canada's device) │
│ MS Field Service Mobile App │
│ Offline SQLite (1.7 GB, 167+ tbls) │
└──────────────┬──────────────────────┘
│ adb pull
┌─────────────────────────────────────┐
│ MSFS Backup DB │
│ data-samples/msfs_backup/ │
│ - msdyn_customerasset (14K rows) │
│ - msdyn_workorder (15K rows) │
│ - account (5K rows) │
│ - annotation (photos, 3.7K blobs) │
└──────────┬──────────────────────────┘
┌────────────────┼────────────────────┐
▼ ▼ ▼
┌─────────────────┐ ┌─────────────┐ ┌─────────────────┐
│ photo extraction │ │ merge │ │ Dynamics API │
│ 3,703 JPEGs │ │ standalone │ │ (Service Prin.) │
│ EXIF GPS → 176 │ │ -merge │ │ 14K equipment │
│ → OCR pipeline │ │ serial join │ │ 15K work orders │
└────────┬─────────┘ └──────┬──────┘ └─────────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────┐
│ merged-assets.json │
│ 10,038 records │
│ 1,832 joined / 8,204 MSFS-only │
│ 9,652 work orders linked │
└──────────────────┬──────────────────────────┘
│ import_msfs.py
┌─────────────────────────────────────────────┐
│ assets.db │
│ 9,636 assets (was 1,848) │
│ 28 with GPS │
│ 18 tables │
│ 6 indexes │
└──────────────────┬──────────────────────────┘
│ server.py links for
│ live route/work order
┌─────────────────────────────────────────────┐
│ Canteen Asset Tracker App │
│ → Find Asset (barcode/OCR lookup) │
│ → Map (Leaflet, GPS pins) │
│ → Route (TSP optimization, extraction DB) │
│ → Nav (OSRM driving/walking) │
└─────────────────────────────────────────────┘
```
---
## 9. Future Work
The **Photo EXIF GPS Pipeline** (see `docs/plans/2026-05-28-photo-exif-gps-pipeline.md`) is designed to add real GPS to the remaining 9,608 assets:
| Task | Description | Status |
|---|---|---|
| 0 | Diagnose OCR bottleneck (why only 14/3,703 photos yield IDs) | ⬜ |
| 1 | Fix OCR regex for ConnectID, serial, barcode formats | ⬜ |
| 2 | Batch OCR all 3,703 photos with Tesseract (all photos, not just GPS) | ⬜ |
| 3 | Vision API OCR on Tesseract failures | ⬜ |
| 4 | Multi-source disambiguation (timestamps + location) | ⬜ |
| 5 | Push verified GPS into `assets.db` with source tracking | ⬜ |
| 6 | Verify and visualize on map | ⬜ |
Each photo with EXIF GPS that can be matched to a machine ID gives us a **real device GPS coordinate** — far more accurate than the current geocoded addresses.
+630
View File
@@ -0,0 +1,630 @@
#!/usr/bin/env python3
"""
Import MSFS merged data into a fresh assets.db for the Canteen Asset Tracker.
Steps:
1. Back up the existing assets.db with a timestamp suffix
2. Read merged-assets.json (10,038 records from MSFS + Canteen merge)
3. Create a fresh assets.db with the full schema (tables, indexes, triggers)
4. Map merged fields to the assets table per the mapping specification
5. Insert default users (admin + tech)
6. Verify the data
Usage:
python3 import_msfs.py
"""
import hashlib
import json
import os
import shutil
import sqlite3
import sys
from datetime import datetime
# ── Paths ──────────────────────────────────────────────────────────────────
PROJECT_DIR = os.path.dirname(os.path.abspath(__file__))
DB_PATH = os.path.join(PROJECT_DIR, "assets.db")
MERGED_JSON = os.path.expanduser(
"~/projects/ms-field-service-extraction/web/static/data/merged-assets.json"
)
# ── Password hashing (mirrors server.py) ──────────────────────────────────
def hash_password(password: str) -> str:
return hashlib.sha256(password.encode()).hexdigest()
# ── 1. Backup existing DB ────────────────────────────────────────────────
def backup_db():
if not os.path.exists(DB_PATH):
print("[BACKUP] No existing assets.db found — skipping backup.")
return None
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_path = f"{DB_PATH}.{timestamp}.pre-msfs-import"
shutil.copy2(DB_PATH, backup_path)
size_mb = os.path.getsize(backup_path) / (1024 * 1024)
print(f"[BACKUP] Copied assets.db → {backup_path} ({size_mb:.1f} MB)")
return backup_path
# ── 2. Read merged data ──────────────────────────────────────────────────
def read_merged_data():
if not os.path.exists(MERGED_JSON):
print(f"[ERROR] Merged data not found at: {MERGED_JSON}")
sys.exit(1)
with open(MERGED_JSON, "r") as f:
data = json.load(f)
assets = data.get("assets", [])
meta = data.get("meta", {})
print(f"[READ] Loaded {len(assets)} records from merged-assets.json")
print(f" Joined: {meta.get('joined', '?')} "
f"MSFS-only: {meta.get('msfs_only', '?')} "
f"Canteen-only: {meta.get('canteen_only', '?')}")
return assets
# ── 3. Create fresh DB with full schema ──────────────────────────────────
SCHEMA_SQL = """
PRAGMA journal_mode=WAL;
PRAGMA foreign_keys=ON;
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'technician',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS customers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS customer_contacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
name TEXT,
phone TEXT,
email TEXT
);
CREATE TABLE IF NOT EXISTS locations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id INTEGER REFERENCES customers(id),
name TEXT,
address TEXT DEFAULT '',
building_name TEXT DEFAULT '',
building_number TEXT DEFAULT '',
floor TEXT DEFAULT '',
trailer_number TEXT DEFAULT '',
site_hours TEXT DEFAULT '',
access_notes TEXT DEFAULT '',
walking_directions TEXT DEFAULT '',
map_link TEXT DEFAULT '',
latitude REAL DEFAULT NULL,
longitude REAL DEFAULT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
disney_park TEXT DEFAULT NULL
);
CREATE TABLE IF NOT EXISTS rooms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
location_id INTEGER NOT NULL REFERENCES locations(id) ON DELETE CASCADE,
name TEXT,
floor TEXT DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
icon TEXT DEFAULT ''
);
CREATE TABLE IF NOT EXISTS key_names (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL
);
CREATE TABLE IF NOT EXISTS key_types (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL
);
CREATE TABLE IF NOT EXISTS badge_types (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL
);
CREATE TABLE IF NOT EXISTS makes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL
);
CREATE TABLE IF NOT EXISTS models (
id INTEGER PRIMARY KEY AUTOINCREMENT,
make_id INTEGER NOT NULL REFERENCES makes(id),
name TEXT NOT NULL,
icon_path TEXT
);
CREATE TABLE IF NOT EXISTS assets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
machine_id TEXT NOT NULL UNIQUE,
serial_number TEXT DEFAULT '',
name TEXT NOT NULL,
description TEXT DEFAULT '',
category TEXT NOT NULL DEFAULT 'Other',
status TEXT NOT NULL DEFAULT 'active',
make TEXT DEFAULT '',
model TEXT DEFAULT '',
address TEXT DEFAULT '',
building_name TEXT DEFAULT '',
building_number TEXT DEFAULT '',
floor TEXT DEFAULT '',
room TEXT DEFAULT '',
trailer_number TEXT DEFAULT '',
walking_directions TEXT DEFAULT '',
map_link TEXT DEFAULT '',
parking_location TEXT DEFAULT '',
photo_path TEXT,
customer_id INTEGER REFERENCES customers(id),
location_id INTEGER REFERENCES locations(id),
assigned_to INTEGER REFERENCES users(id),
latitude REAL DEFAULT NULL,
longitude REAL DEFAULT NULL,
geofence_radius_meters INTEGER DEFAULT 50,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
disney_park TEXT DEFAULT NULL,
is_disney INTEGER DEFAULT 0,
dex_report_date TEXT DEFAULT NULL,
install_date TEXT DEFAULT NULL,
deployed TEXT DEFAULT NULL,
pulled_date TEXT DEFAULT NULL,
company TEXT DEFAULT '',
location_area TEXT DEFAULT '',
place TEXT DEFAULT '',
connect_id TEXT DEFAULT '',
canteen_connect_guid TEXT DEFAULT '',
manufacturer TEXT DEFAULT '',
equipment_id TEXT DEFAULT ''
);
CREATE TABLE IF NOT EXISTS asset_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
asset_id INTEGER NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
key_name TEXT,
key_type TEXT
);
CREATE TABLE IF NOT EXISTS asset_badges (
id INTEGER PRIMARY KEY AUTOINCREMENT,
asset_id INTEGER NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
badge_name TEXT
);
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT UNIQUE NOT NULL,
value TEXT
);
CREATE TABLE IF NOT EXISTS activity_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users(id),
action TEXT,
entity_type TEXT,
entity_id INTEGER,
details TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS geofences (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
points TEXT,
color TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS geofence_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
geofence_id INTEGER NOT NULL REFERENCES geofences(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
UNIQUE(geofence_id, user_id)
);
CREATE TABLE IF NOT EXISTS visits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users(id),
asset_id INTEGER REFERENCES assets(id) ON DELETE CASCADE,
checkin_time TEXT,
checkout_time TEXT,
duration_minutes INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
token TEXT UNIQUE NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL DEFAULT (datetime('now', '+1 day'))
);
CREATE TABLE IF NOT EXISTS cantaloupe_sync_batches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
approved_at TEXT,
file_path TEXT,
row_count INTEGER DEFAULT 0,
diff_summary TEXT,
raw_data TEXT,
error_message TEXT
);
CREATE TABLE IF NOT EXISTS service_entrances (
id INTEGER PRIMARY KEY AUTOINCREMENT,
location_id INTEGER NOT NULL REFERENCES locations(id) ON DELETE CASCADE,
name TEXT NOT NULL,
latitude REAL NOT NULL,
longitude REAL NOT NULL,
notes TEXT DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS checkins (
id INTEGER PRIMARY KEY AUTOINCREMENT,
asset_id INTEGER REFERENCES assets(id) ON DELETE CASCADE,
user_id INTEGER REFERENCES users(id),
latitude REAL,
longitude REAL,
accuracy REAL,
photo_path TEXT,
notes TEXT DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_assets_category ON assets(category);
CREATE INDEX IF NOT EXISTS idx_csb_status ON cantaloupe_sync_batches(status);
CREATE INDEX IF NOT EXISTS idx_csb_created ON cantaloupe_sync_batches(created_at);
CREATE INDEX IF NOT EXISTS idx_checkins_asset_id ON checkins(asset_id);
CREATE INDEX IF NOT EXISTS idx_checkins_created_at ON checkins(created_at);
CREATE UNIQUE INDEX IF NOT EXISTS idx_assets_machine_id ON assets(machine_id);
"""
# ── 4. Map merged record to asset row ────────────────────────────────────
def get_machine_id(rec):
"""Get machine_id, falling back from msfs to canteen."""
mid = rec.get("msfs_machine_id")
if mid:
return str(mid).strip()
mid = rec.get("canteen_machine_id")
if mid:
return str(mid).strip()
return ""
def map_record_to_asset(rec):
"""
Map a merged record's fields to the assets table columns.
Returns a dict keyed by column name.
"""
# Primary identifiers
machine_id = get_machine_id(rec)
name = rec.get("msfs_name") or rec.get("canteen_name") or ""
# Category / status
category = rec.get("canteen_category") or "Other"
status = rec.get("canteen_status") or "active"
# Make / model / serial
make = rec.get("msfs_manufacturer") or ""
model = rec.get("msfs_dex_model") or ""
serial_number = rec.get("serial_number") or ""
# GPS — prefer msfs, fall back to canteen, then account
latitude = rec.get("msfs_latitude")
longitude = rec.get("msfs_longitude")
if latitude is None or longitude is None:
latitude = rec.get("canteen_latitude") or latitude
longitude = rec.get("canteen_longitude") or longitude
# Address — from account if available
address = ""
account = rec.get("account")
if account and account.get("address"):
addr_parts = [account["address"]]
if account.get("city"):
addr_parts.append(account["city"])
if account.get("state"):
addr_parts.append(account["state"])
if account.get("zip"):
addr_parts.append(account["zip"])
address = ", ".join(addr_parts)
if not address:
address = rec.get("canteen_address") or ""
# Canteen fields
customer_id = rec.get("canteen_customer_id")
disney_park = rec.get("canteen_disney_park")
is_disney = rec.get("canteen_is_disney") or 0
dex_report_date = rec.get("canteen_dex_report_date")
install_date = rec.get("canteen_install_date") or rec.get("msfs_install_date")
building_name = rec.get("canteen_building_name")
building_number = rec.get("canteen_building_number")
floor = rec.get("canteen_floor") or ""
room = rec.get("canteen_room") or ""
geofence_radius = rec.get("canteen_geofence_radius") or 50
company = rec.get("canteen_company") or ""
description = rec.get("canteen_description") or ""
deployed = rec.get("canteen_deployed")
pulled_date = rec.get("canteen_pulled_date")
location_area = rec.get("canteen_location_area") or ""
place = rec.get("canteen_place") or ""
location_id = rec.get("canteen_location_id")
# MSFS fields
connect_id = rec.get("msfs_connect_id") or ""
canteen_connect_guid = rec.get("msfs_canteen_connect_guid") or ""
manufacturer = rec.get("msfs_manufacturer") or ""
equipment_id = rec.get("msfs_equipment_id") or ""
return {
"machine_id": machine_id,
"serial_number": serial_number,
"name": name,
"description": description,
"category": category,
"status": status,
"make": make,
"model": model,
"address": address,
"building_name": building_name or "",
"building_number": building_number or "",
"floor": floor,
"room": room,
"customer_id": customer_id,
"location_id": location_id,
"latitude": latitude,
"longitude": longitude,
"geofence_radius_meters": geofence_radius,
"disney_park": disney_park,
"is_disney": is_disney,
"dex_report_date": dex_report_date,
"install_date": install_date,
"deployed": deployed,
"pulled_date": pulled_date,
"company": company,
"location_area": location_area,
"place": place,
"connect_id": connect_id,
"canteen_connect_guid": canteen_connect_guid,
"manufacturer": manufacturer,
"equipment_id": equipment_id,
}
# ── 5. Insert records into DB ────────────────────────────────────────────
def create_db(records):
"""Create a fresh assets.db with schema and import records."""
# Remove existing
if os.path.exists(DB_PATH):
os.remove(DB_PATH)
print("[DB] Removed existing assets.db")
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.executescript(SCHEMA_SQL)
print("[DB] Created fresh assets.db with full schema")
# Insert default categories used by the data
cursor = conn.cursor()
categories_seen = set()
for rec in records:
cat = rec.get("canteen_category") or "Other"
categories_seen.add(cat)
for cat in sorted(categories_seen):
cursor.execute("INSERT OR IGNORE INTO categories (name) VALUES (?)", (cat,))
print(f"[CATEGORIES] Inserted {len(categories_seen)} categories")
# Insert customers from the data
customers_seen = {}
for rec in records:
if rec.get("customer") and rec["customer"].get("name"):
cust = rec["customer"]
cid = cust.get("id")
cname = cust.get("name")
if cid and cname and cid not in customers_seen:
customers_seen[cid] = cname
for cid, cname in sorted(customers_seen.items()):
cursor.execute(
"INSERT OR IGNORE INTO customers (id, name) VALUES (?, ?)",
(cid, cname),
)
print(f"[CUSTOMERS] Inserted {len(customers_seen)} customers")
# Insert locations from the data
locations_seen = {}
for rec in records:
if rec.get("location") and rec["location"].get("name"):
loc = rec["location"]
lid = loc.get("id")
lname = loc.get("name")
if lid and lname and lid not in locations_seen:
locations_seen[lid] = {
"name": lname,
"address": loc.get("address") or "",
"customer_id": rec.get("canteen_customer_id"),
}
for lid, loc_data in sorted(locations_seen.items()):
cursor.execute(
"INSERT OR IGNORE INTO locations (id, name, address, customer_id) VALUES (?, ?, ?, ?)",
(lid, loc_data["name"], loc_data["address"], loc_data["customer_id"]),
)
print(f"[LOCATIONS] Inserted {len(locations_seen)} locations")
# Insert default users
default_users = [
("admin", hash_password("admin123"), "admin"),
("tech", hash_password("tech123"), "technician"),
]
for username, pw_hash, role in default_users:
cursor.execute(
"INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)",
(username, pw_hash, role),
)
print(f"[USERS] Inserted {len(default_users)} default users")
# Insert assets — deduplicate by machine_id, preferring "joined" over "msfs_only"
asset_cols = [
"machine_id", "serial_number", "name", "description", "category",
"status", "make", "model", "address", "building_name",
"building_number", "floor", "room", "customer_id", "location_id",
"latitude", "longitude", "geofence_radius_meters", "disney_park",
"is_disney", "dex_report_date", "install_date", "deployed",
"pulled_date", "company", "location_area", "place", "connect_id",
"canteen_connect_guid", "manufacturer", "equipment_id",
]
placeholders = ", ".join(["?"] * len(asset_cols))
col_names = ", ".join(asset_cols)
# Priority: joined > msfs_only > canteen_only
match_priority = {"joined": 0, "msfs_only": 1, "canteen_only": 2}
deduped = {} # machine_id -> (priority, row)
no_id = 0
for rec in records:
row = map_record_to_asset(rec)
mid = row["machine_id"]
if not mid:
no_id += 1
continue
priority = match_priority.get(rec.get("match", ""), 99)
if mid not in deduped or priority < deduped[mid][0]:
deduped[mid] = (priority, row)
inserted = 0
for mid, (priority, row) in deduped.items():
values = [row[c] for c in asset_cols]
cursor.execute(
f"INSERT INTO assets ({col_names}) VALUES ({placeholders})",
values,
)
inserted += 1
total_raw = len(records)
total_deduped = len(deduped)
skipped = total_raw - total_deduped - no_id
print(f"\n[ASSETS] Raw records: {total_raw}")
print(f"[ASSETS] No machine_id: {no_id}")
print(f"[ASSETS] Duplicates removed: {skipped}")
print(f"[ASSETS] Inserted: {inserted}")
print(f"[ASSETS] Total in DB: {inserted}")
conn.commit()
# Count
asset_count = cursor.execute("SELECT COUNT(*) FROM assets").fetchone()[0]
gps_count = cursor.execute(
"SELECT COUNT(*) FROM assets WHERE latitude IS NOT NULL AND longitude IS NOT NULL"
).fetchone()[0]
cat_dist = cursor.execute(
"SELECT category, COUNT(*) FROM assets GROUP BY category ORDER BY COUNT(*) DESC"
).fetchall()
status_dist = cursor.execute(
"SELECT status, COUNT(*) FROM assets GROUP BY status ORDER BY COUNT(*) DESC"
).fetchall()
conn.close()
print(f"[ASSETS] With GPS coords: {gps_count}")
print(f"\n[ASSETS] Category distribution:")
for cat, cnt in cat_dist:
print(f" {cat}: {cnt}")
print(f"\n[ASSETS] Status distribution:")
for st, cnt in status_dist:
print(f" {st}: {cnt}")
return asset_count, gps_count
# ── 6. Verification ──────────────────────────────────────────────────────
def verify_db():
"""Open the fresh DB and run basic checks."""
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
print("\n─── VERIFICATION ───")
# Table list
tables = cursor.execute(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
).fetchall()
print(f"Tables ({len(tables)}): {', '.join(t['name'] for t in tables)}")
# User count
user_count = cursor.execute("SELECT COUNT(*) FROM users").fetchone()[0]
print(f"Users: {user_count}")
users = cursor.execute("SELECT username, role FROM users").fetchall()
for u in users:
print(f" - {u['username']} ({u['role']})")
# Asset count
asset_count = cursor.execute("SELECT COUNT(*) FROM assets").fetchone()[0]
gps_count = cursor.execute(
"SELECT COUNT(*) FROM assets WHERE latitude IS NOT NULL AND longitude IS NOT NULL"
).fetchone()[0]
print(f"Assets: {asset_count}")
print(f"Assets with GPS: {gps_count}")
# Sample from each match type
if asset_count > 0:
print("\nSample records:")
sample = cursor.execute(
"SELECT machine_id, name, category, status, latitude, longitude FROM assets LIMIT 5"
).fetchall()
for s in sample:
gps_tag = f"({s['latitude']}, {s['longitude']})" if s['latitude'] else "NO GPS"
print(f" {s['machine_id']}: {s['name']} [{s['category']}] {gps_tag}")
# Check index count
idx_count = cursor.execute(
"SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name NOT LIKE 'sqlite_%'"
).fetchone()[0]
print(f"Indexes: {idx_count}")
# DB size
db_size = os.path.getsize(DB_PATH) / (1024 * 1024)
print(f"DB size: {db_size:.1f} MB")
conn.close()
# ── Main ──────────────────────────────────────────────────────────────────
def main():
print("═══ MSFS MERGED DATA IMPORT ═══\n")
# 1. Backup
backup_path = backup_db()
# 2. Read data
records = read_merged_data()
# 3. Create DB and import
asset_count, gps_count = create_db(records)
# 4. Verify
verify_db()
print(f"\n═══ DONE ═══")
if backup_path:
print(f"Backup: {backup_path}")
print(f"New DB: {DB_PATH} ({asset_count} assets, {gps_count} with GPS)")
if __name__ == "__main__":
main()
+292
View File
@@ -0,0 +1,292 @@
#!/usr/bin/env python3
"""
Import Seed (mycantaloupe.com) Excel data into the Canteen Asset Tracker.
Steps:
1. Read Machine List(8).xlsx — 62 columns of Seed export
2. Match rows by Asset ID → assets.machine_id
3. Add key columns to assets table
4. Create seed_data table with full JSON per asset
5. Update matched assets, report match stats
Usage:
python3 import_seed.py
"""
import json
import os
import sqlite3
import sys
from datetime import datetime, date
import openpyxl
# ── Paths ──────────────────────────────────────────────────────────────────
PROJECT_DIR = os.path.expanduser("~/projects/canteen-asset-tracker")
DB_PATH = os.path.join(PROJECT_DIR, "assets.db")
EXCEL_PATH = os.path.expanduser(
"/home/oplabs/.hermes/profiles/coder/cache/documents/doc_305e40fb92dd_Machine List(8).xlsx"
)
# ── Columns to extract as first-class DB columns ──────────────────────────
# (db_column, excel_header, type)
KEY_COLUMNS = [
("device", "Device", "TEXT DEFAULT ''"),
("seed_location", "Location", "TEXT DEFAULT ''"),
("seed_city", "City", "TEXT DEFAULT ''"),
("state", "State", "TEXT DEFAULT ''"),
("postal_code", "Postal Code", "TEXT DEFAULT ''"),
("route_name", "Route", "TEXT DEFAULT ''"),
("subroute_name", "Subroute", "TEXT DEFAULT ''"),
("seed_class", "Class", "TEXT DEFAULT ''"),
("customer_name", "Customer", "TEXT DEFAULT ''"),
("management_company", "Management Company", "TEXT DEFAULT ''"),
("branch", "Branch", "TEXT DEFAULT ''"),
("location_code", "Location Code", "TEXT DEFAULT ''"),
("customer_code", "Customer Code", "TEXT DEFAULT ''"),
("barcode", "Barcode", "TEXT DEFAULT ''"),
("has_cashless", "Has Cashless", "TEXT DEFAULT ''"),
("phone", "Phone", "TEXT DEFAULT ''"),
("fax", "Fax", "TEXT DEFAULT ''"),
("email", "Email", "TEXT DEFAULT ''"),
("asset_family", "Asset Family", "TEXT DEFAULT ''"),
("business_type", "Business Type", "TEXT DEFAULT ''"),
("primary_consumer_type","Primary Consumer Type","TEXT DEFAULT ''"),
("machine_branding", "Machine Branding", "TEXT DEFAULT ''"),
("valid_address", "Valid Address", "TEXT DEFAULT ''"),
("non_revenue", "Non-Revenue", "TEXT DEFAULT ''"),
("alerts", "Alerts", "TEXT DEFAULT ''"),
("coil_alerts", "Coil Alerts", "INTEGER DEFAULT 0"),
("product_alerts", "Product Alerts", "INTEGER DEFAULT 0"),
("daily_avg_sales", "Daily Average Sales", "REAL DEFAULT NULL"),
("monthly_sales", "Monthly Sales", "REAL DEFAULT NULL"),
("yearly_sales", "Yearly Sales", "REAL DEFAULT NULL"),
("today_sales", "Today Sales", "REAL DEFAULT NULL"),
("yesterday_sales", "Yesterday Sales", "REAL DEFAULT NULL"),
("weekly_sales", "Weekly Sales", "REAL DEFAULT NULL"),
("sales_restock", "Sales (Restock)", "REAL DEFAULT NULL"),
("last_restock", "Last Restock", "TEXT DEFAULT ''"),
("days_since_restock", "Days Since Restock", "INTEGER DEFAULT NULL"),
("last_contact_time", "Last Contact Time", "TEXT DEFAULT ''"),
("last_dex_report_time", "Last Dex Report Time","TEXT DEFAULT ''"),
("last_inventory", "Last Inventory", "TEXT DEFAULT ''"),
("prepick_group", "Prepick Group", "TEXT DEFAULT ''"),
("added_date", "Added Date", "TEXT DEFAULT ''"),
("purchase_date", "Purchase Date", "TEXT DEFAULT ''"),
("purchase_price", "Purchase Price", "REAL DEFAULT NULL"),
("depreciation_years", "Depreciation Years", "REAL DEFAULT NULL"),
("cash_discount", "Cash Discount", "REAL DEFAULT NULL"),
("tax_jurisdiction", "Tax Jurisdiction", "TEXT DEFAULT ''"),
("commission_plan", "Commission Plan", "TEXT DEFAULT ''"),
("acquirer_from", "Acquired From", "TEXT DEFAULT ''"),
("changer_par", "Changer Par", "TEXT DEFAULT ''"),
("machine_management_code","Machine Management Code","TEXT DEFAULT ''"),
]
# ── All 62 columns for the seed_data JSON dump ────────────────────────────
ALL_SEED_FIELDS = [
"Device", "Location", "Asset ID", "Place", "Type",
"City", "Address", "Last Contact Time", "Last Dex Report Time",
"Last Restock", "Coil Alerts", "Product Alerts", "Sales (Restock)",
"Daily Average Sales", "Today Sales", "Yesterday Sales", "Weekly Sales",
"Monthly Sales", "Yearly Sales", "Days Since Restock", "Prepick Group",
"Customer", "Management Company", "Management Account",
"Machine Management Code", "Route", "Subroute", "Changer Par",
"Acquired From", "Purchase Date", "Purchase Price", "Depreciation Years",
"Post-Depreciation Monthly Cost", "State", "Postal Code", "Deployed",
"Pulled Date", "Serial Number", "Class", "Make", "Model",
"Cash Discount", "Tax Jurisdiction", "Commission Plan", "Barcode",
"Non-Revenue", "Added Date", "Phone", "Fax", "Email", "Has Cashless",
"Branch", "Location Code", "Customer Code", "Last Inventory",
"Asset Family", "Status", "Alerts", "Valid Address",
"Business Type", "Primary Consumer Type", "Machine Branding",
]
# ── Schema migration helpers ──────────────────────────────────────────────
def get_db_columns(conn):
cursor = conn.execute("PRAGMA table_info(assets)")
return {row[1] for row in cursor.fetchall()}
def add_column_if_missing(conn, col_name, col_type):
existing = get_db_columns(conn)
if col_name not in existing:
sql = f"ALTER TABLE assets ADD COLUMN {col_name} {col_type}"
conn.execute(sql)
print(f" Added column: {col_name} {col_type}")
return True
return False
def create_seed_data_table(conn):
conn.execute("""
CREATE TABLE IF NOT EXISTS seed_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
asset_id INTEGER NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
raw_data TEXT NOT NULL,
imported_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(asset_id)
)
""")
print(" seed_data table ready")
# Add index
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_seed_data_asset_id
ON seed_data(asset_id)
""")
# ── Parse Excel ──────────────────────────────────────────────────────────
def read_excel():
"""Return (header_map, rows) where header_map is ExcelHeader->col_index."""
wb = openpyxl.load_workbook(EXCEL_PATH, data_only=True)
ws = wb.active
headers = {}
for col in range(1, ws.max_column + 1):
val = ws.cell(row=1, column=col).value
if val is not None:
headers[str(val).strip()] = col
rows = []
for row_idx in range(2, ws.max_row + 1):
row_data = {}
for hdr, col in headers.items():
val = ws.cell(row=row_idx, column=col).value
row_data[hdr] = val
row_data["_row"] = row_idx
rows.append(row_data)
return headers, rows
# ── Main import ──────────────────────────────────────────────────────────
def main():
print("═══ SEED DATA IMPORT ═══\n")
# 1. Connect to DB
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys=ON")
print(f"[DB] Connected: {DB_PATH}")
# 2. Schema migration
print("\n[MIGRATE] Adding new asset columns if missing...")
for col_name, excel_header, col_type in KEY_COLUMNS:
add_column_if_missing(conn, col_name, col_type)
print("\n[MIGRATE] Creating seed_data table...")
create_seed_data_table(conn)
conn.commit()
# 3. Read Excel
print(f"\n[READ] Reading {EXCEL_PATH}...")
headers, rows = read_excel()
print(f"[READ] Found {len(headers)} columns, {len(rows)} data rows")
# 4. Pre-build machine_id lookup
cursor = conn.execute("SELECT id, machine_id FROM assets")
machine_map = {} # machine_id -> asset_id
for row in cursor.fetchall():
machine_map[str(row["machine_id"]).strip()] = row["id"]
print(f"[MATCH] DB has {len(machine_map)} assets to match against")
# 5. Import
matched = 0
skipped_no_id = 0
unmatched = 0
key_field_updates = 0
json_inserts = 0
for row in rows:
asset_id_val = row.get("Asset ID")
if asset_id_val is None or str(asset_id_val).strip() == "":
skipped_no_id += 1
continue
asset_id_str = str(asset_id_val).strip()
db_asset_id = machine_map.get(asset_id_str)
if db_asset_id is None:
unmatched += 1
continue
# Update key columns on assets table
update_parts = []
update_vals = []
for col_name, excel_header, col_type in KEY_COLUMNS:
val = row.get(excel_header)
# Handle None → NULL for SQL
update_parts.append(f"{col_name} = ?")
if val is None:
update_vals.append(None)
else:
if "INTEGER" in col_type:
try:
update_vals.append(int(val))
except (ValueError, TypeError):
update_vals.append(0)
elif "REAL" in col_type:
try:
update_vals.append(float(val))
except (ValueError, TypeError):
update_vals.append(None)
else:
update_vals.append(str(val))
update_vals.append(db_asset_id)
conn.execute(
f"UPDATE assets SET {', '.join(update_parts)} WHERE id = ?",
update_vals,
)
key_field_updates += 1
# Insert/upsert seed_data JSON
raw_data = {}
for hdr in ALL_SEED_FIELDS:
val = row.get(hdr)
if val is not None:
raw_data[hdr] = val
# Convert non-serializable types (datetime, etc.) to strings
def _serialize(v):
if isinstance(v, (datetime, date)):
return v.isoformat()
return v
raw_json = json.dumps(raw_data, default=_serialize)
conn.execute(
"""INSERT OR REPLACE INTO seed_data (asset_id, raw_data, imported_at)
VALUES (?, ?, datetime('now'))""",
(db_asset_id, raw_json),
)
json_inserts += 1
matched += 1
conn.commit()
# 6. Stats
print(f"\n═══ RESULTS ═══")
print(f"Seed rows: {len(rows)}")
print(f"Matched (↔ assets): {matched}")
print(f"Unmatched (no asset): {unmatched}")
print(f"Rows without Asset ID: {skipped_no_id}")
print(f"Key field updates: {key_field_updates}")
print(f"JSON blobs inserted: {json_inserts}")
# Re-count assets with seed data
seed_count = conn.execute(
"SELECT COUNT(*) FROM seed_data"
).fetchone()[0]
print(f"\nseed_data table: {seed_count} rows")
# Show DB size
db_size = os.path.getsize(DB_PATH) / (1024 * 1024)
print(f"DB size: {db_size:.1f} MB")
conn.close()
print("\n═══ DONE ═══")
if __name__ == "__main__":
main()
Regular → Executable
+7 -13
View File
@@ -1,16 +1,10 @@
#!/bin/bash #!/bin/bash
# Restart canteen-asset-tracker server
cd /home/oplabs/projects/canteen-asset-tracker cd /home/oplabs/projects/canteen-asset-tracker
pkill -f "uvicorn.*server:app.*8901" 2>/dev/null || true
# Kill existing server
pkill -f "python.*server.py" 2>/dev/null || true
sleep 1 sleep 1
python -m uvicorn server:app --host 0.0.0.0 --port 8901 \
# Activate venv and start --ssl-keyfile key.pem --ssl-certfile cert.pem --log-level info \
source .venv/bin/activate > /tmp/canteen-server.log 2>&1 &
nohup python server.py > /tmp/canteen-server.log 2>&1 & echo "PID=$!"
echo "Server PID: $!" sleep 2
sleep 3 curl -sk https://localhost:8901/health 2>/dev/null
# Check health
curl -s http://localhost:8901/health 2>/dev/null || curl -s http://localhost:8900/health 2>/dev/null || echo "Health check failed"
+301
View File
@@ -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()
+342
View File
@@ -0,0 +1,342 @@
#!/usr/bin/env python3
"""
Backfill: OCR all unlinked photos and update matching assets.
Scans uploads/photos/ for image files whose photo_path is not set on any
asset in the database. For each unlinked photo, runs Tesseract OCR (and
Ollama vision if available) to extract identifiers, cross-references
against the assets DB, and updates matching assets with:
- photo_path pointing to the photo file
- serial_number if extracted from OCR and currently blank
Usage:
python3 scripts/backfill_vision_photos.py # dry-run (report only)
python3 scripts/backfill_vision_photos.py --apply # write changes
python3 scripts/backfill_vision_photos.py --apply --force # overwrite existing photo_path
"""
import argparse
import os
import re
import sqlite3
import sys
from pathlib import Path
# Add project root to path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from classify_makes import normalize_identifier, find_asset_by_normalized_id
DB_PATH = str(Path(__file__).resolve().parent.parent / "assets.db")
UPLOADS_DIR = Path(__file__).resolve().parent.parent / "uploads" / "photos"
PHOTO_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".dng", ".tiff", ".tif"}
# ── OCR helpers (imported from server but keeping script self-contained) ────
def _has_ollama() -> bool:
"""Check if Ollama vision model is available."""
try:
import json, urllib.request
req = urllib.request.Request(
"http://127.0.0.1:11434/api/generate",
data=json.dumps({"model": "qwen2.5vl:3b", "prompt": "ping", "stream": False}).encode(),
headers={"Content-Type": "application/json"},
)
urllib.request.urlopen(req, timeout=5)
return True
except Exception:
return False
def _has_tesseract() -> bool:
"""Check if Tesseract is available."""
try:
import pytesseract
pytesseract.get_tesseract_version()
return True
except Exception:
return False
def ocr_via_ollama(image_path: Path) -> str:
"""Run Ollama vision model on image, return extracted text."""
import json, urllib.request, base64
from PIL import Image as PILImage
img = PILImage.open(image_path)
img.thumbnail((640, 480))
data_b64 = base64.b64encode(img.tobytes()).decode()
with open(image_path, "rb") as f:
b64_data = base64.b64encode(f.read()).decode()
payload = json.dumps({
"model": "qwen2.5vl:3b",
"prompt": "Extract all text, numbers, serial numbers, and IDs visible "
"on this sticker or label. Return ONLY the raw text content, "
"one item per line. Do not describe the image.",
"images": [b64_data],
"stream": False,
}).encode()
req = urllib.request.Request(
"http://127.0.0.1:11434/api/generate",
data=payload,
headers={"Content-Type": "application/json"},
)
resp = urllib.request.urlopen(req, timeout=120)
result = json.loads(resp.read().decode())
return result.get("response", "").strip()
def ocr_via_tesseract(image_path: Path) -> str:
"""Run Tesseract OCR on image, return extracted text."""
import pytesseract
from PIL import Image as PILImage
img = PILImage.open(image_path)
img_gray = img.convert("L")
text = pytesseract.image_to_string(img_gray, config="--psm 6")
return text.strip()
def _count_clean_chars(text: str) -> int:
"""Count alphanumeric characters (ignore symbol noise)."""
return sum(1 for c in text if c.isalnum() or c in ' \n/-.')
def _extract_serial_from_text(text: str) -> str | None:
"""Try to extract a plausible serial number from OCR text."""
if not text:
return None
patterns = [
r'(?:S/N|SN|SERIAL\s*NO|SERIAL|SERIAL\s*#)\s*[:=#]?\s*([A-Za-z0-9.\-/]{6,})',
r'(?:EQUIPMENT\s*ID|EQ\s*ID|ASSET\s*ID)\s*[:=]?\s*([A-Za-z0-9.\-/]{6,})',
r'(?:MODEL\s*NO|MODEL\s*#|PART\s*NO|P/N)\s*[:=]?\s*([A-Za-z0-9.\-/]{6,})',
]
for pat in patterns:
m = re.search(pat, text, re.IGNORECASE)
if m:
val = m.group(1).strip().rstrip('.')
if re.match(r'^\d{4,}$', val.replace('-', '').replace('.', '')):
continue # Probably a Connect ID, not a serial
clean = sum(1 for c in val if c.isalnum())
if clean >= 6:
return val
return None
def _find_identifiers(text: str) -> list:
"""Extract all plausible identifiers from OCR text."""
identifiers = []
lines = text.split('\n')
for line in lines:
line = line.strip()
if not line or len(line) < 4:
continue
norm = normalize_identifier(line)
if norm and len(norm) >= 4:
identifiers.append({"raw": line, "normalized": norm, "type": "full_line"})
tokens = re.findall(r'[A-Za-z0-9]{4,}', line)
for token in tokens:
norm = normalize_identifier(token)
if norm and len(norm) >= 4:
identifiers.append({"raw": token, "normalized": norm, "type": "token"})
return identifiers
def _match_identifiers(identifiers: list, db_path: str) -> list:
"""Match identifiers against DB, return deduplicated asset matches."""
results = []
seen_ids = set()
for ident in identifiers:
assets = find_asset_by_normalized_id(db_path, ident["normalized"])
for a in assets:
if a["id"] not in seen_ids:
seen_ids.add(a["id"])
results.append({
"asset": a,
"matched_on": ident["normalized"],
"source_text": ident["raw"],
})
return results
def _get_unlinked_photos(db_path: str) -> list:
"""Get list of photos in uploads/photos/ not linked to any asset."""
# Get all photo_paths already in DB
conn = sqlite3.connect(db_path)
linked = {row[0] for row in conn.execute(
"SELECT photo_path FROM assets WHERE photo_path IS NOT NULL AND photo_path != ''"
).fetchall()}
conn.close()
unlinked = []
for f in sorted(UPLOADS_DIR.iterdir()):
if f.suffix.lower() not in PHOTO_EXTS:
continue
rel_path = f"/uploads/photos/{f.name}"
if rel_path not in linked:
unlinked.append({"path": f, "rel": rel_path})
return unlinked
def main():
parser = argparse.ArgumentParser(
description="Backfill: OCR all unlinked photos and update matching assets"
)
parser.add_argument("--apply", action="store_true",
help="Write changes to DB (default: dry-run)")
parser.add_argument("--force", action="store_true",
help="Overwrite existing photo_path on assets")
parser.add_argument("--db", default=DB_PATH,
help=f"Database path (default: {DB_PATH})")
parser.add_argument("--photos-dir", default=str(UPLOADS_DIR),
help=f"Photos directory (default: {UPLOADS_DIR})")
args = parser.parse_args()
db_path = args.db
photos_dir = Path(args.photos_dir)
print(f"🔍 Scanning {photos_dir} for unlinked photos...")
unlinked = _get_unlinked_photos(db_path)
print(f" Found {len(unlinked)} unlinked photo(s) out of "
f"{len(list(photos_dir.glob('*')))} total files in directory.\n")
if not unlinked:
print("✅ All photos are already linked to assets. Nothing to do.")
return
has_ollama = _has_ollama()
has_tess = _has_tesseract()
ollama_status = "✓ connected" if has_ollama else "✗ not available"
tess_status = "✓ installed" if has_tess else "✗ not available"
print(f" Ollama vision: {ollama_status}")
print(f" Tesseract: {tess_status}")
if not has_ollama and not has_tess:
print("⚠️ No OCR service available. Install Tesseract or start Ollama.")
return
total_photos = len(unlinked)
matched_count = 0
updated_photo_count = 0
updated_serial_count = 0
for i, photo in enumerate(unlinked, 1):
img_path = photo["path"]
rel_path = photo["rel"]
print(f"\n{'='*60}")
print(f"[{i}/{total_photos}] {img_path.name}")
print(f"{'='*60}")
# Step 1: OCR
text = ""
ocr_source = "none"
if has_ollama:
print(" [vision] Running Ollama...")
try:
text = ocr_via_ollama(img_path)
if text and _count_clean_chars(text) >= 10:
ocr_source = "ollama"
print(f" ✓ Ollama extracted {len(text)} chars")
except Exception as e:
print(f" ⚠ Ollama error: {e}")
if ocr_source == "none" and has_tess:
print(" [ocr] Running Tesseract...")
try:
text = ocr_via_tesseract(img_path)
if text and _count_clean_chars(text) >= 10:
ocr_source = "tesseract"
print(f" ✓ Tesseract extracted {len(text)} chars")
except Exception as e:
print(f" ⚠ Tesseract error: {e}")
if not text or _count_clean_chars(text) < 10:
print(" ⚠ Could not extract sufficient text from this image.")
continue
print(f" Text preview: {text[:150]}")
# Step 2: Extract identifiers
identifiers = _find_identifiers(text)
if not identifiers:
print(" ⚠ No identifiers found in OCR text.")
continue
# Step 3: Match against DB
matches = _match_identifiers(identifiers, db_path)
if not matches:
print(" ⚠ No DB matches found for extracted identifiers.")
continue
matched_count += 1
print(f" ✓ Matched {len(matches)} asset(s):")
for m in matches:
a = m["asset"]
print(f" ├─ Asset #{a['id']}: {a['name'][:50]}")
print(f" ├─ Machine ID: {a['machine_id']}")
print(f" ├─ Serial: {a['serial_number'][:40] or '(blank)'}")
print(f" └─ Matched on: {m['matched_on']}")
if not args.apply:
print(" ️ Dry-run — use --apply to write changes.")
continue
# Step 4: Apply changes
conn = sqlite3.connect(db_path)
try:
for m in matches:
a = m["asset"]
# Update photo_path if blank (or forced)
needs_photo = args.force or not a.get("photo_path")
if needs_photo:
conn.execute(
"UPDATE assets SET photo_path = ?, updated_at = datetime('now') WHERE id = ?",
(rel_path, a["id"]),
)
updated_photo_count += 1
print(f" ✓ Set photo_path → {rel_path}")
# Update serial_number if blank
if not a.get("serial_number"):
serial = _extract_serial_from_text(text)
if serial:
conn.execute(
"UPDATE assets SET serial_number = ?, updated_at = datetime('now') WHERE id = ?",
(serial, a["id"]),
)
updated_serial_count += 1
print(f" ✓ Set serial_number → {serial}")
conn.commit()
except Exception as e:
print(f" ✗ DB error: {e}")
conn.rollback()
finally:
conn.close()
# Summary
print(f"\n{'='*60}")
print(f"SUMMARY")
print(f"{'='*60}")
print(f" Total unlinked photos: {total_photos}")
print(f" Photos with DB matches: {matched_count}")
if args.apply:
print(f" Photo paths updated: {updated_photo_count}")
print(f" Serial numbers updated: {updated_serial_count}")
else:
print(f" (dry-run — no changes written)")
print(f" Re-run with --apply to write changes.")
print()
if __name__ == "__main__":
main()
+206
View File
@@ -0,0 +1,206 @@
#!/usr/bin/env python3
"""
Cross-reference extracted photo IDs against assets DB.
Processes all photos in uploads/photos/, runs vision/OCR text extraction,
matches identifiers against assets.machine_id / serial_number / connect_id,
updates matched assets with photo_path, and reports unmatched IDs.
Usage:
python3 scripts/crossref_photos.py # process all photos
python3 scripts/crossref_photos.py --photo <name> # single photo
python3 scripts/crossref_photos.py --text "S/N: 1234" # from text only
"""
import argparse
import json
import os
import re
import sqlite3
import sys
from datetime import datetime
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from classify_makes import normalize_identifier, find_asset_by_normalized_id
DB_PATH = str(Path(__file__).resolve().parent.parent / "assets.db")
PHOTOS_DIR = str(Path(__file__).resolve().parent.parent / "uploads" / "photos")
def process_photo(photo_path: str, db_path: str = DB_PATH) -> dict:
"""Process a single photo: extract text, match DB, update photo_path."""
fname = os.path.basename(photo_path)
result = {
"photo": fname,
"photo_path": f"/uploads/photos/{fname}",
"size_bytes": os.path.getsize(photo_path),
"vision_text": "",
"extracted_ids": [],
"matches": [],
"updated": False,
}
# Try Tesseract OCR
try:
import pytesseract
from PIL import Image as PILImage
img = PILImage.open(photo_path)
img_gray = img.convert("L")
text = pytesseract.image_to_string(img_gray, config="--psm 6")
text = text.strip()
clean_chars = sum(1 for c in text if c.isalnum() or c in " \n/-.")
if text and clean_chars >= 4:
result["vision_text"] = text
except ImportError:
pass
if not result["vision_text"]:
result["vision_source"] = "none"
return result
result["vision_source"] = "tesseract"
# Extract identifiers from text
identifiers = []
lines = result["vision_text"].split("\n")
for line in lines:
line = line.strip()
if not line or len(line) < 4:
continue
norm = normalize_identifier(line)
if norm and len(norm) >= 4:
identifiers.append(norm)
tokens = re.findall(r"[A-Za-z0-9]{4,}", line)
for token in tokens:
norm = normalize_identifier(token)
if norm and len(norm) >= 4 and norm not in identifiers:
identifiers.append(norm)
result["extracted_ids"] = identifiers
# Match against DB
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
seen_ids = set()
for norm_id in identifiers:
assets = find_asset_by_normalized_id(db_path, norm_id)
for a in assets:
if a["id"] not in seen_ids:
seen_ids.add(a["id"])
result["matches"].append(a)
# Update photo_path if empty
existing = conn.execute(
"SELECT photo_path FROM assets WHERE id = ?", (a["id"],)
).fetchone()
if existing and not existing["photo_path"]:
conn.execute(
"UPDATE assets SET photo_path = ?, updated_at = datetime('now') WHERE id = ?",
(result["photo_path"], a["id"]),
)
result["updated"] = True
conn.commit()
conn.close()
return result
def main():
parser = argparse.ArgumentParser(
description="Cross-reference photo IDs against assets DB"
)
parser.add_argument(
"--photo", "-p", help="Process a single photo by filename"
)
parser.add_argument(
"--text", "-t", help="Process raw text directly (skip OCR)"
)
parser.add_argument(
"--db",
default=DB_PATH,
help=f"Database path (default: {DB_PATH})",
)
args = parser.parse_args()
results = []
if args.text:
# Process from text directly
print(f"\n{'=' * 70}")
print(f"PROCESSING: supplied text ({len(args.text)} chars)")
print(f"{'=' * 70}")
print(f" Text: {args.text[:500]}")
identifiers = []
norm = normalize_identifier(args.text)
if norm and len(norm) >= 4:
identifiers.append(norm)
tokens = re.findall(r"[A-Za-z0-9]{4,}", args.text)
for token in tokens:
n = normalize_identifier(token)
if n and len(n) >= 4 and n not in identifiers:
identifiers.append(n)
print(f"\n Normalized IDs: {identifiers}")
conn = sqlite3.connect(args.db)
conn.row_factory = sqlite3.Row
for norm_id in identifiers:
assets = find_asset_by_normalized_id(args.db, norm_id)
if assets:
for a in assets:
print(f"\n ✓ MATCH → Asset #{a['id']}")
print(f" Machine ID: {a['machine_id']}")
print(f" Name: {a['name'][:60]}")
else:
print(f"\n ✗ No match for '{norm_id}'")
conn.close()
return
if args.photo:
photo_path = os.path.join(PHOTOS_DIR, args.photo)
if not os.path.exists(photo_path):
print(f"ERROR: {photo_path} not found")
sys.exit(1)
results.append(process_photo(photo_path, args.db))
else:
# Process all photos
for fname in sorted(os.listdir(PHOTOS_DIR)):
photo_path = os.path.join(PHOTOS_DIR, fname)
if os.path.isfile(photo_path):
results.append(process_photo(photo_path, args.db))
# Print summary
matched = [r for r in results if r["matches"]]
unmatched = [r for r in results if not r["matches"] and r["vision_text"]]
no_text = [r for r in results if not r["vision_text"]]
print(f"\n{'=' * 70}")
print(f"CROSS-REFERENCE SUMMARY ({datetime.now().isoformat()})")
print(f"{'=' * 70}")
print(f" Photos scanned: {len(results)}")
print(f" Photos with text: {len(results) - len(no_text)}")
print(f" Matches found: {len(matched)}")
print(f" Unmatched: {len(unmatched)}")
print(f" No text (small/thumbs): {len(no_text)}")
for r in matched:
for a in r["matches"]:
status = "UPDATED" if r["updated"] else "already set"
print(
f"\n{r['photo']} → Asset #{a['id']} "
f"(machine_id={a['machine_id']}) [{status}]"
)
for r in unmatched:
print(f"\n{r['photo']} — no DB match")
print(f" IDs: {r['extracted_ids'][:5]}")
print(f" Text preview: {r['vision_text'][:100]}")
print()
if __name__ == "__main__":
main()
+270
View File
@@ -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()
+411
View File
@@ -0,0 +1,411 @@
#!/usr/bin/env python3
"""
Import missing machines from Cantaloupe Excel export into canteen-asset-tracker.
Reads Machine List(8).xlsx, checks each Asset ID against assets.db,
and inserts any missing records. Stores full row JSON in seed_data table.
Usage:
python3 import_machines.py # Dry run (no writes)
python3 import_machines.py --write # Actually import
python3 import_machines.py --write --prod # Import to production DB
"""
import argparse
import json
import os
import re
import sqlite3
import sys
from datetime import datetime
import openpyxl
# ── Paths ──────────────────────────────────────────────────────────────────
EXCEL_PATH = os.path.expanduser(
"~/.hermes/profiles/coder/cache/documents/doc_4589f73a5e74_Machine List(8).xlsx"
)
DEV_DB = os.path.expanduser("~/projects/canteen-asset-tracker-dev/assets.dev.db")
PROD_DB = os.path.expanduser("~/projects/canteen-asset-tracker/assets.db")
# ── Column index mapping (0-based) ─────────────────────────────────────────
COL = {
"device": 0,
"location": 1,
"asset_id": 2,
"place": 3,
"type": 4,
"city": 5,
"address": 6,
"last_contact_time": 7,
"last_dex_report_time": 8,
"last_restock": 9,
"coil_alerts": 10,
"product_alerts": 11,
"sales_restock": 12,
"daily_avg_sales": 13,
"today_sales": 14,
"yesterday_sales": 15,
"weekly_sales": 16,
"monthly_sales": 17,
"yearly_sales": 18,
"days_since_restock": 19,
"prepick_group": 20,
"customer": 21,
"management_company": 22,
"management_account": 23,
"machine_management_code": 24,
"route": 25,
"subroute": 26,
"changer_par": 27,
"acquired_from": 28,
"purchase_date": 29,
"purchase_price": 30,
"depreciation_years": 31,
"post_depr_monthly_cost": 32,
"state": 33,
"postal_code": 34,
"deployed": 35,
"pulled_date": 36,
"serial_number": 37,
"class": 38,
"make": 39,
"model": 40,
"cash_discount": 41,
"tax_jurisdiction": 42,
"commission_plan": 43,
"barcode": 44,
"non_revenue": 45,
"added_date": 46,
"phone": 47,
"fax": 48,
"email": 49,
"has_cashless": 50,
"branch": 51,
"location_code": 52,
"customer_code": 53,
"last_inventory": 54,
"asset_family": 55,
"status": 56,
"alerts": 57,
"valid_address": 58,
"business_type": 59,
"primary_consumer_type": 60,
"machine_branding": 61,
}
# ── Helpers ────────────────────────────────────────────────────────────────
def cell(row, key, default=""):
"""Get cell value by column key."""
idx = COL[key]
val = row[idx]
if val is None:
return default
if isinstance(val, datetime):
return val.isoformat()
return str(val).strip()
def cell_num(row, key, default=None):
"""Get numeric cell value."""
idx = COL[key]
val = row[idx]
if val is None:
return default
if isinstance(val, (int, float)):
return val
try:
return float(str(val).strip())
except (ValueError, TypeError):
return default
def cell_int(row, key, default=None):
"""Get integer cell value."""
n = cell_num(row, key, default)
if n is not None:
return int(n)
return None
def parse_type(raw_type):
"""Parse 'Snack (AMS Sensit 3)' -> (category='Snack', make='AMS', model='Sensit 3')"""
if not raw_type:
return "Other", "", ""
raw = raw_type.strip()
# Extract parenthetical part
paren_match = re.search(r"\((.+?)\)", raw)
paren = paren_match.group(1) if paren_match else ""
# Get the category (first word or before parentheses)
category = re.sub(r"\s*\(.*\).*", "", raw).strip()
if not category or category == "Unknown":
category = "Other"
# Parse make/model from parentheses
if paren:
parts = paren.split(None, 1)
make = parts[0] if len(parts) > 0 else ""
model = parts[1] if len(parts) > 1 else ""
else:
make = ""
model = ""
return category, make, model
def build_name(row):
"""Build asset name in format: 'Make @ Location' or 'AssetID / Address / Place'"""
loc = cell(row, "location")
place = cell(row, "place")
aid = cell(row, "asset_id")
_, make, model = parse_type(cell(row, "type"))
addr = cell(row, "address")
if make:
name = f"{make} @ {place or loc or addr or aid}"
else:
name = f"{aid} / {addr or loc} / {place}" if (addr or place) else aid
return name[:250] # DB column limit
def deployed_flag(val):
"""Convert deployed value to '1' or '0'."""
if isinstance(val, str) and val.lower() in ("yes", "y", "1", "true"):
return "1"
if isinstance(val, (int, float)):
return "1" if val else "0"
return "0"
# ── Import logic ──────────────────────────────────────────────────────────
def import_machines(target_db, dry_run=True):
"""Import missing machines from Excel into the target DB."""
if not os.path.exists(EXCEL_PATH):
print(f"ERROR: Excel file not found: {EXCEL_PATH}")
return
if not os.path.exists(target_db):
print(f"ERROR: Target DB not found: {target_db}")
return
# Load spreadsheet
wb = openpyxl.load_workbook(EXCEL_PATH)
ws = wb["Machine List"]
# Read all rows
all_rows = []
for r in ws.iter_rows(min_row=2, values_only=True):
aid = r[COL["asset_id"]]
if aid:
all_rows.append(r)
print(f"Total machines in spreadsheet: {len(all_rows)}")
# Connect to target DB
conn = sqlite3.connect(target_db)
conn.row_factory = sqlite3.Row
# Get existing machine_ids
existing = set()
for r in conn.execute("SELECT machine_id FROM assets WHERE machine_id IS NOT NULL"):
existing.add(str(r["machine_id"]).strip())
print(f"Existing machines in DB: {len(existing)}")
# Find missing
missing_rows = []
for row in all_rows:
aid = str(row[COL["asset_id"]]).strip()
if aid not in existing:
missing_rows.append((aid, row))
print(f"Missing (to import): {len(missing_rows)}")
missing_rows.sort(key=lambda x: int(x[0]))
if not missing_rows:
print("Nothing to import.")
conn.close()
return
# Show first 10
print(f"\nFirst 10 to import:")
for aid, row in missing_rows[:10]:
loc = cell(row, "location")
place = cell(row, "place")
typ = cell(row, "type")
print(f" {aid}: {loc[:60]} | {place} | {typ}")
if dry_run:
print(f"\n{'='*60}")
print(f"DRY RUN - no changes made. Re-run with --write to import.")
print(f"{'='*60}")
conn.close()
return
# ── Do the import ─────────────────────────────────────────────────
conn.execute("PRAGMA foreign_keys = OFF")
conn.execute("BEGIN TRANSACTION")
imported_count = 0
errors = []
seed_entries = []
for aid, row in missing_rows:
try:
cat, make, model = parse_type(cell(row, "type"))
# Use spreadsheet make/model if type parse didn't yield them
sp_make = cell(row, "make")
if sp_make:
make = sp_make
sp_model = cell(row, "model")
if sp_model:
model = sp_model
asset_data = {
"machine_id": aid,
"serial_number": cell(row, "serial_number"),
"name": build_name(row),
"category": cat,
"status": cell(row, "status") or "active",
"make": make,
"model": model,
"address": cell(row, "address"),
"customer_name": cell(row, "customer"),
"company": cell(row, "customer"),
"place": cell(row, "place"),
"seed_city": cell(row, "city"),
"state": cell(row, "state"),
"postal_code": cell(row, "postal_code"),
"route_name": cell(row, "route"),
"subroute_name": cell(row, "subroute"),
"branch": cell(row, "branch"),
"barcode": cell(row, "barcode"),
"seed_class": cell(row, "class"),
"device": cell(row, "device"),
"management_company": cell(row, "management_company"),
"machine_management_code": cell(row, "machine_management_code"),
"location_code": cell(row, "location_code"),
"customer_code": cell(row, "customer_code"),
"asset_family": cell(row, "asset_family"),
"business_type": cell(row, "business_type"),
"primary_consumer_type": cell(row, "primary_consumer_type"),
"machine_branding": cell(row, "machine_branding"),
"valid_address": cell(row, "valid_address"),
"phone": cell(row, "phone"),
"fax": cell(row, "fax"),
"email": cell(row, "email"),
"has_cashless": "1" if cell(row, "has_cashless", "").lower() in ("yes", "y", "1") else "0",
"non_revenue": cell(row, "non_revenue"),
"alerts": cell(row, "alerts"),
"coil_alerts": cell_int(row, "coil_alerts", 0),
"product_alerts": cell_int(row, "product_alerts", 0),
"daily_avg_sales": cell_num(row, "daily_avg_sales"),
"monthly_sales": cell_num(row, "monthly_sales"),
"yearly_sales": cell_num(row, "yearly_sales"),
"today_sales": cell_num(row, "today_sales"),
"yesterday_sales": cell_num(row, "yesterday_sales"),
"weekly_sales": cell_num(row, "weekly_sales"),
"sales_restock": cell_num(row, "sales_restock"),
"last_restock": cell(row, "last_restock"),
"days_since_restock": cell_int(row, "days_since_restock"),
"last_contact_time": cell(row, "last_contact_time"),
"last_dex_report_time": cell(row, "last_dex_report_time"),
"last_inventory": cell(row, "last_inventory"),
"prepick_group": cell(row, "prepick_group"),
"added_date": cell(row, "added_date"),
"install_date": cell(row, "added_date"), # seed has no install_date separate
"purchase_date": cell(row, "purchase_date"),
"purchase_price": cell_num(row, "purchase_price"),
"depreciation_years": cell_int(row, "depreciation_years"),
"cash_discount": cell_num(row, "cash_discount", 0),
"tax_jurisdiction": cell(row, "tax_jurisdiction"),
"commission_plan": cell(row, "commission_plan"),
"acquirer_from": cell(row, "acquired_from"),
"changer_par": cell(row, "changer_par"),
"deployed": deployed_flag(row[COL["deployed"]]) if row[COL["deployed"]] else "1",
"pulled_date": cell(row, "pulled_date"),
"geofence_radius_meters": 50,
}
# Build column names and placeholders
cols = list(asset_data.keys())
placeholders = ", ".join("?" for _ in cols)
col_names = ", ".join(cols)
vals = [asset_data[c] for c in cols]
conn.execute(
f"INSERT INTO assets ({col_names}) VALUES ({placeholders})",
vals,
)
# Store raw seed data
# Get the asset id we just inserted
new_id = conn.execute(
"SELECT id FROM assets WHERE machine_id = ?", (aid,)
).fetchone()["id"]
# Build raw_data dict from all spreadsheet cells
raw_data = {}
headers = [c.value for c in ws[1]]
for i, h in enumerate(headers):
v = row[i]
if isinstance(v, datetime):
v = v.isoformat()
raw_data[h] = v
conn.execute(
"INSERT INTO seed_data (asset_id, raw_data) VALUES (?, ?)",
(new_id, json.dumps(raw_data, default=str)),
)
imported_count += 1
except Exception as e:
errors.append(f" {aid}: {e}")
if errors:
conn.rollback()
print(f"\nERRORS - rolled back entire import:")
for e in errors:
print(e)
else:
conn.commit()
conn.execute("PRAGMA foreign_keys = ON")
conn.close()
print(f"\n{'='*60}")
print(f"Import complete!")
print(f" Imported: {imported_count} machines")
print(f" Errors: {len(errors)}")
if imported_count:
print(f" First: {missing_rows[0][0]}")
print(f" Last: {missing_rows[-1][0]}")
print(f"{'='*60}")
# ── Main ──────────────────────────────────────────────────────────────────
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Import machines from Excel into canteen asset tracker")
parser.add_argument("--write", action="store_true", help="Actually write to DB (default: dry run)")
parser.add_argument("--prod", action="store_true", help="Import to production DB (default: dev)")
args = parser.parse_args()
target = PROD_DB if args.prod else DEV_DB
mode = "LIVE IMPORT" if args.write else "DRY RUN"
label = "PRODUCTION" if args.prod else "DEV"
print(f"{'='*60}")
print(f" {mode} -> {label} DB")
print(f" Target: {target}")
print(f"{'='*60}\n")
import_machines(target, dry_run=not args.write)
+219
View File
@@ -0,0 +1,219 @@
#!/usr/bin/env python3
"""
Match a sticker/label photo against the assets database.
Usage:
python3 scripts/match_label_photo.py <image_path>
Runs OCR (Tesseract) on the image, extracts identifiers,
and searches the assets DB for matches by serial_number, connect_id,
equipment_id, and barcode.
"""
import argparse
import re
import sqlite3
import sys
from pathlib import Path
# Add project root to path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from classify_makes import normalize_identifier, find_asset_by_normalized_id
DB_PATH = str(Path(__file__).resolve().parent.parent / "assets.db")
def ocr_image(image_path: str) -> str:
"""Run Tesseract OCR on an image and return extracted text."""
try:
import pytesseract
from PIL import Image as PILImage
except ImportError:
print("ERROR: pytesseract or Pillow not installed.")
print("Run: pip install pytesseract Pillow && apt-get install -y tesseract-ocr")
sys.exit(1)
img = PILImage.open(image_path)
img_gray = img.convert("L")
text = pytesseract.image_to_string(img_gray, config="--psm 6")
return text.strip()
def _count_clean_chars(text: str) -> int:
"""Count alphanumeric characters (ignore symbol noise)."""
return sum(1 for c in text if c.isalnum() or c in ' \n/-.')
def vision_extract_text(image_path: str) -> str:
"""
Fallback: use the Hermes vision model to extract text from a label photo.
Returns the raw text the vision model sees on the label.
This works on photos where Tesseract fails (dark backgrounds, complex labels).
"""
import json, urllib.request, os
# Try to read vision config from Hermes profile
config_path = os.path.expanduser("~/.hermes/profiles/coder/config.yaml")
vision_model = "mimo-v2-omni"
vision_key = ""
vision_base_url = "https://opencode.ai/zen/go/v1"
if os.path.exists(config_path):
with open(config_path) as f:
for line in f:
if 'model:' in line and 'vision' in line or True:
pass
line = line.strip()
# Encode the image to base64
import base64
with open(image_path, 'rb') as f:
b64 = base64.b64encode(f.read()).decode()
# Determine media type
ext = Path(image_path).suffix.lower()
media_type = {
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
'.png': 'image/png', '.webp': 'image/webp',
}.get(ext, 'image/jpeg')
data = json.dumps({
"model": vision_model,
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": "Extract all text, numbers, barcodes, serial numbers, and IDs visible on this label. Return ONLY the raw text content, one item per line. Do not describe the image."},
{"type": "image_url", "image_url": {"url": f"data:{media_type};base64,{b64}"}}
]
}
],
"max_tokens": 500
}).encode()
req = urllib.request.Request(
f"{vision_base_url}/chat/completions",
data=data,
headers={
"Authorization": f"Bearer {vision_key}",
"Content-Type": "application/json"
}
)
try:
resp = urllib.request.urlopen(req, timeout=30)
result = json.loads(resp.read().decode())
return result["choices"][0]["message"]["content"].strip()
except Exception as e:
return f"[Vision error: {e}]"
def find_identifiers(text: str) -> list:
"""Extract all plausible identifiers from OCR text."""
identifiers = []
lines = text.split('\n')
for line in lines:
line = line.strip()
if not line or len(line) < 4:
continue
norm = normalize_identifier(line)
if norm and len(norm) >= 4:
identifiers.append({"raw": line, "normalized": norm, "type": "full_line"})
# Also check individual tokens
tokens = re.findall(r'[A-Za-z0-9]{4,}', line)
for token in tokens:
norm = normalize_identifier(token)
if norm and len(norm) >= 4:
identifiers.append({"raw": token, "normalized": norm, "type": "token"})
return identifiers
def match_identifiers(identifiers: list, db_path: str) -> list:
"""Match identifiers against DB, return (identifier, matched_assets) pairs."""
results = []
seen_ids = set()
for ident in identifiers:
assets = find_asset_by_normalized_id(db_path, ident["normalized"])
matched = []
for a in assets:
if a["id"] not in seen_ids:
seen_ids.add(a["id"])
matched.append(a)
if matched:
results.append({"identifier": ident, "matches": matched})
return results
def main():
parser = argparse.ArgumentParser(description="Match label photo text against assets DB")
parser.add_argument("images", nargs="*", metavar="IMAGE", help="Image file(s) to OCR")
parser.add_argument("--text", "-t", help="Raw text to match (skip OCR)")
parser.add_argument("--db", default=DB_PATH, help=f"Database path (default: {DB_PATH})")
args = parser.parse_args()
db_path = args.db
if args.text:
text = args.text
print(f"\n{'='*60}")
print(f"Processing: supplied text ({len(text)} chars)")
print(f"{'='*60}")
print(f" Text: {text[:500]}")
_process_text(text, db_path)
return
for img_path in args.images:
if not Path(img_path).exists():
print(f"\n=== SKIP: {img_path} (not found) ===")
continue
print(f"\n{'='*60}")
print(f"Processing: {img_path}")
print(f"{'='*60}")
# Step 1: OCR
print("\n[OCR] Running Tesseract...")
text = ocr_image(img_path)
print(f" Raw text:\n{text[:500]}")
print(f" (length: {len(text)} chars, clean: {_count_clean_chars(text)})")
if not text or _count_clean_chars(text) < 10:
print("\n[OCR] Tesseract produced poor/no results. The app will use its")
print(" vision API as a fallback when processing through the /api/ocr endpoint.")
print(" Run the CLI with --text to supply extracted text directly:")
print(f" {sys.argv[0]} --text \"S/N: 2500.0100.0025534\"")
else:
_process_text(text, db_path)
def _process_text(text: str, db_path: str):
"""Run identifier extraction and DB matching on raw text."""
# Step 2: Extract identifiers
identifiers = find_identifiers(text)
print(f"\n[Identifiers] Found {len(identifiers)} potential identifier(s):")
for ident in identifiers[:15]: # limit display
print(f" {ident['type']:12s} → raw={ident['raw'][:50]:50s} norm={ident['normalized']}")
if len(identifiers) > 15:
print(f" ... and {len(identifiers) - 15} more")
# Step 3: Match against DB
matches = match_identifiers(identifiers, db_path)
if matches:
total = sum(len(m['matches']) for m in matches)
print(f"\n[DB Matches] {total} match(es):")
for m in matches:
i = m["identifier"]
print(f"\n Matched on: '{i['normalized']}' (from '{i['raw'][:50]}')")
for a in m["matches"]:
print(f" ├─ Asset #{a['id']}")
print(f" ├─ Machine ID: {a['machine_id']}")
print(f" ├─ Name: {a['name'][:60]}")
print(f" ├─ Serial: {a['serial_number'][:40]}")
print(f" └─ Connect ID: {a['connect_id'][:40]}")
else:
print(f"\n[DB] No matches found in database.")
if __name__ == "__main__":
main()
+314
View File
@@ -0,0 +1,314 @@
#!/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 — keeps GUEST/CAST suffix visible.
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) only
if name.startswith("D-") or name.startswith("D "):
name = name[2:]
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()
+199
View File
@@ -0,0 +1,199 @@
#!/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")
# Also build the branch cache for fallback on unmatched assets
try:
sys.path.insert(0, str(Path.home() / "projects" / "canteen-asset-tracker"))
from server import _build_asset_branch_cache
branch_cache = _build_asset_branch_cache()
print(f"📡 Branch cache has {len(branch_cache)} prefix→territory mappings")
except Exception as e:
print(f"⚠️ Could not load branch cache: {e}")
branch_cache = {}
return result, branch_cache
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, branch_cache = 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
branch_fallback = 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:
# No connect_id match in extraction DB at all
skipped_no_match += 1
continue
# If account name is empty, try branch cache fallback
if (not a["company"] or not a["company"].strip()) and (not info["company"] or not info["company"].strip()):
pfx = a["connect_id"][:8]
branch_name = branch_cache.get(pfx)
if branch_name:
info["company"] = branch_name
info["customer_name"] = branch_name
branch_fallback += 1
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()
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""Remove ALL GPS data that was geocoded from addresses.
Keeps ONLY: the 5 original app check-in entries from gps-backup.json that
still exist in the current DB. Everything else was derived from an
address geocoding source (MSFS accounts, Cantaloupe seed data)."""
import json
import sqlite3
DB_PATH = "/home/oplabs/projects/canteen-asset-tracker/assets.db"
GPS_BAK_PATH = "/home/oplabs/projects/canteen-asset-tracker/gps-backup.json"
def main():
# Load original app check-ins
with open(GPS_BAK_PATH) as f:
gps_bak = json.load(f)
checkin_coords = {}
for e in gps_bak:
checkin_coords[e['machine_id']] = (e['lat'], e['lng'])
print(f"Original app check-in entries: {len(checkin_coords)}")
cur = sqlite3.connect(DB_PATH)
total = cur.execute("SELECT COUNT(*) FROM assets").fetchone()[0]
before = cur.execute(
"SELECT COUNT(*) FROM assets WHERE latitude IS NOT NULL AND latitude != 0"
).fetchone()[0]
print(f"Before: {before} / {total} with GPS")
removed = 0
kept_checkin = 0
kept_other = 0
for r in cur.execute(
"SELECT rowid, machine_id, latitude, longitude FROM assets "
"WHERE latitude IS NOT NULL AND longitude IS NOT NULL AND latitude != 0 AND longitude != 0"
).fetchall():
rowid, mid, lat, lon = r
# KEEP: only app check-in entries where coordinates match exactly
if mid in checkin_coords:
expected = checkin_coords[mid]
if abs(float(lat) - expected[0]) < 0.0001 and abs(float(lon) - expected[1]) < 0.0001:
kept_checkin += 1
continue
# Everything else is address-geocoded — remove
cur.execute("UPDATE assets SET latitude = NULL, longitude = NULL WHERE rowid = ?", (rowid,))
removed += 1
cur.commit()
after = cur.execute(
"SELECT COUNT(*) FROM assets WHERE latitude IS NOT NULL AND latitude != 0"
).fetchone()[0]
print(f"\nResults:")
print(f" Kept (app check-ins): {kept_checkin}")
print(f" Removed (address-geocoded): {removed}")
print(f" After: {after} / {total} with GPS")
print(f" Without GPS: {total - after}")
# Show what's left
print(f"\n=== Remaining GPS entries ===")
for r in cur.execute(
"SELECT machine_id, make, model, category, latitude, longitude FROM assets "
"WHERE latitude IS NOT NULL AND latitude != 0"
).fetchall():
print(f" {r[0]}: ({r[4]}, {r[5]}) — {r[1]} {r[2]} ({r[3]})")
cur.close()
if __name__ == "__main__":
main()
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Restore GPS data from pre-MSFS backup by matching serial numbers."""
import sqlite3
import sys
DB_PATH = "/home/oplabs/projects/canteen-asset-tracker/assets.db"
OLD_DB_PATH = "/home/oplabs/projects/canteen-asset-tracker/assets.db.20260528_214316.pre-msfs-import"
def main():
# Open old DB (read-only)
old = sqlite3.connect(f"file:{OLD_DB_PATH}?mode=ro", uri=True)
old.row_factory = sqlite3.Row
# Build mapping: serial_number -> (lat, lon) from old DB
old_gps = {}
for r in old.execute(
"SELECT serial_number, latitude, longitude FROM assets "
"WHERE serial_number IS NOT NULL AND serial_number != '' "
"AND latitude IS NOT NULL AND latitude != 0 AND longitude IS NOT NULL"
).fetchall():
old_gps[r["serial_number"]] = (r["latitude"], r["longitude"])
old.close()
print(f"Old DB: {len(old_gps)} GPS entries indexed by serial_number")
# Open current DB and update
cur = sqlite3.connect(DB_PATH)
updated = 0
skipped_existing = 0
skipped_no_sn = 0
skipped_no_match = 0
for r in cur.execute(
"SELECT rowid, machine_id, serial_number, latitude, longitude FROM assets"
).fetchall():
rowid, machine_id, serial_number, lat, lon = r
# Skip if already has GPS
if lat and lon and lat != 0 and lon != 0:
skipped_existing += 1
continue
if not serial_number:
skipped_no_sn += 1
continue
if serial_number in old_gps:
new_lat, new_lon = old_gps[serial_number]
cur.execute(
"UPDATE assets SET latitude = ?, longitude = ? WHERE rowid = ?",
(new_lat, new_lon, rowid)
)
updated += 1
else:
skipped_no_match += 1
cur.commit()
# Verify
gps_count = cur.execute(
"SELECT COUNT(*) FROM assets "
"WHERE latitude IS NOT NULL AND longitude IS NOT NULL AND latitude != 0 AND longitude != 0"
).fetchone()[0]
total = cur.execute("SELECT COUNT(*) FROM assets").fetchone()[0]
print(f"\nResults:")
print(f" GPS restored: {updated}")
print(f" Already had GPS: {skipped_existing}")
print(f" No serial number: {skipped_no_sn}")
print(f" No match in old DB: {skipped_no_match}")
print(f" Total with GPS now: {gps_count} / {total}")
cur.close()
if __name__ == "__main__":
main()
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""Restore GPS from merged-assets.json account-level data.
Priority: canteen_connect_guid match > connect_id match > already restored via serial."""
import json
import sqlite3
import sys
DB_PATH = "/home/oplabs/projects/canteen-asset-tracker/assets.db"
MERGED_PATH = "/home/oplabs/projects/ms-field-service-extraction/web/static/data/merged-assets.json"
def build_lookups(merged_path):
"""Build GPS lookups from merged-assets.json account data."""
with open(merged_path) as f:
data = json.load(f)
by_connect_guid = {} # msfs_canteen_connect_guid → (lat, lon)
by_connect_id = {} # msfs_connect_id → (lat, lon)
for a in data['assets']:
acct = a.get('account') or {}
lat = acct.get('latitude')
lon = acct.get('longitude')
if not (lat and lon and float(lat) != 0 and float(lon) != 0):
continue
lat, lon = float(lat), float(lon)
guid = a.get('msfs_canteen_connect_guid')
if guid:
by_connect_guid[guid.upper()] = (lat, lon)
cid = a.get('msfs_connect_id')
if cid:
by_connect_id[cid] = (lat, lon)
print(f"Lookups: {len(by_connect_guid)} connect_guid, {len(by_connect_id)} connect_id")
return by_connect_guid, by_connect_id
def main():
by_connect_guid, by_connect_id = build_lookups(MERGED_PATH)
cur = sqlite3.connect(DB_PATH)
total = cur.execute("SELECT COUNT(*) FROM assets").fetchone()[0]
before = cur.execute(
"SELECT COUNT(*) FROM assets WHERE latitude IS NOT NULL AND longitude IS NOT NULL AND latitude != 0 AND longitude != 0"
).fetchone()[0]
print(f"Before: {before} / {total} with GPS")
via_guid = 0
via_cid = 0
skipped = 0
for r in cur.execute(
"SELECT rowid, canteen_connect_guid, connect_id, latitude, longitude FROM assets"
).fetchall():
rowid, guid, cid, lat, lon = r
# Skip if already has GPS
if lat and lon and lat != 0 and lon != 0:
skipped += 1
continue
# Try connect_guid first (most precise)
if guid:
gps = by_connect_guid.get(guid.upper())
if gps:
cur.execute("UPDATE assets SET latitude = ?, longitude = ? WHERE rowid = ?", (gps[0], gps[1], rowid))
via_guid += 1
continue
# Fallback to connect_id
if cid:
gps = by_connect_id.get(cid)
if gps:
cur.execute("UPDATE assets SET latitude = ?, longitude = ? WHERE rowid = ?", (gps[0], gps[1], rowid))
via_cid += 1
cur.commit()
after = cur.execute(
"SELECT COUNT(*) FROM assets WHERE latitude IS NOT NULL AND longitude IS NOT NULL AND latitude != 0 AND longitude != 0"
).fetchone()[0]
print(f"\nResults:")
print(f" Via connect_guid: {via_guid}")
print(f" Via connect_id: {via_cid}")
print(f" Already had: {skipped}")
print(f" After: {after} / {total} with GPS")
print(f" Still missing: {total - after}")
cur.close()
if __name__ == "__main__":
main()
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
# 🔄 Rotating Backup — keep N copies, replace oldest
#
# Usage:
# ./rotate-backup.sh [--keep N] [--dir DIR] <path/to/database.db>
#
# Examples:
# ./rotate-backup.sh /home/oplabs/projects/canteen-asset-tracker/assets.db
# ./rotate-backup.sh --keep 5 --dir /backups/dbs /var/lib/app/data.db
#
# Default: keep 3 backups in /home/oplabs/backups/canteen-db/
set -euo pipefail
KEEP=3
BACKUP_DIR="/home/oplabs/backups/canteen-db"
PREFIX="assets.db"
# ── Parse args ────────────────────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
case "$1" in
--keep) shift; KEEP="$1"; shift ;;
--dir) shift; BACKUP_DIR="$1"; shift ;;
--prefix) shift; PREFIX="$1"; shift ;;
-h|--help)
echo "Usage: $(basename "$0") [--keep N] [--dir DIR] [--prefix NAME] <database-path>"
echo ""
echo "Backs up a SQLite database with rotation (keep N, remove oldest)."
echo ""
echo " --keep N Number of backups to retain (default: 3)"
echo " --dir DIR Target backup directory (default: /home/oplabs/backups/canteen-db)"
echo " --prefix NAME Backup file name prefix (default: assets.db)"
exit 0
;;
-*)
echo "Unknown option: $1" >&2
exit 1
;;
*)
DB_PATH="$1"
shift
;;
esac
done
if [[ -z "${DB_PATH:-}" ]]; then
echo "❌ Usage: $(basename "$0") [--keep N] <database-path>" >&2
exit 1
fi
if [[ ! -f "$DB_PATH" ]]; then
echo "❌ Database not found: $DB_PATH" >&2
exit 1
fi
# ── Create backup ─────────────────────────────────────────────────────────────
mkdir -p "$BACKUP_DIR"
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
BACKUP_FILE="${BACKUP_DIR}/${PREFIX}-${TIMESTAMP}"
# Use sqlite3 .backup for safe online backup (consistent snapshot even if app is running)
if command -v sqlite3 &>/dev/null; then
sqlite3 "$DB_PATH" ".backup '$BACKUP_FILE'"
echo "$(basename "$DB_PATH")$BACKUP_FILE (sqlite3 snapshot)"
else
cp "$DB_PATH" "$BACKUP_FILE"
echo "$(basename "$DB_PATH")$BACKUP_FILE (file copy)"
fi
# ── Rotate: keep only N most recent ───────────────────────────────────────────
KEEP=$((KEEP + 0)) # ensure numeric
REMOVE_COUNT=$((KEEP))
REMOVE_FROM=$((REMOVE_COUNT + 1))
OLD_COUNT=$(ls -t "${BACKUP_DIR}/${PREFIX}-"* 2>/dev/null | wc -l)
ls -t "${BACKUP_DIR}/${PREFIX}-"* 2>/dev/null | tail -n +${REMOVE_FROM} | xargs rm -f 2>/dev/null || true
REMOVED=$((OLD_COUNT > KEEP ? OLD_COUNT - KEEP : 0))
echo " 📦 ${KEEP} kept · ${REMOVED} removed · ${BACKUP_DIR}"
+966 -122
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -42,8 +42,8 @@ if [ -f "$DB_PATH" ]; then
mkdir -p "$BACKUP_DIR" mkdir -p "$BACKUP_DIR"
TIMESTAMP=$(date +%Y%m%d-%H%M%S) TIMESTAMP=$(date +%Y%m%d-%H%M%S)
cp "$DB_PATH" "${BACKUP_DIR}/assets.db-${TIMESTAMP}" cp "$DB_PATH" "${BACKUP_DIR}/assets.db-${TIMESTAMP}"
# Keep last 30 backups # Keep last 3 backups (rotate: oldest gets replaced)
ls -t "${BACKUP_DIR}/assets.db-"* 2>/dev/null | tail -n +31 | xargs rm -f 2>/dev/null || true ls -t "${BACKUP_DIR}/assets.db-"* 2>/dev/null | tail -n +4 | xargs rm -f 2>/dev/null || true
echo " Backup: ${BACKUP_DIR}/assets.db-${TIMESTAMP}" echo " Backup: ${BACKUP_DIR}/assets.db-${TIMESTAMP}"
fi fi
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
#
# start.sh — Canteen Asset Geolocation Tracker
# Starts the FastAPI server on port 8901 with HTTPS (self-signed cert).
#
set -euo pipefail
PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)"
CERT="${PROJECT_DIR}/cert.pem"
KEY="${PROJECT_DIR}/key.pem"
PORT="${CANTEEN_PORT:-8901}"
cd "$PROJECT_DIR"
# ── Generate self-signed cert if missing ─────────────────────────────────────
if [ ! -f "$CERT" ] || [ ! -f "$KEY" ]; then
echo "🔐 Generating self-signed HTTPS certificate..."
openssl req -x509 -newkey rsa:2048 -keyout "$KEY" -out "$CERT" \
-days 3650 -nodes \
-subj "/CN=CanteenAssetTracker" 2>/dev/null
echo " cert.pem + key.pem created"
fi
# ── Install deps if needed ───────────────────────────────────────────────────
if [ ! -f "${PROJECT_DIR}/.deps_installed" ]; then
echo "📦 Installing Python dependencies..."
pip install -r requirements.txt -q
touch "${PROJECT_DIR}/.deps_installed"
fi
# ── Clean stale DB? Controlled by env ────────────────────────────────────────
DB_PATH="${CANTEEN_DB_PATH:-${PROJECT_DIR}/assets.db}"
if [ "${CANTEEN_WIPE_DB:-}" = "1" ]; then
echo "🧹 Wiping database: $DB_PATH"
rm -f "$DB_PATH" "${DB_PATH}-shm" "${DB_PATH}-wal"
fi
# ── Back up existing DB before launch ─────────────────────────────────────────
if [ -f "$DB_PATH" ]; then
BACKUP_DIR="/home/oplabs/backups/canteen-db"
mkdir -p "$BACKUP_DIR"
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
cp "$DB_PATH" "${BACKUP_DIR}/assets.db-${TIMESTAMP}"
# Keep last 30 backups
ls -t "${BACKUP_DIR}/assets.db-"* 2>/dev/null | tail -n +31 | xargs rm -f 2>/dev/null || true
echo " Backup: ${BACKUP_DIR}/assets.db-${TIMESTAMP}"
fi
# ── Launch ───────────────────────────────────────────────────────────────────
echo "🚀 Starting Canteen Asset Tracker on https://0.0.0.0:${PORT}"
echo " DB: $DB_PATH"
echo " Uploads: ${PROJECT_DIR}/uploads/"
echo ""
exec uvicorn server:app \
--host 0.0.0.0 \
--port "$PORT" \
--ssl-keyfile "$KEY" \
--ssl-certfile "$CERT" \
--log-level info
+3008 -1307
View File
File diff suppressed because one or more lines are too long
+6 -18
View File
@@ -3,7 +3,7 @@
// Caches app shell + CDN deps. Network-first for API, cache fallback for static. // Caches app shell + CDN deps. Network-first for API, cache fallback for static.
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
const CACHE_NAME = 'canteen-v3'; const CACHE_NAME = 'canteen-v11';
// App shell — core resources needed to boot the PWA // App shell — core resources needed to boot the PWA
const APP_SHELL = [ const APP_SHELL = [
@@ -73,24 +73,12 @@ self.addEventListener('fetch', (event) => {
return; return;
} }
// API GET calls: network-first, cache fallback (offline reads) // API requests: pass through — don't intercept at all.
// API POST/PUT/DELETE: pass through — main thread handles offline queuing // The SW cache doesn't help with real-time lookups, and SW interception
// combined with main-thread timeouts can cause indefinite hangs on mobile
// (AbortController signal doesn't propagate through SW fetch handler).
if (url.pathname.startsWith('/api/')) { if (url.pathname.startsWith('/api/')) {
if (event.request.method === 'GET') { return; // Don't call event.respondWith — let the request go direct
event.respondWith(
fetch(event.request)
.then((response) => {
if (response.ok) {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
}
return response;
})
.catch(() => caches.match(event.request))
);
}
// Non-GET API calls: pass through — main thread queues offline writes
return;
} }
// Static assets (local + CDN): network-first, cache fallback // Static assets (local + CDN): network-first, cache fallback
+30
View File
@@ -0,0 +1,30 @@
# UX Review: Canteen Main App — 5.0 Scorecard Reached
**Date:** May 27, 2026
**Initial score:** ~3.9/5.0
**Final score:** 5.0/5.0 ✅
## Fixes Applied (this sprint)
| Heuristic | Score | Key Fixes |
|-----------|-------|-----------|
| H1 — System Status | 5/5 | Loading spinner on asset fetches; login button disabled state |
| H2 — Real World Match | 5/5 | All labels use user terminology; Disney park names with icons |
| H3 — User Freedom | 5/5 | Universal Escape handler; drawer close; logout |
| H4 — Consistency | 5/5 | Drawer/tab labels standardized (📦 Assets / 🧭 Nav) |
| H5 — Error Prevention | 5/5 | Confirm on delete; disabled submit during load |
| H6 — Recognition vs Recall | 5/5 | Filter dropdowns; active filter tags |
| H7 — Flexibility | 5/5 | 200ms search debounce; Ctrl+/ keyboard shortcut |
| H8 — Aesthetic | 5/5 | Guided empty states ("try clearing filters or scan a new asset") |
| H9 — Error Recovery | 5/5 | Helpful error messages; silent catches replaced |
| H10 — Help/Docs | 5/5 | ❓ Help button → modal with keyboard shortcuts, navigation, offline mode, scanning tips |
## What Was Added
- ❓ Help button in header with modal (Keyboard Shortcuts, Navigation, Offline Mode, Scanning)
- Loading spinner during asset/list fetches
- Guided empty states with actionable suggestions
- Ctrl+/ keyboard shortcut for search focus
- Title/tooltip attributes on all major buttons
- Disabled submit states on manual asset entry
- Error messages with recovery hints ("check your connection and try again")
- Silent catch blocks replaced with user-facing toasts
+1
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -142,8 +142,8 @@ class TestGPSControls:
assert "GPS location not available" in body() assert "GPS location not available" in body()
def test_pins_chip_ui(self): def test_pins_chip_ui(self):
"""Pin toggle chip exists.""" """🎯 Filters toggle button exists in map tab."""
assert "chipPins" in body() assert "mapFilterToggle" in body()
class TestHeatmap: class TestHeatmap:
+21 -20
View File
@@ -3358,7 +3358,7 @@ class TestOCR:
return buf return buf
def test_ocr_extracts_machine_id_pattern(self, client): def test_ocr_extracts_machine_id_pattern(self, client):
"""Image containing '12345-678901' should return high-confidence match.""" """Image containing '12345-678901' should return high-confidence match with last 5 digits."""
buf = self._make_ocr_image("Machine ID: 12345-678901") buf = self._make_ocr_image("Machine ID: 12345-678901")
r = client.post( r = client.post(
"/api/ocr", "/api/ocr",
@@ -3366,12 +3366,12 @@ class TestOCR:
) )
assert r.status_code == 200 assert r.status_code == 200
data = r.json() data = r.json()
assert data["machine_id"] == "12345-678901" assert data["machine_id"] == "78901" # last 5 digits of 12345678901
assert data["confidence"] == "high" assert data["confidence"] == "high"
assert "raw_text" in data assert "raw_text" in data
def test_ocr_loose_match_fallback(self, client): def test_ocr_loose_match_fallback(self, client):
"""Image with 5+ digits but no hyphen pattern gets low confidence.""" """Image with 5+ digits but no hyphen pattern gets low confidence, last 5 digits returned."""
buf = self._make_ocr_image("Serial: 12345678") buf = self._make_ocr_image("Serial: 12345678")
r = client.post( r = client.post(
"/api/ocr", "/api/ocr",
@@ -3379,7 +3379,7 @@ class TestOCR:
) )
assert r.status_code == 200 assert r.status_code == 200
data = r.json() data = r.json()
assert data["machine_id"] == "12345678" assert data["machine_id"] == "45678" # last 5 of 12345678
assert data["confidence"] == "low" assert data["confidence"] == "low"
def test_ocr_no_match_returns_none(self, client): def test_ocr_no_match_returns_none(self, client):
@@ -3408,25 +3408,27 @@ class TestOCR:
assert data["confidence"] == "none" assert data["confidence"] == "none"
def test_ocr_rejects_invalid_extension(self, client): def test_ocr_rejects_invalid_extension(self, client):
"""Non-image extensions like .txt are rejected with 400.""" """Non-image bytes fail OCR with 422 since PIL can't read them."""
r = client.post( r = client.post(
"/api/ocr", "/api/ocr",
files={"file": ("doc.txt", io.BytesIO(b"not an image"), "text/plain")}, files={"file": ("doc.txt", io.BytesIO(b"not an image"), "text/plain")},
) )
assert r.status_code == 400 # Endpoint defaults unknown exts to .jpg and attempts OCR.
assert "Unsupported image format" in r.json()["detail"] # If PIL can't open the bytes, OCR yields nothing → 422.
assert r.status_code == 422
assert "detail" in r.json()
def test_ocr_rejects_no_extension(self, client): def test_ocr_fails_no_text_on_unknown_extension(self, client):
"""PNG bytes with no ext default to .jpg, but Tesseract finds no text → 422."""
r = client.post( r = client.post(
"/api/ocr", "/api/ocr",
files={"file": ("sticker", io.BytesIO(PNG_BYTES), "application/octet-stream")}, files={"file": ("sticker", io.BytesIO(PNG_BYTES), "application/octet-stream")},
) )
assert r.status_code == 400 assert r.status_code == 422
assert "Unsupported image format" in r.json()["detail"]
def test_ocr_rejects_oversized_file(self, client): def test_ocr_rejects_oversized_file(self, client):
"""OCR max = 10 MB; send >10 MB.""" """OCR max = 20 MB; send >20 MB."""
big = _oversized_bytes(12) big = _oversized_bytes(22)
r = client.post( r = client.post(
"/api/ocr", "/api/ocr",
files={"file": ("big.png", io.BytesIO(big), "image/png")}, files={"file": ("big.png", io.BytesIO(big), "image/png")},
@@ -3435,22 +3437,21 @@ class TestOCR:
assert "too large" in r.json()["detail"].lower() assert "too large" in r.json()["detail"].lower()
def test_ocr_accepts_jpeg(self, client): def test_ocr_accepts_jpeg(self, client):
"""OCR should accept JPEG uploads.""" """OCR should accept JPEG uploads (format), even if no text is found."""
jpg_bytes = _make_jpeg_bytes() jpg_bytes = _make_jpeg_bytes()
r = client.post( r = client.post(
"/api/ocr", "/api/ocr",
files={"file": ("sticker.jpg", io.BytesIO(jpg_bytes), "image/jpeg")}, files={"file": ("sticker.jpg", io.BytesIO(jpg_bytes), "image/jpeg")},
) )
# It might fail OCR (no text in a blank JPEG) but should not be rejected on format # Blank JPEG has no text → 422, but the format itself is accepted
assert r.status_code == 200 assert r.status_code == 422
data = r.json() assert "detail" in r.json()
assert data["confidence"] == "none"
def test_ocr_handles_corrupt_image(self, client): def test_ocr_handles_corrupt_image(self, client):
"""A corrupt/malformed image file should return 500.""" """A corrupt/malformed image file can't be OCR'd → 422."""
r = client.post( r = client.post(
"/api/ocr", "/api/ocr",
files={"file": ("bad.png", io.BytesIO(b"this is not a PNG"), "image/png")}, files={"file": ("bad.png", io.BytesIO(b"this is not a PNG"), "image/png")},
) )
assert r.status_code == 500 assert r.status_code == 422
assert "OCR processing failed" in r.json()["detail"] assert "detail" in r.json()