🌱 Complete seed import tool — all 17 kanban tasks done

Modules:
- parser.py: Full Excel parser (1,848 machines, Disney mapping, Pattern A+B parsing)
- db_writer.py: SQLite writer with per-field update policy (seed + update modes)
- backup.py: GPS/photo backup, restore, and compare
- reporter.py: Comprehensive validation report generator
- main.py: CLI entry point

Database: assets.db with 1,848 machines across 36 columns
Handbook: reference_handbook.md v2.0 — all sections finalized
Report: validation_report.md — 184 OCR corrections, 143 unknown machines
This commit is contained in:
2026-05-24 21:50:46 -04:00
parent 6743df6859
commit 457e7794a0
9 changed files with 4523 additions and 416 deletions
+3
View File
@@ -18,3 +18,6 @@ Thumbs.db
# Reports
reports/
seed-data/assets.db
seed-data/Machine_List.xlsx
seed-data/*.zip
+441
View File
@@ -0,0 +1,441 @@
#!/usr/bin/env python3
"""
📦 Backup GPS & Check-ins Module
Before each import run, backs up existing GPS (lat, lng) and photo_path
fields from the target database to a compressed JSON backup file.
After import completes, compares backed up values with what was written
and flags any GPS/photo values that were incorrectly overwritten.
CLI Usage:
python3 backup.py --backup seed-data/assets.db
→ Creates backup_YYYY-MM-DD.json.gz
python3 backup.py --restore backup_2026-05-24.json.gz [target.db]
→ Restores preserved fields from backup (uses embedded path if
target.db not provided)
python3 backup.py --compare backup_2026-05-24.json.gz [target.db]
→ Compares backup vs current DB, flags unexpected changes
"""
import argparse
import gzip
import json
import os
import sqlite3
import sys
from datetime import date
from typing import Any, Optional
# ─── Fields to preserve ───────────────────────────────────────────────────────
PRESERVED_FIELDS = [
"lat",
"lng",
"photo_path",
]
BACKUP_VERSION = 1
# ─── Helpers ──────────────────────────────────────────────────────────────────
def _resolve_path(path: str) -> str:
"""Resolve ~ and relative paths to absolute."""
return os.path.abspath(os.path.expanduser(path))
def _backup_filename() -> str:
"""Generate a timestamped backup filename."""
today = date.today().isoformat() # YYYY-MM-DD
return f"backup_{today}.json.gz"
def _conn(db_path: str) -> sqlite3.Connection:
"""Open a read-write connection to the SQLite database."""
path = _resolve_path(db_path)
if not os.path.isfile(path):
print(f"❌ Database not found: {path}", file=sys.stderr)
sys.exit(1)
return sqlite3.connect(path)
# ─── Schema introspection ─────────────────────────────────────────────────────
def _get_table_info(conn: sqlite3.Connection) -> list[dict[str, Any]]:
"""Return list of {name, columns, id_column} for candidate tables."""
cursor = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
)
tables = []
for (tname,) in cursor.fetchall():
cols = [
row[1]
for row in conn.execute(f"PRAGMA table_info({tname!r})").fetchall()
]
# Find the most likely ID column
id_col = _pick_id_column(cols)
tables.append({"name": tname, "columns": cols, "id_column": id_col})
return tables
def _pick_id_column(cols: list[str]) -> Optional[str]:
"""Pick the most probable identity column from a list of column names."""
lower = [c.lower() for c in cols]
for candidate in ["machine_id", "asset_id", "id", "uid"]:
if candidate in lower:
idx = lower.index(candidate)
return cols[idx]
# Fallback: first column (usually a PK)
return cols[0] if cols else None
def _discover_asset_table(
conn: sqlite3.Connection,
) -> tuple[str, str, list[str]]:
"""
Find the 'assets' table (or best guess) and return
(table_name, id_column, preserved_columns_that_exist).
"""
tables = _get_table_info(conn)
if not tables:
print("❌ No tables found in database.", file=sys.stderr)
sys.exit(1)
# Prefer a table named 'assets' or similar
preferred = ["assets", "machines", "machines", "asset"]
asset_table = None
for pref in preferred:
for t in tables:
if t["name"].lower() == pref:
asset_table = t
break
if asset_table:
break
if not asset_table:
# Use the first table that has at least one preserved field
for t in tables:
cols_lower = [c.lower() for c in t["columns"]]
if any(f.lower() in cols_lower for f in PRESERVED_FIELDS):
asset_table = t
break
if not asset_table:
print(
"❌ Could not find a suitable table with GPS/photo columns.",
file=sys.stderr,
)
print(f" Tables found: {[t['name'] for t in tables]}", file=sys.stderr)
sys.exit(1)
cols_lower = [c.lower() for c in asset_table["columns"]]
existing = [f for f in PRESERVED_FIELDS if f.lower() in cols_lower]
return asset_table["name"], asset_table["id_column"] or "rowid", existing
# ─── Core operations ──────────────────────────────────────────────────────────
def backup(db_path: str) -> str:
"""
Back up GPS and photo_path fields from the database.
Returns the path to the created backup file.
"""
db_path = _resolve_path(db_path)
conn = _conn(db_path)
table, id_col, fields = _discover_asset_table(conn)
if not fields:
print(
f"⚠️ No preserved fields ({', '.join(PRESERVED_FIELDS)}) "
f"found in table '{table}'.",
file=sys.stderr,
)
conn.close()
sys.exit(1)
# Build query
col_exprs = ", ".join(f'"{c}"' for c in [id_col] + fields)
rows = conn.execute(f'SELECT {col_exprs} FROM "{table}"').fetchall()
backup_data: dict[str, Any] = {
"version": BACKUP_VERSION,
"source_db": db_path,
"source_table": table,
"id_column": id_col,
"fields": fields,
"created": date.today().isoformat(),
"records": [],
}
for row in rows:
rec: dict[str, Any] = {"id": row[0]}
for i, field in enumerate(fields):
val = row[i + 1]
# Convert None to null (JSON-friendly)
rec[field] = val
backup_data["records"].append(rec)
conn.close()
# Write compressed backup
out_path = _backup_filename()
json_bytes = json.dumps(backup_data, indent=2, default=str).encode("utf-8")
with gzip.open(out_path, "wb") as f:
f.write(json_bytes)
record_count = len(backup_data["records"])
non_null = sum(
1
for r in backup_data["records"]
if any(r.get(f) is not None for f in fields)
)
print(
f"✅ Backup saved: {out_path} "
f"({record_count} records, {non_null} with non-null preserved fields)"
)
return out_path
def restore(backup_path: str, target_db: Optional[str] = None) -> None:
"""
Restore GPS and photo_path fields from a backup file into the database.
"""
backup_path = _resolve_path(backup_path)
if not os.path.isfile(backup_path):
print(f"❌ Backup file not found: {backup_path}", file=sys.stderr)
sys.exit(1)
# Load backup
with gzip.open(backup_path, "rb") as f:
backup_data = json.loads(f.read().decode("utf-8"))
# Determine target DB
db_path = target_db or backup_data.get("source_db")
if not db_path:
print(
"❌ No target database specified and no source_db in backup.",
file=sys.stderr,
)
sys.exit(1)
db_path = _resolve_path(db_path)
if not os.path.isfile(db_path):
print(f"❌ Target database not found: {db_path}", file=sys.stderr)
sys.exit(1)
table = backup_data["source_table"]
id_col = backup_data["id_column"]
fields = backup_data["fields"]
records = backup_data["records"]
conn = sqlite3.connect(db_path)
# Verify table and columns still exist
existing_cols = [
r[1]
for r in conn.execute(f"PRAGMA table_info({table!r})").fetchall()
]
existing_lower = [c.lower() for c in existing_cols]
missing_fields = [f for f in fields if f.lower() not in existing_lower]
if missing_fields:
print(
f"⚠️ Columns missing in target table '{table}': "
f"{', '.join(missing_fields)}",
file=sys.stderr,
)
fields = [f for f in fields if f.lower() in existing_lower]
if not fields:
print("❌ No preserved columns exist in target.", file=sys.stderr)
conn.close()
sys.exit(1)
# Build UPDATE statements
set_clause = ", ".join(f'"{c}" = ?' for c in fields)
sql = f'UPDATE "{table}" SET {set_clause} WHERE "{id_col}" = ?'
restored = 0
skipped = 0
for rec in records:
values = [rec.get(f) for f in fields] + [rec["id"]]
cursor = conn.execute(sql, values)
if cursor.rowcount > 0:
restored += 1
else:
skipped += 1
conn.commit()
conn.close()
print(
f"✅ Restored {restored} records from {backup_path}\n"
f" Fields: {', '.join(fields)}\n"
f" Skipped (no matching row): {skipped}"
)
def compare(backup_path: str, target_db: Optional[str] = None) -> int:
"""
Compare backed-up values against current database values.
Flags any GPS/photo values that were incorrectly overwritten.
Returns count of flags.
"""
backup_path = _resolve_path(backup_path)
if not os.path.isfile(backup_path):
print(f"❌ Backup file not found: {backup_path}", file=sys.stderr)
sys.exit(1)
# Load backup
with gzip.open(backup_path, "rb") as f:
backup_data = json.loads(f.read().decode("utf-8"))
# Determine target DB
db_path = target_db or backup_data.get("source_db")
if not db_path:
print(
"❌ No target database specified and no source_db in backup.",
file=sys.stderr,
)
sys.exit(1)
db_path = _resolve_path(db_path)
if not os.path.isfile(db_path):
print(f"❌ Target database not found: {db_path}", file=sys.stderr)
sys.exit(1)
table = backup_data["source_table"]
id_col = backup_data["id_column"]
fields = backup_data["fields"]
records = backup_data["records"]
conn = sqlite3.connect(db_path)
# Build a lookup of current values
col_exprs = ", ".join(f'"{c}"' for c in [id_col] + fields)
try:
current_rows = conn.execute(
f'SELECT {col_exprs} FROM "{table}"'
).fetchall()
except sqlite3.OperationalError as e:
print(f"❌ Error querying table '{table}': {e}", file=sys.stderr)
conn.close()
sys.exit(1)
current_lookup: dict[str, dict[str, Any]] = {}
for row in current_rows:
rec: dict[str, Any] = {"id": row[0]}
for i, field in enumerate(fields):
rec[field] = row[i + 1]
current_lookup[str(row[0])] = rec
# Compare
flags = []
for rec in records:
rec_id = str(rec["id"])
current = current_lookup.get(rec_id)
if current is None:
# Record was deleted — not a concern
continue
for field in fields:
backup_val = rec.get(field)
current_val = current.get(field)
if backup_val is None:
# Backup had no value — import may have set one, that's fine
continue
if str(backup_val) != str(current_val):
# Previously non-null value was changed
flags.append(
{
"id": rec_id,
"field": field,
"backup_value": backup_val,
"current_value": current_val,
}
)
conn.close()
if not flags:
print("✅ No unexpected GPS/photo overwrites detected.")
return 0
print(
f"⚠️ Found {len(flags)} potentially overwritten field(s):\n",
file=sys.stderr,
)
for f in flags:
print(
f" ID {f['id']:>10s} | {f['field']:15s} "
f"| was: {str(f['backup_value']):>25s} "
f"| now: {str(f['current_value']):>25s}",
file=sys.stderr,
)
print(
"\n💡 Use --restore to revert if these changes are unintended.",
file=sys.stderr,
)
return len(flags)
# ─── CLI ──────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
description="📦 Backup GPS & Check-ins Module — Preserve GPS/photo data "
"across import runs.",
)
parser.add_argument(
"--backup",
metavar="DB_PATH",
help="Backup GPS (lat, lng) and photo_path from database.",
)
parser.add_argument(
"--restore",
metavar="BACKUP_FILE",
help="Restore preserved fields from a backup .json.gz file.",
)
parser.add_argument(
"--compare",
metavar="BACKUP_FILE",
help="Compare backup against current DB and flag overwrites.",
)
parser.add_argument(
"target",
nargs="?",
default=None,
help="Target database path (optional for --restore and --compare "
"if source_db is embedded in the backup file).",
)
args = parser.parse_args()
# Exactly one mode
modes = sum(
1 for m in [args.backup, args.restore, args.compare] if m
)
if modes != 1:
parser.print_help()
print(
"\n❌ Specify exactly one of: --backup, --restore, --compare",
file=sys.stderr,
)
sys.exit(1)
if args.backup:
backup(args.backup)
elif args.restore:
restore(args.restore, args.target)
elif args.compare:
compare(args.compare, args.target)
if __name__ == "__main__":
main()
+480
View File
@@ -0,0 +1,480 @@
#!/usr/bin/env python3
"""
🌱 Canteen Seed Data Import — Database Writer
Writes parsed machine records to SQLite database with per-field update policy.
Usage:
from db_writer import DatabaseWriter
writer = DatabaseWriter('seed-data/assets.db')
writer.seed(machines) # First import — insert all fresh
writer.update(machines) # Subsequent import — upsert with preserve/auto rules
"""
import json
import os
import sqlite3
from datetime import datetime
# ─── Per-field update policy (from Handbook Section 11) ───────────────────────
# Fields that are NEVER overwritten if they already have a value
PRESERVE_FIELDS = {
"latitude",
"longitude",
"photo_path",
"install_date",
"deployed",
"pulled_date",
}
# Fields that are ALWAYS overwritten from new data
AUTO_FIELDS = {
"serial_number",
"name",
"make",
"model",
"class",
"is_glass_front",
"company",
"place",
"building_name",
"floor",
"zone",
"zone_type",
"address",
"location_area",
"dex_report_date",
"disney_park",
"remote_pricing_status",
"alerts",
"telemetry_provider",
"card_reader_brand",
"priority",
"yearly_sales",
"monthly_sales",
"weekly_sales",
"days_since_restock",
"prepick_group",
"has_cashless",
}
# All 33 DB columns (Section 11 of the handbook)
# machine_id is the primary key — handled separately
ALL_COLUMNS = [
"machine_id", # preserve — primary key
"serial_number", # auto
"name", # auto (generated: "Make @ Place")
"make", # auto
"model", # auto
"class", # auto
"is_glass_front", # auto
"company", # auto
"place", # auto
"building_name", # auto
"floor", # auto
"zone", # auto
"zone_type", # auto
"address", # auto
"location_area", # auto
"latitude", # preserve
"longitude", # preserve
"photo_path", # preserve
"dex_report_date", # auto
"install_date", # preserve
"deployed", # preserve
"pulled_date", # preserve
"disney_park", # auto
"remote_pricing_status", # auto
"alerts", # auto
"telemetry_provider", # auto
"card_reader_brand", # auto
"priority", # auto
"yearly_sales", # auto
"monthly_sales", # auto
"weekly_sales", # auto
"days_since_restock", # auto
"prepick_group", # auto
"has_cashless", # auto
]
# SQL column types
COLUMN_TYPES = {
"machine_id": "TEXT PRIMARY KEY",
"serial_number": "TEXT",
"name": "TEXT",
"make": "TEXT",
"model": "TEXT",
"class": "TEXT",
"is_glass_front": "INTEGER",
"company": "TEXT",
"place": "TEXT",
"building_name": "TEXT",
"floor": "INTEGER",
"zone": "TEXT",
"zone_type": "TEXT",
"address": "TEXT",
"location_area": "TEXT",
"latitude": "REAL",
"longitude": "REAL",
"photo_path": "TEXT",
"dex_report_date": "TEXT",
"install_date": "TEXT",
"deployed": "INTEGER",
"pulled_date": "TEXT",
"disney_park": "TEXT",
"remote_pricing_status": "TEXT",
"alerts": "TEXT",
"telemetry_provider": "TEXT",
"card_reader_brand": "TEXT",
"priority": "TEXT",
"yearly_sales": "REAL",
"monthly_sales": "REAL",
"weekly_sales": "REAL",
"days_since_restock": "INTEGER",
"prepick_group": "TEXT",
"has_cashless": "INTEGER",
# Extra metadata fields (not in Section 11 but useful)
"created_at": "TEXT",
"updated_at": "TEXT",
}
# Extra columns for metadata
EXTRA_COLUMNS = ["created_at", "updated_at"]
# Fields that are not in the parser output and only come from external sources
EXTERNAL_PRESERVE_FIELDS = {"latitude", "longitude", "photo_path"}
def _serialize_value(val):
"""Convert a Python value to a SQLite-friendly value."""
if val is None:
return None
if isinstance(val, dict):
return json.dumps(val, default=str)
if isinstance(val, bool):
return 1 if val else 0
if isinstance(val, datetime):
return val.isoformat()
return val
def _deserialize_value(val, column):
"""Convert SQLite value back to Python, if needed."""
if column == "is_glass_front" or column == "has_cashless" or column == "deployed":
if val is None:
return None
return bool(val)
if column == "alerts" and val is not None and isinstance(val, str):
try:
return json.loads(val)
except (json.JSONDecodeError, TypeError):
return val
return val
def generate_name(make, place):
"""Generate a machine name from Make and Place."""
make_str = str(make).strip() if make else "Unknown"
place_str = str(place).strip() if place else "Unknown"
return f"{make_str} @ {place_str}"
class DatabaseWriter:
"""
SQLite database writer for seed data import.
Handles both 'seed' (fresh insert) and 'update' (upsert with policy) modes.
"""
def __init__(self, db_path):
self.db_path = os.path.abspath(db_path)
self.conn = None
self._open_connection()
def _open_connection(self):
"""Open SQLite connection, creating dir if needed."""
db_dir = os.path.dirname(self.db_path)
if db_dir and not os.path.exists(db_dir):
os.makedirs(db_dir, exist_ok=True)
self.conn = sqlite3.connect(self.db_path)
self.conn.row_factory = sqlite3.Row
def create_table(self):
"""Create the 'assets' table with all columns from Section 11."""
columns_defs = []
for col in ALL_COLUMNS:
col_type = COLUMN_TYPES.get(col, "TEXT")
columns_defs.append(f" {col} {col_type}")
for col in EXTRA_COLUMNS:
col_type = COLUMN_TYPES.get(col, "TEXT")
columns_defs.append(f" {col} {col_type}")
create_sql = (
"CREATE TABLE IF NOT EXISTS assets (\n"
+ ",\n".join(columns_defs)
+ "\n)"
)
self.conn.execute(create_sql)
self.conn.commit()
def _extract_row_values(self, machine):
"""Extract values from a machine dict, mapping to DB columns."""
now = datetime.utcnow().isoformat()
# Generate name from Make + Place
name = generate_name(machine.get("make"), machine.get("place"))
# Build row dict (only the columns we store)
row = {}
for col in ALL_COLUMNS:
if col == "name":
row[col] = name
elif col == "machine_id":
row[col] = str(machine.get("machine_id", ""))
elif col == "is_glass_front":
row[col] = 1 if machine.get("is_glass_front") else 0
elif col == "has_cashless":
val = machine.get("has_cashless")
if val is True or val == 1 or val == "Yes" or val == "yes":
row[col] = 1
elif val is False or val == 0 or val == "No" or val == "no":
row[col] = 0
else:
row[col] = None
elif col == "deployed":
val = machine.get("deployed")
if val is True or val == 1 or val == "Yes" or val == "yes":
row[col] = 1
elif val is False or val == 0 or val == "No" or val == "no":
row[col] = 0
else:
row[col] = None
elif col == "alerts":
row[col] = _serialize_value(machine.get("alerts"))
elif col == "floor":
val = machine.get("floor")
if val is not None:
try:
row[col] = int(val)
except (ValueError, TypeError):
row[col] = None
else:
row[col] = None
elif col in ("yearly_sales", "monthly_sales", "weekly_sales"):
row[col] = _serialize_value(machine.get(col))
elif col in ("latitude", "longitude"):
row[col] = _serialize_value(machine.get(col))
else:
val = machine.get(col)
if val is not None and not isinstance(val, (str, int, float, bool)):
val = str(val)
row[col] = val
# Extra metadata
row["created_at"] = now
row["updated_at"] = now
return row
def _columns_for_insert(self):
"""Return list of column names for INSERT (excluding PRIMARY KEY metadata for upsert)."""
return ALL_COLUMNS + EXTRA_COLUMNS
def seed(self, machines):
"""
Seed mode: Insert all machines fresh.
Drops any existing data and re-inserts.
Returns dict with counts.
"""
self.create_table()
# Clear existing data
self.conn.execute("DELETE FROM assets")
insert_cols = self._columns_for_insert()
placeholders = ", ".join("?" for _ in insert_cols)
col_names = ", ".join(insert_cols)
insert_sql = f"INSERT INTO assets ({col_names}) VALUES ({placeholders})"
count = 0
errors = 0
for machine in machines:
try:
row = self._extract_row_values(machine)
values = [row.get(c) for c in insert_cols]
self.conn.execute(insert_sql, values)
count += 1
except Exception as e:
errors += 1
mid = machine.get("machine_id", "???")
print(f" ⚠️ Error inserting machine {mid}: {e}")
self.conn.commit()
return {
"inserted": count,
"errors": errors,
"total": len(machines),
}
def update(self, machines):
"""
Update mode: Upsert by machine_id.
PRESERVE fields (lat, lng, photo_path, install_date, deployed, pulled_date):
Skip update if existing value is non-null.
AUTO fields: Always overwrite.
machine_id: Never changes (used as match key).
Returns dict with counts.
"""
self.create_table()
# Ensure machine_id is indexed
self.conn.execute("""
CREATE INDEX IF NOT EXISTS idx_assets_machine_id ON assets(machine_id)
""")
insert_cols = self._columns_for_insert()
col_names = ", ".join(insert_cols)
stats = {
"inserted": 0,
"updated": 0,
"preserved": 0,
"skipped": 0,
"errors": 0,
}
# Prepare the UPDATE SET clause: col = ? for each non-PK column
update_cols = [c for c in insert_cols if c != "machine_id"]
set_clause = ", ".join(f"{c} = ?" for c in update_cols)
for machine in machines:
mid = machine.get("machine_id")
if not mid:
stats["skipped"] += 1
continue
mid_str = str(mid)
# Check if machine exists
cursor = self.conn.execute(
"SELECT * FROM assets WHERE machine_id = ?", (mid_str,)
)
existing = cursor.fetchone()
row = self._extract_row_values(machine)
if existing is None:
# INSERT new record
values = [row.get(c) for c in insert_cols]
try:
self.conn.execute(
f"INSERT INTO assets ({col_names}) VALUES ({', '.join('?' for _ in insert_cols)})",
values,
)
stats["inserted"] += 1
except Exception as e:
stats["errors"] += 1
print(f" ⚠️ Error inserting machine {mid}: {e}")
else:
# UPDATE existing record with preserve/auto rules
existing_dict = dict(existing)
updates = {}
preserved_count = 0
for col in update_cols:
new_val = row.get(col)
old_val = existing_dict.get(col)
if col in PRESERVE_FIELDS:
# PRESERVE: skip if existing value is non-null
if old_val is not None and old_val != "":
preserved_count += 1
continue # Don't update
# If old is NULL, we can set it
updates[col] = new_val
elif col == "machine_id":
continue # PK — never update
else:
# AUTO: always overwrite
updates[col] = new_val
if updates:
# Build dynamic UPDATE
set_parts = []
set_values = []
for col, val in updates.items():
set_parts.append(f"{col} = ?")
set_values.append(val)
# Always update updated_at
set_parts.append("updated_at = ?")
set_values.append(datetime.utcnow().isoformat())
set_values.append(mid_str)
try:
self.conn.execute(
f"UPDATE assets SET {', '.join(set_parts)} WHERE machine_id = ?",
set_values,
)
stats["updated"] += 1
stats["preserved"] += preserved_count
except Exception as e:
stats["errors"] += 1
print(f" ⚠️ Error updating machine {mid}: {e}")
else:
stats["skipped"] += 1
self.conn.commit()
return stats
def get_machine_count(self):
"""Return the number of machines in the database."""
cursor = self.conn.execute("SELECT COUNT(*) FROM assets")
return cursor.fetchone()[0]
def close(self):
"""Close the database connection."""
if self.conn:
self.conn.close()
self.conn = None
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
def write_seed(machines, db_path):
"""Convenience: seed mode with reporting."""
print(f" 📁 Database: {db_path}")
with DatabaseWriter(db_path) as writer:
stats = writer.seed(machines)
final_count = writer.get_machine_count()
print(f" ✅ Seeded: {stats['inserted']} machines")
print(f" 📊 Total in DB: {final_count}")
if stats["errors"]:
print(f" ⚠️ Errors: {stats['errors']}")
return stats
def write_update(machines, db_path):
"""Convenience: update mode with reporting."""
print(f" 📁 Database: {db_path}")
with DatabaseWriter(db_path) as writer:
stats = writer.update(machines)
final_count = writer.get_machine_count()
print(f" ✅ Inserted: {stats['inserted']}")
print(f" 🔄 Updated: {stats['updated']}")
print(f" 🛡️ Preserved fields: {stats['preserved']}")
print(f" ⏭️ Skipped (no changes): {stats['skipped']}")
print(f" 📊 Total in DB: {final_count}")
if stats["errors"]:
print(f" ⚠️ Errors: {stats['errors']}")
return stats
+94 -16
View File
@@ -2,30 +2,108 @@
"""
🌱 Canteen Seed Data Import Tool
Standalone import pipeline for Cantaloupe CSV exports.
Run with --help for usage.
CLI entry point for the seed data import pipeline.
Parses Cantaloupe Excel exports and writes to SQLite database.
Pipeline stages:
1. Backup existing GPS + check-ins from target DB
2. Parse & map CSV headers
3. Extract location data from Customer/Place columns
4. Derive GPS coordinates (with multi-floor support)
5. Validate & correct serial numbers
6. Normalize Class/Make/Model
7. Apply pricing status, telemetry, alert metadata
8. Score priority from sales data
9. Write to DB (preserving existing GPS/check-ins)
10. Generate validation report
Usage:
python3 main.py seed-data/Machine_List.xlsx --output seed-data/assets.db
python3 main.py seed-data/Machine_List.xlsx --output seed-data/assets.db --mode=update
python3 main.py seed-data/Machine_List.xlsx -o seed-data/assets.db -m seed
"""
import argparse
import sys
import os
# Import pipeline modules
from parser import parse_excel
from db_writer import write_seed, write_update
def main():
parser = argparse.ArgumentParser(
description="🌱 Canteen Seed Data Import Tool",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"Examples:\n"
" %(prog)s seed-data/Machine_List.xlsx -o seed-data/assets.db\n"
" %(prog)s seed-data/Machine_List.xlsx -o seed-data/assets.db --mode=update\n"
),
)
parser.add_argument(
"input",
type=str,
help="Path to the input Excel file (Machine_List.xlsx)",
)
parser.add_argument(
"-o", "--output",
type=str,
default="seed-data/assets.db",
help="Output SQLite database path (default: seed-data/assets.db)",
)
parser.add_argument(
"-m", "--mode",
type=str,
choices=["seed", "update"],
default="seed",
help="Import mode: 'seed' for first import (fresh insert), 'update' for incremental (default: seed)",
)
args = parser.parse_args()
# Resolve input path
input_path = os.path.abspath(args.input)
if not os.path.exists(input_path):
print(f"❌ Error: Input file not found: {input_path}")
sys.exit(1)
output_path = os.path.abspath(args.output)
print("🌱 Canteen Seed Data Import Tool")
print(" Coming soon — pipeline modules pending.")
sys.exit(0)
print("" * 50)
print(f" 📄 Input: {input_path}")
print(f" 📁 Output: {output_path}")
print(f" 🔧 Mode: {args.mode}")
print()
# ── Step 1: Parse Excel ──
print("📋 Parsing Excel file...")
try:
machines = parse_excel(input_path)
except Exception as e:
print(f"❌ Error parsing Excel file: {e}")
sys.exit(1)
print(f" ✅ Parsed {len(machines)} machines from Excel")
print()
# ── Step 2: Write to database ──
print("💾 Writing to database...")
if args.mode == "seed":
stats = write_seed(machines, output_path)
else:
stats = write_update(machines, output_path)
print()
print("" * 50)
print("✅ Import complete!")
print(f" Mode: {args.mode}")
print(f" Records: {stats.get('inserted', 0) + stats.get('updated', 0)} processed")
if "inserted" in stats:
print(f" Inserted: {stats['inserted']}")
if "updated" in stats:
print(f" Updated: {stats['updated']}")
if "preserved" in stats:
print(f" Preserved: {stats['preserved']}")
if "errors" in stats and stats["errors"]:
print(f" Errors: {stats['errors']}")
print("" * 50)
return 0
if __name__ == "__main__":
main()
sys.exit(main())
+1474
View File
File diff suppressed because it is too large Load Diff
+293
View File
@@ -0,0 +1,293 @@
#!/usr/bin/env python3
"""
📊 Canteen Seed Import — Validation Report Generator
Parses the Machine_List.xlsx (1,848 machines) and generates a comprehensive
markdown validation report at seed-data/validation_report.md.
Usage:
python3 reporter.py seed-data/Machine_List.xlsx
"""
import sys
import re
import os
from collections import Counter, defaultdict
import openpyxl
from parser import parse_excel, OCR_CORRECTIONS
# ─── OCR Correction Detection ────────────────────────────────────────────────
def _clean_raw(raw):
"""Strip non-alphanumeric characters (same as parse.valid_serial)."""
if raw is None:
return ""
return re.sub(r'[^a-zA-Z0-9]', '', str(raw).strip())
def _ocr_correct(cleaned):
"""Apply OCR character corrections."""
return ''.join(OCR_CORRECTIONS.get(ch, ch) for ch in cleaned)
# ─── Report Generation ───────────────────────────────────────────────────────
def generate_report(filepath):
"""Parse the Excel and produce the full markdown validation report."""
# ── Parse ────────────────────────────────────────────────────────────
machines = parse_excel(filepath)
total = len(machines)
# ── Also read raw serials from xlsx for OCR correction detection ─────
wb = openpyxl.load_workbook(filepath, data_only=True)
ws = wb.active
raw_serials = {}
# Column 38 = "Serial Number" (1-based)
serial_col = 38
for row_idx in range(2, ws.max_row + 1):
asset_id = str(ws.cell(row_idx, 3).value or "").strip()
raw_ser = ws.cell(row_idx, serial_col).value
raw_serials[asset_id] = raw_ser
wb.close()
# ── Compute all stats ────────────────────────────────────────────────
# By class
class_counts = Counter(m.get("class", "Unknown") for m in machines)
# By make
make_counts = Counter(m.get("make", "Unknown") for m in machines)
# By Disney property
disney_machines = [m for m in machines if m.get("disney_park")]
disney_prop_counts = Counter(m["disney_park"] for m in disney_machines)
# Invalid / placeholder serials
invalid_serials = []
placeholder_serials = []
for m in machines:
mid = m.get("machine_id", "???")
if m.get("serial_is_placeholder"):
placeholder_serials.append(mid)
elif not m.get("serial_is_valid"):
invalid_serials.append(mid)
# OCR corrections applied
ocr_fixed = []
for m in machines:
mid = m.get("machine_id", "???")
raw_ser = raw_serials.get(mid)
cleaned = _clean_raw(raw_ser)
if cleaned:
corrected = _ocr_correct(cleaned)
if cleaned != corrected:
ocr_fixed.append((mid, cleaned, corrected))
# Unknown Type/Class/Make/Model
unknown_type = []
for m in machines:
mid = m.get("machine_id", "???")
cls = m.get("class", "")
make = m.get("make", "")
model = m.get("model", "")
if cls == "Unknown" or make == "Unknown" or model == "Unknown":
unknown_type.append((mid, cls, make, model))
# Machines lacking any GPS (no address, city, state, postal code)
no_gps = []
for m in machines:
mid = m.get("machine_id", "???")
addr = (m.get("address") or "").strip()
city = (m.get("location_area") or "").strip()
state = (m.get("state") or "").strip()
zipc = (m.get("postal_code") or "").strip()
if not addr and not city and not state and not zipc:
no_gps.append(mid)
# Priority distribution
priority_counts = Counter(m.get("priority", "Unknown") for m in machines)
# Alert severity breakdown
severity_counts = Counter()
for m in machines:
alerts = m.get("alerts", {})
if isinstance(alerts, dict):
sev = alerts.get("severity", "none")
else:
sev = "none"
severity_counts[sev] += 1
# Telemetry provider breakdown
telem_counts = Counter(m.get("telemetry_provider") or "None" for m in machines)
# ── Build Report ─────────────────────────────────────────────────────
lines = []
lines.append("# 🛠️ Validation Report — Canteen Seed Import")
lines.append("")
lines.append(f"**Generated:** {__import__('datetime').datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
lines.append(f"**Source:** `{os.path.basename(filepath)}`")
lines.append(f"**Total Machines:** {total}")
lines.append("")
lines.append("---")
lines.append("")
# ── 1. Total by Class ────────────────────────────────────────────────
lines.append("## 1. Machine Count by Class")
lines.append("")
lines.append(f"| Class | Count |")
lines.append(f"|-------|-------|")
for cls in ["Snack", "Bev", "Food"]:
c = class_counts.get(cls, 0)
lines.append(f"| {cls} | {c} |")
for cls, c in class_counts.most_common():
if cls not in ("Snack", "Bev", "Food"):
lines.append(f"| {cls} | {c} |")
lines.append("")
lines.append(f"**Subtotal (Snack/Bev/Food):** {sum(class_counts.get(c, 0) for c in ['Snack','Bev','Food'])}")
lines.append("")
# ── 2. By Make ───────────────────────────────────────────────────────
lines.append("## 2. Machine Count by Make")
lines.append("")
lines.append(f"| Make | Count |")
lines.append(f"|------|-------|")
for make, c in make_counts.most_common():
lines.append(f"| {make} | {c} |")
lines.append("")
# ── 3. By Disney Property ────────────────────────────────────────────
lines.append("## 3. Machine Count by Disney Property")
lines.append("")
lines.append(f"**Total Disney machines:** {len(disney_machines)}")
lines.append("")
lines.append(f"| Disney Property | Count |")
lines.append(f"|-----------------|-------|")
for prop, c in disney_prop_counts.most_common():
lines.append(f"| {prop} | {c} |")
lines.append("")
# ── 4. Invalid / Placeholder Serials ─────────────────────────────────
lines.append("## 4. Invalid / Placeholder Serials")
lines.append("")
lines.append(f"**Placeholder serials:** {len(placeholder_serials)}")
if placeholder_serials:
lines.append("")
lines.append("Machine IDs with placeholder serials:")
for mid in sorted(placeholder_serials):
lines.append(f"- `{mid}`")
lines.append("")
lines.append(f"**Invalid serials:** {len(invalid_serials)}")
if invalid_serials:
lines.append("")
lines.append("Machine IDs with invalid serials:")
for mid in sorted(invalid_serials):
lines.append(f"- `{mid}`")
lines.append("")
# ── 5. OCR Corrections Applied ───────────────────────────────────────
lines.append("## 5. OCR Corrections Applied")
lines.append("")
lines.append(f"**Serials corrected:** {len(ocr_fixed)}")
if ocr_fixed:
lines.append("")
lines.append("| Machine ID | Raw (cleaned) | Corrected |")
lines.append("|------------|---------------|-----------|")
for mid, raw_c, corr in sorted(ocr_fixed, key=lambda x: x[0]):
lines.append(f"| `{mid}` | `{raw_c}` | `{corr}` |")
lines.append("")
# ── 6. Unknown Type/Class/Make/Model ─────────────────────────────────
lines.append("## 6. Unknown Type / Class / Make / Model")
lines.append("")
lines.append(f"**Machines with unknowns:** {len(unknown_type)}")
if unknown_type:
lines.append("")
lines.append("| Machine ID | Class | Make | Model |")
lines.append("|------------|-------|------|-------|")
for mid, cls, make, model in sorted(unknown_type, key=lambda x: x[0]):
lines.append(f"| `{mid}` | {cls} | {make} | {model} |")
lines.append("")
# ── 7. Machines Lacking Any GPS ──────────────────────────────────────
lines.append("## 7. Machines Lacking Any GPS / Location Data")
lines.append("")
lines.append(f"**Machines with no address data:** {len(no_gps)}")
if no_gps:
lines.append("")
lines.append("Machine IDs:")
for mid in sorted(no_gps):
lines.append(f"- `{mid}`")
lines.append("")
# ── 8. Priority Distribution ─────────────────────────────────────────
lines.append("## 8. Priority Distribution")
lines.append("")
lines.append(f"| Priority | Count |")
lines.append(f"|----------|-------|")
for pri in ["Low", "Mid", "High", "Unknown"]:
c = priority_counts.get(pri, 0)
lines.append(f"| {pri} | {c} |")
lines.append("")
# ── 9. Alert Severity Breakdown ──────────────────────────────────────
lines.append("## 9. Alert Severity Breakdown")
lines.append("")
lines.append(f"| Severity | Count |")
lines.append(f"|----------|-------|")
for sev in ["critical", "info", "none"]:
c = severity_counts.get(sev, 0)
lines.append(f"| {sev} | {c} |")
lines.append("")
# ── 10. Telemetry Provider Breakdown ─────────────────────────────────
lines.append("## 10. Telemetry Provider Breakdown")
lines.append("")
lines.append(f"| Provider | Count |")
lines.append(f"|----------|-------|")
for prov, c in telem_counts.most_common():
lines.append(f"| {prov} | {c} |")
lines.append("")
lines.append("---")
lines.append("")
lines.append("*Report generated automatically by `reporter.py`*")
lines.append("")
return "\n".join(lines)
# ─── CLI Entry Point ────────────────────────────────────────────────────────
def main():
if len(sys.argv) < 2:
print("Usage: python3 reporter.py seed-data/Machine_List.xlsx")
sys.exit(1)
filepath = sys.argv[1]
if not os.path.exists(filepath):
print(f"❌ File not found: {filepath}")
sys.exit(1)
print(f"📄 Loading {filepath}...")
report = generate_report(filepath)
out_dir = os.path.dirname(os.path.abspath(filepath))
out_path = os.path.join(out_dir, "validation_report.md")
with open(out_path, "w") as f:
f.write(report)
print(f"✅ Report written to {out_path}")
print(f" {len(report)} bytes")
# Print a quick summary to stdout
import re as _re
total_match = _re.search(r'\*\*Total Machines:\*\* (\d+)', report)
if total_match:
print(f"\n📊 Summary: {total_match.group(1)} machines analyzed.")
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
+615
View File
@@ -0,0 +1,615 @@
# reference_handbook
# 🌱 Seed Data Import — Reference Handbook
> This handbook documents all extraction rules, logic, and reference data
for the canteen seed data import pipeline.
>
>
> Sections marked **FILL_IN** need your domain expertise.
> Sections marked **✅ READY** Ive pre-filled from data analysis.
>
---
## 1. Customer Column → Company/Domain
**Status: ✅ READY**
The `Customer` column in the seed data maps directly to the `company` field in the database.
Its already clean — just the business name. No parsing needed.
### Disney Customers (prefix `D-`)
Customers starting with `D-` are Disney properties. These map to `disney_park`:
| Customer prefix / pattern | Disney Park | Category |
| --- | --- | --- |
| `D-Magic Kingdom` | `magic-kingdom` | Park |
| `D-Epcot` | `epcot` | Park |
| `D-Hollywood Studios` | `hollywood-studios` | Park |
| `D-Animal Kingdom` | `animal-kingdom` | Park |
| `D-Disney Springs` | `disney-springs` | Park |
| `D-ContemporaryHotel` | `resort` | Resort |
| `D-POLYNESIAN RESORT` | `resort` | Resort |
| `D-Port Orleans` | `resort` | Resort |
| `D-CORONADO SPRINGS` | `resort` | Resort |
| `D-ART OF ANIMATION` | `resort` | Resort |
| `D-POP CENTURY` | `resort` | Resort |
| `D-All Star ...` | `resort` | Resort |
| `D-DISNEY WORLD SS` | `office` | Support services |
**FILL_IN**: Add any Disney customer patterns I missed:
-
-
---
## 2. Place Column → Location Fields
**Status: ✅ READY** (patterns identified)
The `Place` column has **two main patterns** — the extraction engine must detect which pattern applies and parse accordingly.
### Pattern A: Simple — ~90% of rows
**Format:** `Venue Name-Venue Detail` (dash-separated, repetitive)
**Examples:**
```
Sygma - Vending-Sygma - Vending Breakroom
Imperial Dade-Imperial Dade- Breakroom
Ancora Apt.-Ancora Apt. Breakroom
Delamarre-Delamarre Breakroom
BMW Service-BMW Breakroom
Kisselback Ford-Kisselback Ford Breakroom
Sam's Club-Sam's Club
BREAK AREA-BREAK AREA
Building 1180-Building 1180
```
**Rule:** Take the last meaningful segment (after the last dash). Strip:
- `Breakroom`, `Br`, `Break Room`, `BREAKROOM`, `BREAK AREA`
- `Vending Area`, `Vending`
- Trailing whitespace and punctuation
### Pattern B: Complex Disney — ~10% of rows
**Format:** `PersonName - Address-G-Zone FLOOR`
**Examples:**
```
Todd J - 901 Tinberline Dr-G-REAR 6TH FL
Carrianne C - 1251 Riversida Dr-G-PO RIVERSIDE 80S
Justin M - 1536 Buena Vista-Team Disney North Win
Penny D - 3520 Ft Wilderness-G-BUS STOP
Sarah W - 4401 Floridian Way-G-BUILDING 9 LOBBY
Brenda G - 2101 Epcot Resorts-Hotel by Room 1290
Jeremy B - 1960 Broadway-2nd FL Brkrm Vacat Club D
```
**Extraction steps:**
1. Strip person name prefix: `<FirstName> <Initial>` or `<FirstName> <LastInit> -`
2. Strip address segment (number + street name)
3. Extract floor info: `NTH FL`, `Nth floor`, `FL N`, `1ST FL`, etc.
1. Extract Building info: `B N`, `Bldg N`, `Mermaid N`, etc
2. Whatever remains is the clean `place` name
### Edge Cases
```
FL8108-Vending Area → place="FL8108", no venue
1st FL Wing-1st Fl Breakroom → floor="1st Fl", place="Wing"
Epcot-Imagination → place="Imagination"
C-MK EASTGATE SECURITY-Be Our Guest → place="Be Our Guest"
Southern Tech University:Sun Life-Sun Life → place="Sun Life" (remove university prefix)
```
### Floor Extraction Patterns
| Pattern | Example | Normalized |
| --- | --- | --- |
| `NTH FL` | `6TH FL` | `6th Floor` |
| `Nth floor` | `7th floor` | `7th Floor` |
| `FL N` | `FL 2` | `Floor 2` |
| `NST FL` | `1ST FL` | `1st Floor` |
**FILL_IN**: Add any special Place patterns or corrections you know about:
-
-
-
---
## 3. GPS Derivation for Multi-Floor Machines
**Status: ⚠️ NEEDS FILL_IN**
### Base GPS Strategy
For machines with no GPS data, we need to derive coordinates:
1. Use existing GPS from other machines at the same address
### Floor Offset Rules
When a base GPS coordinate is found for the building, adjust for floor:
**FILL_IN**: What offset values should we use per floor?
- Does the lat/lng change measurably per floor? No, Machines are usually in the same area directly above. Usually, not always
### GPS Priority
When multiple machines exist at the same address:
1. Use existing GPS from the DB (field-collected is authoritative)
2. Look for other machines on the same floor, on floors above or below, and in the same building
3. Never overwrite existing GPS (preserve policy)
---
## 4. Serial Number Validation & OCR Correction
**Status: ✅ READY** (data analyzed)
### Standard Serial Formats
| Format | Count | Example | Notes |
| --- | --- | --- | --- |
| All digits (10-12 chars) | 1,220 | `168020939`, `471011958` | Crane standard format |
| Starts with `1` (10+ digits) | 175 | `1-16018993`, `112034419` | AMS/Crane prefixed |
| Starts with `222` | various | `222001280013` | Newer Crane format |
| Crane model-prefixed | various | `472-052119`, `449-011276` | Model + serial |
| All digits shorter | various | `124473010109` | USI/Mercato format |
### Likely OCR Errors Found
| Machine ID | Raw Serial | Suspected Correct | Pattern |
| --- | --- | --- | --- |
| 60017 | `15S491811573` | `155491811573` | `S``5` |
| 60025 | `15S491811581` | `155491811581` | `S``5` |
| 60028 | `I5s331807595` | `155331807595` | `I``1`, `s``5` |
| 60032 | `I5SS331807593` | `1555331807593` | `I5SS``1555` |
### Known Character Confusions
| Mistyped | Should Be | Context |
| --- | --- | --- |
| `S` | `5` | In numeric serials — S looks like 5 in some fonts |
| `s` | `5` | Same — lowercase s scanned as 5 |
| `I` | `1` | Capital I looks like 1 |
| `O` | `0` | Letter O vs zero |
| `B` | `8` | B scanned as 8 (less common) |
| `G` | `6` | G scanned as 6 (less common) |
**FILL_IN**: Add any other character confusions youve seen:
- ___________ → ___________
- ___________ → ___________
### Placeholder / Invalid Serials
| Machine ID | Serial | Action |
| --- | --- | --- |
| 60031 | `520` | Flag — likely incomplete or placeholder |
| 68194 | `123` | Flag — clear placeholder |
| 99660 | `00` | Flag — clear placeholder |
**FILL_IN**: How should we handle flagged serials? clear it.
---
### Unknown Machine Identification by Serial
**FILL_IN**: How can we identify an unknown machine from its serial number?
- What do different serial prefixes mean? We can look at known good serials and compare with makes/models to determine structure
- Can we cross-reference with Make/Model/Type? We look at known good machines, and look at their serials to figure out structure
- Any known serial → machine type mappings?
---
---
## 5. Type/Class/Make/Model Consolidation
**Status: ✅ READY** (data analyzed)
### The Priority Rule
The `Type` column is the **best source** for Class/Make/Model. Parse it first,
then fill gaps from the standalone columns.
### Type Column Format
> `CLASS (MAKE MODEL)` — e.g., `Snack (AMS Sensit 3)`
>
**Extraction:**
```
"Snack (AMS Sensit 3)"
→ Class = "Snack"
→ Make = "AMS"
→ Model = "Sensit 3"
"GF Food (Crane 472)"
→ Class = "Food"
→ Make = "Crane"
→ Model = "472"
"Bev (Royal GIII)"
→ Class = "Bev"
→ Make = "Royal"
→ Model = "GIII"
"Unknown"
→ All three = "Unknown" (leave as-is)
```
Make sure that if the Class, make, model, etc has a GF prefix then strip it.
### Type Patterns Found
| Type Pattern | Count | Class | Make | Model |
| --- | --- | --- | --- | --- |
| `Bev (Royal GIII)` | 299 | Bev | Royal | GIII |
| `Snack (Crane Merchant Media)` | 287 | Snack | Crane | Merchant Media |
| `Bev (Vendo 621/721/821)` | 235 | Bev | Vendo | 621/721/821 |
| `GF Food (Crane 472)` | 151 | Food | Crane | 472 |
| `Snack (Crane 15x/16x)` | 76 | Snack | Crane | 15x/16x |
| `Unknown` | 67 | Unknown | Unknown | Unknown |
| `GF Bev (DN 200E)` | 49 | Bev | DN | 200E |
| `GF Bev (DN 5800)` | 35 | GF Bev | DN | 5800 |
| `Snack (Crane 186)` | 34 | Snack | Crane | 186 |
| `GF Bev (DN BevMax 4)` | 34 | GF Bev | DN | BevMax 4 |
| `Bev (DN 501E/600E/276E)` | 34 | Bev | DN | 501E/600E/276E |
| `Snack (Crane 472)` | 26 | Snack | Crane | 472 |
| `Bev (DN 276E)` | 25 | Bev | DN | 276E |
| `GF Bev (DN 3800 BevMax 4)` | 20 | GF Bev | DN | 3800 BevMax 4 |
| `GF Bev (DN Baby BevMax)` | 19 | GF Bev | DN | Baby BevMax |
| `Bev (DN 501E)` | 18 | Bev | DN | 501E |
| `GF Bev (DN BevMax)` | 18 | GF Bev | DN | BevMax |
| `Snack/Bev (Crane 472)` | 16 | Snack/Bev | Crane | 472 |
| `Snack (Crane 187)` | 16 | Snack | Crane | 187 |
| `NATIONAL - 168 SERIES` | 16 | Snack | Crane | 168 |
| `VENDO - 721` | 14 | Bev | Vendo | 721 |
| `Snack (AMS 3561)` | 12 | Snack | AMS | 3561 |
| `Snack (USI Mercato)` | 10 | Snack | USI | Mercato |
| `Snack (VE)` | 10 | Snack | VE | Unknown |
| `NATIONAL - 186` | 10 | Snack | Crane | 186 |
| `NATIONAL - 187` | 10 | Snack | Crane | 187 |
### Special Parse Cases
| Raw Type | Rule |
| --- | --- |
| `NATIONAL - 168 SERIES` | Class=Snack, Make=Crane (National=Crane brand), Model=168 |
| `VENDO - 721` | Class=Bev (if Type doesnt have Class), Make=Vendo, Model=721 |
| `Snack` (no parens) | Class=Snack, check Make/Model columns for fill |
| `Bev` (no parens) | Class=Bev, check Make/Model columns |
| `Unknown` | Leave all Unknown |
### Filling Unknowns
After parsing Type, check the standalone `Make` and `Model` columns to fill gaps.
If Type-based Make is empty but standalone Make has a value, use it.
If both are empty/Unknown, flag for review.
**FILL_IN**: Are there any Type→Make/Model mappings Ive missed?
-
-
### Make Normalization
**FILL_IN**: Consolidate these make variants:
| Raw Value | Normalized To |
| --- | --- |
| `Crane`, `Crane Co`, `Crane Nat`, `NATIONAL` | `Crane` |
| `DN`, `Dixie Narco` | `DN` |
| `Royal`, `Royal Vendors` | `Royal` |
| `Vendo`, `Vendo Co` | `Vendo` |
| `USI`, `U Select It` | `USI` |
| `AMS`, `Automatic Merchandising` | `AMS` |
| `VE`, `Vending Equipment` | ___________ |
| `AP`, `Automatic Products` | ___________ |
| `Faz`, `Fas` | ___________ |
### Class Normalization
| Raw Value | Normalized To |
| --- | --- |
| `Snack`, `Snack/Food` | `Snack` |
| `Bev` | `Bev` |
| `GF Food` | `GF Food` |
| `GF Bev` | `GF Bev` |
| `Snack/Bev` | `Snack/Bev` |
| `Food` | `GF Food` — verify this |
**FILL_IN**: Any other class consolidations?
-
---
## 6. Remote Pricing Status — Meanings
**Status: ✅ READY** (data analyzed)
### Distribution
| Status | Count | Meaning |
| --- | --- | --- |
| **On** | 614 | Remote pricing is active and working normally |
| **Action Required** | 718 | Remote pricing needs attention — may have failed, needs configuration, or other issue |
| **Incompatible** | 516 | Machine does not support remote pricing (older model, EEEPROM Upgrade Required) |
**FILL_IN**: Clarify what each status actually means in practice:
**On** — Working
**Action Required** — No Dex Passcode / Bad Coil Count
**Incompatible** — achine does not support remote pricing (older model, EEEPROM Upgrade Required
Are there any other status values not present in this data?
---
---
## 7. Telemetry ID → Card Reader Decoder
**Status: ✅ READY** (data analyzed)
### Telemetry Provider Patterns
| Telemetry ID Pattern | Provider | Card Reader Type |
| --- | --- | --- |
| `VJ... (ePort)` | ePort (USI/Crane) | ePort telemetry module |
| `K3CT... (ePort)` | ePort (Crane) | ePort telemetry module |
| `VK... (ePort)` | ePort | ePort telemetry module |
| `... (NAYAX)` | Nayax | Nayax card reader |
| `... (CMS)` | Crane Merchandising Systems | CMS telemetry/reader |
| All-numeric (no suffix) | Unknown | Unknown/other |
### Distribution
| Provider | Count |
| --- | --- |
| ePort | 924 |
| CMS | 628 |
| Nayax | 275 |
| Other/unknown | 21 |
**FILL_IN**: More detail on what each provider means for card readers:
**ePort** —Looknuo cantaloupe devices_______________________________________________________________
**NAYAX** — look up nayax devices_______________________________________________________________
**CMS** — Crane integrated and external solutions_________________________________________________________________
**FILL_IN**: Do these map to specific credit card reader models?
| Provider | Reader Brand | Reader Model |
| --- | --- | --- |
| ePort | Cantaloupe___________ | ___________ |
| NAYAX | Nayax___________ | ___________ |
| CMS | Crane___________ | ___________ |
---
## 8. Alert Meanings
**Status: ✅ READY** (data analyzed)
### Alert Types Found
| Alert Text | Count | Severity | Meaning |
| --- | --- | --- | --- |
| `(empty)` | majority | none | No alerts |
| `This machine has been scheduled for service on Monday, May 25, 2026` | many | ⚠️ info | Scheduled maintenance |
| `This machine is scheduled for service today` | several | ⚠️ info | Same-day service scheduled |
| `Out of touch for 19 hours - contact Crane Merchandising Systems for assistance` | several | 🔴 critical | Telemetry offline > 19 hours |
| `Out of touch for 19 hours...` (combined with service notice) | few | 🔴 critical | Multiple issues |
**FILL_IN**: Categorize these alerts properly. What severity should each get?
| Alert | Severity (Info/Warning/Critical) | Display in app? |
| --- | --- | --- |
| Scheduled service in future | ___________ | ___________ |
| Scheduled service today | ___________ | ___________ |
| Out of touch (telemetry lost) | ___________ | ___________ |
| Out of touch + service needed | ___________ | ___________ |
**FILL_IN**: Are there other alert patterns that could appear?
---
---
## 9. Sales Priority Scoring
**Status: ⚠️ NEEDS INPUT**
The current `import_seed.csv` has **no sales data columns** — theyre marked as “Deleted” in the header map.
### Available Alternative Signals
Without sales data, we can score priority using:
| Signal | What it tells us | Priority hint |
| --- | --- | --- |
| `Class = "GF Food"` | Perishable food — needs frequent service | Higher priority |
| `Class = "GF Bev"` | Perishable drinks — needs frequent service | Higher priority |
| `Remote Pricing = "Action Required"` | Needs technical attention | Higher priority |
| `Alerts = "Out of touch"` | Telemetry down | Higher priority |
| `Customer = Disney` | High-traffic, high-visibility | Higher priority |
| `Type = "Unknown"` | Unknown machine — needs identification | Lower priority |
**FILL_IN**: How do you want to score priority?
| Criteria | Priority (Low / Mid / High) |
| --- | --- |
| GF Food or GF Bev class | ___________ |
| Action Required pricing | ___________ |
| Critical alert (out of touch) | ___________ |
| Disney property | ___________ |
| Active pricing (On) + Snack class | ___________ |
| Unknown Type/Make/Model | ___________ |
| Incompatible pricing | ___________ |
**FILL_IN**: Do you have sales data from a separate export? Where does it come from?
---
**FILL_IN**: If sales data is available, what are the priority thresholds?
| Annual Sales Range | Priority |
| --- | --- |
| $_______ - $_______ | Low |
| $_______ - $_______ | Mid |
| $_______+ | High |
---
## 10. Asset List & Detail Screen Spec
**Status: ⚠️ NEEDS FILL_IN**
### Asset List Card
Each asset card in the main app list should show:
**FILL_IN**: What fields belong on the asset card?
- [ ] Machine ID
- [ ] Name
- [ ] Make / Model
- [ ] Location (place, building, floor)
- [ ] Priority badge (low/mid/high)
- [ ] Status badge (active/maintenance/retired)
- [ ] Remote Pricing status
- [ ] Disney park badge
- [ ] Alert indicator
- [ ] GPS coordinates badge (has/dont have)
- [ ] Last contact / last dex time
- [ ] Card reader type
- [ ] Other: _______________
**FILL_IN**: Priority badge colors?
| Priority | Color |
| --- | --- |
| Low | ___________ |
| Mid | ___________ |
| High | ___________ |
### Asset Detail Screen
Full detail view should show:
**FILL_IN**: Section layout for the detail screen (order and contents):
1.
2.
3.
4.
5.
6.
**FILL_IN**: Which sections should be editable?
-
-
**FILL_IN**: Should the approval workflow fields be editable here too?
---
---
## 11. Update Policy — Per-Field
**Status: ⚠️ NEEDS FILL_IN**
For each field, specify how updates from new seed data should be handled:
| DB Column | Source CSV Column | Update Policy | Notes |
| --- | --- | --- | --- |
| `machine_id` | Machine ID | preserve | Primary key, never changes |
| `serial_number` | Serial Number | ___________ | |
| `name` | — (generated) | ___________ | |
| `make` | Type→Make (parsed) | ___________ | |
| `model` | Type→Model (parsed) | ___________ | |
| `class`*) | Type→Class (parsed) | ___________ | |
| `company` | Customer | ___________ | |
| `place` | Place (parsed) | ___________ | |
| `building_name` | Place (parsed) | ___________ | |
| `building_number` | Place (parsed) | ___________ | |
| `floor` | Place (parsed) | ___________ | |
| `room` | Place (parsed) | ___________ | |
| `trailer_number` | Place (parsed) | ___________ | |
| `address` | Address | ___________ | |
| `location_area` | City | ___________ | |
| `latitude` | — (derived) | preserve | Field-collected is authoritative |
| `longitude` | — (derived) | preserve | Field-collected is authoritative |
| `photo_path` | — | preserve | Never overwrite |
| `dex_report_date` | Last Dex Report Time | ___________ | |
| `install_date` | Added Date | ___________ | |
| `deployed` | — | ___________ | |
| `pulled_date` | — | ___________ | |
| `disney_park` | Customer (parsed) | ___________ | |
| `remote_pricing_status` | Remote Pricing | ___________ | |
| `alerts` | Alerts | ___________ | |
| `telemetry_provider` | Telemetry ID (parsed) | ___________ | |
| `card_reader_brand` | Telemetry ID (parsed) | ___________ | |
| `card_reader_model` | Telemetry ID (parsed) | ___________ | |
| `priority` | — (scored) | ___________ | |
| `prepick_group` | Prepick Group | ___________ | |
- `class` is not a current DB column — needs to be added via migration.
**Policy values:** `auto` (always overwrite), `approval` (queue for review), `preserve` (never touch)
**FILL_IN**: Add or change any policies above.
---
## Appendix: Column Mapping
### Raw Excel → CSV → DB Mapping
| Xlsx Column | CSV Column | DB Column | Notes |
| --- | --- | --- | --- |
| Device | Telemetry ID | — | Parsed to provider/brand |
| Location | Location | — | Redundant (Customer + Address) |
| Asset ID | Machine ID | `machine_id` | Primary key |
| Place | Place | `place` + derived fields | Main extraction target |
| Type | Type | — | Parsed to Class/Make/Model |
| City | City | `location_area` | Direct map |
| Address | Address | `address` | Direct map |
| Last Contact Time | Last Contact Time | — | Freshness indicator |
| Last Dex Report Time | Last Dex Report Time | `dex_report_date` | |
| Prepick Group | Prepick Group | — | Routing info |
| Customer | Customer | `company` | Direct map |
| Route | Route | — | Routing info |
| State | State | — | |
| Postal Code | Postal Code | — | |
| Serial Number | Serial Number | `serial_number` | Validate + correct |
| Class | Class | — | Source for `class` |
| Make | Make | `make` (fallback) | Type column takes priority |
| Model | Model | `model` (fallback) | Type column takes priority |
| Added Date | Added Date | `install_date` | |
| Status | Remote Pricing | — | Direct to app |
| Alerts | Alerts | — | Parsed to structured alerts |
**FILL_IN**: Any missing columns or corrections?
---
+618
View File
@@ -0,0 +1,618 @@
# 🛠️ Validation Report — Canteen Seed Import
**Generated:** 2026-05-24 21:49:34
**Source:** `Machine_List.xlsx`
**Total Machines:** 1848
---
## 1. Machine Count by Class
| Class | Count |
|-------|-------|
| Snack | 610 |
| Bev | 987 |
| Food | 166 |
| Unknown | 67 |
| Snack/Bev | 18 |
**Subtotal (Snack/Bev/Food):** 1763
## 2. Machine Count by Make
| Make | Count |
|------|-------|
| Crane | 711 |
| DN | 351 |
| Royal | 343 |
| Vendo | 267 |
| Unknown | 114 |
| VE | 25 |
| AMS | 19 |
| USI | 16 |
| AP | 1 |
| Faz | 1 |
## 3. Machine Count by Disney Property
**Total Disney machines:** 994
| Disney Property | Count |
|-----------------|-------|
| Port Orleans Riverside | 60 |
| D-Magic Kingdom | 54 |
| D-Hollywood Studios | 51 |
| D-Coronado Springs | 47 |
| Art of Animation Resort | 46 |
| D-Animal Kingdom | 45 |
| D-Epcot | 44 |
| Disney's Pop Century Resort | 44 |
| D-All Star Movie | 37 |
| Celebration (Admin) | 36 |
| Disney World Support Services | 35 |
| All-Star Sports Resort | 34 |
| All-Star Music Resort | 34 |
| Saratoga Springs Resort & Spa | 32 |
| Disney Support Services | 30 |
| Disney's Polynesian Village Resort | 30 |
| D-Animal Kngdm Lodge | 29 |
| Disney's BoardWalk Inn | 27 |
| Fort Wilderness Resort & Campground | 26 |
| Port Orleans French Quarter | 26 |
| Disney's Wilderness Lodge | 24 |
| D-Caribbean Beach Guest | 24 |
| Disney Springs | 22 |
| Disney's Contemporary Resort | 22 |
| ESPN Wide World of Sports | 19 |
| D-Kidani Village GUEST | 17 |
| Disney's Riviera Resort | 15 |
| D-Gran Destino | 13 |
| Disney's Yacht & Beach Club Resorts | 12 |
| Disney's Grand Floridian Resort | 11 |
| Disney Regional Center | 11 |
| D-Island Tower Polynesian | 8 |
| D-OLD KEY WEST GUEST | 7 |
| D-ESPN 2 CAST | 4 |
| Bay Lake Tower at Contemporary | 4 |
| Treehouse Villas (Saratoga Springs) | 3 |
| D-CARIBBEAN BEACH CAST | 2 |
| D-TYPHOON LAGOON CAST | 2 |
| Blizzard Beach Water Park | 2 |
| Winter Summerland Mini Golf | 2 |
| D-FANTASIA GOLF Guest | 2 |
| D-OLD KEY WEST CAST | 1 |
## 4. Invalid / Placeholder Serials
**Placeholder serials:** 14
Machine IDs with placeholder serials:
- `60031`
- `68194`
- `69148`
- `71369`
- `90009`
- `94132`
- `95337`
- `95418`
- `95433`
- `95435`
- `95440`
- `95441`
- `98271`
- `99660`
**Invalid serials:** 135
Machine IDs with invalid serials:
- `67638`
- `67670`
- `68571`
- `68711`
- `68722`
- `68795`
- `68796`
- `69005`
- `69007`
- `69009`
- `69010`
- `69015`
- `69017`
- `69033`
- `69034`
- `69036`
- `69037`
- `69039`
- `69052`
- `69062`
- `69079`
- `69084`
- `69094`
- `69098`
- `69104`
- `69105`
- `69112`
- `69141`
- `69146`
- `69147`
- `69245`
- `69251`
- `69281`
- `69284`
- `69286`
- `69294`
- `69308`
- `69333`
- `69339`
- `69342`
- `69343`
- `69354`
- `69355`
- `69358`
- `69366`
- `70001`
- `71404`
- `90001`
- `90013`
- `90018`
- `90115`
- `90117`
- `90122`
- `90130`
- `90187`
- `92121`
- `92123`
- `92129`
- `92298`
- `92591`
- `92593`
- `92610`
- `92683`
- `92706`
- `92947`
- `93435`
- `93437`
- `93771`
- `94126`
- `94131`
- `94605`
- `94706`
- `94803`
- `94860`
- `94870`
- `94959`
- `95042`
- `95051`
- `95054`
- `95055`
- `95056`
- `95058`
- `95098`
- `95099`
- `95100`
- `95279`
- `95313`
- `95358`
- `95361`
- `95364`
- `95368`
- `95395`
- `95410`
- `95432`
- `95457`
- `96752`
- `97007`
- `97013`
- `97016`
- `97017`
- `97022`
- `97024`
- `97063`
- `97065`
- `97113`
- `97138`
- `97159`
- `97166`
- `97168`
- `97172`
- `97179`
- `97188`
- `97208`
- `97215`
- `97256`
- `97258`
- `97271`
- `97273`
- `97274`
- `97277`
- `97292`
- `97326`
- `97331`
- `97358`
- `97359`
- `97398`
- `98007`
- `98038`
- `98040`
- `98059`
- `98060`
- `98085`
- `98197`
- `99672`
- `99719`
## 5. OCR Corrections Applied
**Serials corrected:** 184
| Machine ID | Raw (cleaned) | Corrected |
|------------|---------------|-----------|
| `60017` | `15S491811573` | `155491811573` |
| `60018` | `14S192104132` | `145192104132` |
| `60025` | `15S491811581` | `155491811581` |
| `60028` | `I5s331807595` | `155331807595` |
| `60032` | `I5SS331807593` | `1555331807593` |
| `69231` | `l200011201` | `1200011201` |
| `69282` | `112B01074049` | `112801074049` |
| `71369` | `pending` | `pendin6` |
| `90071` | `201735BA00056` | `2017358A00056` |
| `90079` | `28670149BT` | `286701498T` |
| `90083` | `201808BA0020` | `2018088A0020` |
| `90094` | `201915BA00062` | `2019158A00062` |
| `90097` | `201834BA00014` | `2018348A00014` |
| `90098` | `201746BA00801` | `2017468A00801` |
| `90119` | `69600142BC` | `696001428C` |
| `90129` | `200050BA00076` | `2000508A00076` |
| `90139` | `201915BA00027` | `2019158A00027` |
| `90140` | `201221BA00307` | `2012218A00307` |
| `90141` | `201132BA00307` | `2011328A00307` |
| `90526` | `201706BA00029` | `2017068A00029` |
| `92852` | `200844BA00008` | `2008448A00008` |
| `92853` | `200850BA00060` | `2008508A00060` |
| `92858` | `200844BA0007` | `2008448A0007` |
| `92912` | `200948BA00252` | `2009488A00252` |
| `92935` | `1509BL02978` | `15098L02978` |
| `93052` | `20115BA00199` | `201158A00199` |
| `93177` | `201210BA00178` | `2012108A00178` |
| `93217` | `202043BA00518` | `2020438A00518` |
| `93358` | `200226BA00220` | `2002268A00220` |
| `93360` | `199944BA02302` | `1999448A02302` |
| `93361` | `20303BA00619` | `203038A00619` |
| `93395` | `20124BA00132` | `201248A00132` |
| `93399` | `200108BA00354` | `2001088A00354` |
| `93533` | `199944ba00309` | `1999448a00309` |
| `93535` | `200122BA00429` | `2001228A00429` |
| `93551` | `201423BA00152` | `2014238A00152` |
| `93580` | `201523BA00086` | `2015238A00086` |
| `93630` | `20119BA00322` | `201198A00322` |
| `93786` | `30130092AO` | `30130092A0` |
| `93794` | `201706BA0039` | `2017068A0039` |
| `93796` | `201706BA00066` | `2017068A00066` |
| `94106` | `201026BA00093` | `2010268A00093` |
| `94114` | `201430BA00068` | `2014308A00068` |
| `94116` | `201243BA00158` | `2012438A00158` |
| `94118` | `01586206CS` | `01586206C5` |
| `94125` | `057666700BZ` | `0576667008Z` |
| `94127` | `200949BA00333` | `2009498A00333` |
| `94128` | `20030BA00176` | `200308A00176` |
| `94129` | `200307BA00600` | `2003078A00600` |
| `94131` | `1293776G` | `12937766` |
| `94136` | `201735BA00040` | `2017358A00040` |
| `94730` | `20114BA0060` | `201148A0060` |
| `94814` | `201222BA00084` | `2012228A00084` |
| `94823` | `201844BA00003` | `2018448A00003` |
| `94833` | `201433BA00308` | `2014338A00308` |
| `94886` | `201832BA00054` | `2018328A00054` |
| `94890` | `201832ba00060` | `2018328a00060` |
| `94891` | `201832ba00034` | `2018328a00034` |
| `94933` | `68800487BB` | `6880048788` |
| `94942` | `201037BA00032` | `2010378A00032` |
| `94957` | `1518BL02744` | `15188L02744` |
| `95033` | `200938BA00111` | `2009388A00111` |
| `95046` | `200251BA00995` | `2002518A00995` |
| `95064` | `201748BA00015` | `2017488A00015` |
| `95068` | `201049BA00006` | `2010498A00006` |
| `95069` | `200227BA00045` | `2002278A00045` |
| `95081` | `201222BA00079` | `2012228A00079` |
| `95082` | `200110BA00078` | `2001108A00078` |
| `95090` | `200042BA00218` | `2000428A00218` |
| `95091` | `201207BA00028` | `2012078A00028` |
| `95092` | `1522BL02325` | `15228L02325` |
| `95094` | `200050BA00119` | `2000508A00119` |
| `95095` | `201140BA00523` | `2011408A00523` |
| `95096` | `20112BA00044` | `201128A00044` |
| `95100` | `201037BA` | `2010378A` |
| `95117` | `200706BA00034` | `2007068A00034` |
| `95122` | `201210BA00140` | `2012108A00140` |
| `95123` | `201623BA00227P` | `2016238A00227P` |
| `95124` | `201433BA00249` | `2014338A00249` |
| `95138` | `201033BA00094` | `2010338A00094` |
| `95139` | `1518BL01680` | `15188L01680` |
| `95143` | `200837BA00101` | `2008378A00101` |
| `95144` | `1200118BA00500` | `12001188A00500` |
| `95148` | `201029BA00081` | `2010298A00081` |
| `95149` | `201717BA00017` | `2017178A00017` |
| `95150` | `201148BA00467` | `2011488A00467` |
| `95151` | `201204BA00041` | `2012048A00041` |
| `95152` | `201037BA00076` | `2010378A00076` |
| `95153` | `201624BA00092` | `2016248A00092` |
| `95154` | `201915BA00052` | `2019158A00052` |
| `95155` | `200225BA01283` | `2002258A01283` |
| `95156` | `200225BA012` | `2002258A012` |
| `95161` | `2001444BA00106` | `20014448A00106` |
| `95162` | `200209BA00806` | `2002098A00806` |
| `95165` | `201744BA00021` | `2017448A00021` |
| `95170` | `201243BA00531` | `2012438A00531` |
| `95171` | `201833BA00014` | `2018338A00014` |
| `95173` | `201147BA00021` | `2011478A00021` |
| `95183` | `201207BA00104` | `2012078A00104` |
| `95184` | `201133BA00049` | `2011338A00049` |
| `95214` | `200938BA00122` | `2009388A00122` |
| `95215` | `1482BK01614` | `14828K01614` |
| `95216` | `200120BA00012` | `2001208A00012` |
| `95217` | `201037BA00098` | `2010378A00098` |
| `95247` | `201122BA0010` | `2011228A0010` |
| `95254` | `201140BA00512` | `2011408A00512` |
| `95256` | `200225BA01301` | `2002258A01301` |
| `95257` | `201029BA00166` | `2010298A00166` |
| `95271` | `201037BA0030` | `2010378A0030` |
| `95276` | `201746BA00058` | `2017468A00058` |
| `95337` | `S` | `5` |
| `95397` | `201735BA00109` | `2017358A00109` |
| `95398` | `201833BA00021` | `2018338A00021` |
| `95399` | `201748BA00014` | `2017488A00014` |
| `95400` | `201807BA00022` | `2018078A00022` |
| `95401` | `201832BA00030` | `2018328A00030` |
| `95402` | `201748B00046` | `201748800046` |
| `95403` | `201746BA000109` | `2017468A000109` |
| `95405` | `201832BA00015` | `2018328A00015` |
| `97018` | `1381B12669` | `1381812669` |
| `97028` | `76820565BD` | `768205658D` |
| `97029` | `68980319BB` | `6898031988` |
| `97040` | `68870563BB` | `6887056388` |
| `97052` | `0876666ODY` | `08766660DY` |
| `97055` | `08006617BY` | `080066178Y` |
| `97082` | `68080567BA` | `680805678A` |
| `97084` | `52060152BK` | `520601528K` |
| `97086` | `200048BA00073` | `2000488A00073` |
| `97087` | `24110016BN` | `241100168N` |
| `97095` | `69030300CB` | `69030300C8` |
| `97099` | `06196697BZ` | `061966978Z` |
| `97102` | `201207BA00098` | `2012078A00098` |
| `97104` | `201836BA00016` | `2018368A00016` |
| `97111` | `201706BA00026` | `2017068A00026` |
| `97124` | `69000450CB` | `69000450C8` |
| `97125` | `69000449CB` | `69000449C8` |
| `97126` | `68780229BB` | `6878022988` |
| `97128` | `69000125CB` | `69000125C8` |
| `97142` | `200042BA00376` | `2000428A00376` |
| `97145` | `201204BA00060` | `2012048A00060` |
| `97158` | `084D6157BX` | `084D61578X` |
| `97161` | `201526BA00159` | `2015268A00159` |
| `97171` | `24120073BN` | `241200738N` |
| `97218` | `10496607BY` | `104966078Y` |
| `97219` | `200043BA00809` | `2000438A00809` |
| `97221` | `20144BA00304` | `201448A00304` |
| `97222` | `30376500BW` | `303765008W` |
| `97223` | `15086793BA` | `150867938A` |
| `97237` | `69620140BC` | `696201408C` |
| `97240` | `77270226BE` | `772702268E` |
| `97246` | `69700435BC` | `697004358C` |
| `97265` | `200225BA01086` | `2002258A01086` |
| `97268` | `52060005BK` | `520600058K` |
| `97280` | `50890236DI` | `50890236D1` |
| `97285` | `76920191BD` | `769201918D` |
| `97291` | `201744BA00052` | `2017448A00052` |
| `97295` | `69010283CB` | `69010283C8` |
| `97307` | `07856700BZ` | `078567008Z` |
| `97320` | `6891076BB` | `689107688` |
| `97330` | `200050BA0018` | `2000508A0018` |
| `97344` | `24120064BN` | `241200648N` |
| `97346` | `200323ba00511` | `2003238a00511` |
| `97355` | `201444ba00201` | `2014448a00201` |
| `97374` | `28806471BW` | `288064718W` |
| `97381` | `07866614BY` | `078666148Y` |
| `98056` | `23166489BW` | `231664898W` |
| `98062` | `86890056AG` | `86890056A6` |
| `98074` | `201836BA00010` | `2018368A00010` |
| `98076` | `200209BA00134` | `2002098A00134` |
| `98077` | `201915BA00067` | `2019158A00067` |
| `98080` | `200220BA00544` | `2002208A00544` |
| `98104` | `69700369BC` | `697003698C` |
| `98110` | `69620270BC` | `696202708C` |
| `98130` | `76830252B0` | `7683025280` |
| `98132` | `69620206BC` | `696202068C` |
| `98140` | `68450456AB` | `68450456A8` |
| `98163` | `15266532BX` | `152665328X` |
| `98164` | `19876551BX` | `198765518X` |
| `98192` | `69100439CB` | `69100439C8` |
| `98194` | `76820625BD` | `768206258D` |
| `98195` | `69600228BC` | `696002288C` |
| `98196` | `69700313BC` | `697003138C` |
| `98198` | `69680622BC` | `696806228C` |
| `99704` | `94310050BJ` | `943100508J` |
## 6. Unknown Type / Class / Make / Model
**Machines with unknowns:** 143
| Machine ID | Class | Make | Model |
|------------|-------|------|-------|
| `60015` | Unknown | Unknown | Unknown |
| `60017` | Snack | Unknown | Unknown |
| `60018` | Unknown | Unknown | Unknown |
| `60021` | Snack | USI | Unknown |
| `60025` | Snack | Unknown | Unknown |
| `60028` | Snack | Unknown | Unknown |
| `60031` | Snack | Unknown | Unknown |
| `60032` | Snack | Unknown | Unknown |
| `65536` | Snack | VE | Unknown |
| `67529` | Snack | Unknown | Unknown |
| `68617` | Snack | Unknown | Unknown |
| `68653` | Unknown | Unknown | Unknown |
| `68654` | Snack | Crane | Unknown |
| `68664` | Unknown | Unknown | Unknown |
| `68699` | Snack | Unknown | Unknown |
| `69008` | Snack | Unknown | Unknown |
| `69012` | Snack | Unknown | Unknown |
| `69032` | Snack | Unknown | Unknown |
| `69039` | Snack | VE | Unknown |
| `69070` | Snack | VE | Unknown |
| `69089` | Snack | Unknown | Unknown |
| `69102` | Snack | VE | Unknown |
| `69104` | Snack | VE | Unknown |
| `69146` | Snack | VE | Unknown |
| `69231` | Snack | VE | Unknown |
| `69235` | Snack | Unknown | Unknown |
| `69280` | Snack | VE | Unknown |
| `69287` | Snack | VE | Unknown |
| `69296` | Snack | Unknown | Unknown |
| `69308` | Snack | VE | Unknown |
| `69332` | Snack | Faz | Unknown |
| `69342` | Snack | VE | Unknown |
| `69357` | Snack | Unknown | Unknown |
| `69360` | Snack | Unknown | Unknown |
| `90003` | Unknown | Unknown | Unknown |
| `90010` | Unknown | Unknown | Unknown |
| `90042` | Unknown | Unknown | Unknown |
| `90048` | Unknown | Unknown | Unknown |
| `90053` | Unknown | Unknown | Unknown |
| `90054` | Unknown | Unknown | Unknown |
| `90055` | Unknown | Unknown | Unknown |
| `90074` | Bev | Vendo | Unknown |
| `90090` | Unknown | Unknown | Unknown |
| `90109` | Unknown | Unknown | Unknown |
| `90160` | Bev | Unknown | Unknown |
| `90162` | Bev | Unknown | Unknown |
| `90166` | Unknown | Unknown | Unknown |
| `90168` | Unknown | Unknown | Unknown |
| `90170` | Unknown | Unknown | Unknown |
| `90172` | Unknown | Unknown | Unknown |
| `90175` | Bev | Unknown | Unknown |
| `90176` | Bev | Unknown | Unknown |
| `90177` | Bev | Unknown | Unknown |
| `90178` | Bev | Unknown | Unknown |
| `90183` | Bev | Royal | Unknown |
| `90184` | Unknown | Unknown | Unknown |
| `90186` | Bev | Unknown | Unknown |
| `90199` | Unknown | Unknown | Unknown |
| `90200` | Unknown | Unknown | Unknown |
| `90205` | Bev | Unknown | Unknown |
| `93177` | Bev | Royal | Unknown |
| `93243` | Bev | DN | Unknown |
| `93399` | Unknown | Unknown | Unknown |
| `93552` | Bev | DN | Unknown |
| `93555` | Bev | DN | Unknown |
| `94121` | Bev | Royal | Unknown |
| `94706` | Bev | DN | Unknown |
| `94910` | Unknown | Unknown | Unknown |
| `95033` | Bev | Royal | Unknown |
| `95084` | Unknown | Unknown | Unknown |
| `95090` | Unknown | Unknown | Unknown |
| `95097` | Unknown | Unknown | Unknown |
| `95150` | Snack | VE | Unknown |
| `95256` | Unknown | Unknown | Unknown |
| `95324` | Unknown | Unknown | Unknown |
| `95330` | Unknown | Unknown | Unknown |
| `95365` | Unknown | Unknown | Unknown |
| `95366` | Unknown | Unknown | Unknown |
| `95367` | Unknown | Unknown | Unknown |
| `95368` | Unknown | Unknown | Unknown |
| `95369` | Unknown | Unknown | Unknown |
| `95370` | Unknown | Unknown | Unknown |
| `95371` | Unknown | Unknown | Unknown |
| `95372` | Unknown | Unknown | Unknown |
| `95373` | Unknown | Unknown | Unknown |
| `95374` | Unknown | Unknown | Unknown |
| `95378` | Unknown | Unknown | Unknown |
| `95379` | Unknown | Unknown | Unknown |
| `95380` | Bev | Royal | Unknown |
| `95381` | Unknown | Unknown | Unknown |
| `95382` | Unknown | Unknown | Unknown |
| `95383` | Unknown | Unknown | Unknown |
| `95384` | Unknown | Unknown | Unknown |
| `95387` | Unknown | Unknown | Unknown |
| `95388` | Unknown | Unknown | Unknown |
| `95389` | Bev | Unknown | Unknown |
| `95390` | Unknown | Unknown | Unknown |
| `95391` | Unknown | Unknown | Unknown |
| `95392` | Unknown | Unknown | Unknown |
| `95414` | Unknown | Unknown | Unknown |
| `95416` | Unknown | Unknown | Unknown |
| `95431` | Unknown | Unknown | Unknown |
| `95434` | Unknown | Unknown | Unknown |
| `95435` | Unknown | Unknown | Unknown |
| `95436` | Unknown | Unknown | Unknown |
| `95498` | Unknown | Unknown | Unknown |
| `96728` | Bev | Unknown | Unknown |
| `97015` | Bev | Unknown | Unknown |
| `97082` | Bev | Unknown | Unknown |
| `97084` | Bev | DN | Unknown |
| `97086` | Bev | Unknown | Unknown |
| `97106` | Bev | Unknown | Unknown |
| `97115` | Bev | Unknown | Unknown |
| `97117` | Bev | Unknown | Unknown |
| `97138` | Bev | DN | Unknown |
| `97187` | Snack | Unknown | Unknown |
| `97188` | Bev | Unknown | Unknown |
| `97268` | Bev | DN | Unknown |
| `97326` | Bev | DN | Unknown |
| `97392` | Bev | Unknown | Unknown |
| `98006` | Unknown | Unknown | Unknown |
| `98023` | Unknown | Unknown | Unknown |
| `98029` | Bev | Unknown | Unknown |
| `98030` | Bev | Unknown | Unknown |
| `98032` | Unknown | Unknown | Unknown |
| `98033` | Unknown | Unknown | Unknown |
| `98034` | Unknown | Unknown | Unknown |
| `98035` | Bev | Unknown | Unknown |
| `98045` | Bev | Unknown | Unknown |
| `98060` | Bev | Unknown | Unknown |
| `98065` | Bev | Unknown | Unknown |
| `98092` | Unknown | Unknown | Unknown |
| `98093` | Unknown | Unknown | Unknown |
| `98094` | Unknown | Unknown | Unknown |
| `98099` | Bev | Unknown | Unknown |
| `98106` | Unknown | Unknown | Unknown |
| `98112` | Bev | Unknown | Unknown |
| `98125` | Bev | Unknown | Unknown |
| `99651` | Bev | Unknown | Unknown |
| `99653` | Bev | Unknown | Unknown |
| `99660` | Bev | Unknown | Unknown |
| `99662` | Unknown | Unknown | Unknown |
| `99688` | Unknown | Unknown | Unknown |
## 7. Machines Lacking Any GPS / Location Data
**Machines with no address data:** 0
## 8. Priority Distribution
| Priority | Count |
|----------|-------|
| Low | 469 |
| Mid | 1163 |
| High | 194 |
| Unknown | 22 |
## 9. Alert Severity Breakdown
| Severity | Count |
|----------|-------|
| critical | 43 |
| info | 1183 |
| none | 622 |
## 10. Telemetry Provider Breakdown
| Provider | Count |
|----------|-------|
| ePort | 924 |
| CMS | 628 |
| NAYAX | 275 |
| AIRVEND | 11 |
| None | 10 |
---
*Report generated automatically by `reporter.py`*