🌱 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:
@@ -18,3 +18,6 @@ Thumbs.db
|
||||
|
||||
# Reports
|
||||
reports/
|
||||
seed-data/assets.db
|
||||
seed-data/Machine_List.xlsx
|
||||
seed-data/*.zip
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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())
|
||||
|
||||
+293
@@ -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()
|
||||
+505
-400
File diff suppressed because it is too large
Load Diff
@@ -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** I’ve 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.
|
||||
It’s 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 you’ve 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 doesn’t 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 I’ve 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** — they’re 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/don’t 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?
|
||||
|
||||
---
|
||||
@@ -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`*
|
||||
Reference in New Issue
Block a user