diff --git a/.gitignore b/.gitignore index 21e3b20..505cf7f 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ Thumbs.db # Reports reports/ +seed-data/assets.db +seed-data/Machine_List.xlsx +seed-data/*.zip diff --git a/backup.py b/backup.py new file mode 100644 index 0000000..6ba3a0c --- /dev/null +++ b/backup.py @@ -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() diff --git a/db_writer.py b/db_writer.py new file mode 100644 index 0000000..27d413d --- /dev/null +++ b/db_writer.py @@ -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 diff --git a/main.py b/main.py index 6469ecb..9105870 100644 --- a/main.py +++ b/main.py @@ -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()) diff --git a/parser.py b/parser.py new file mode 100644 index 0000000..c051807 --- /dev/null +++ b/parser.py @@ -0,0 +1,1474 @@ +#!/usr/bin/env python3 +""" +🌱 Canteen Seed Data Import β€” CSV Parser & Header Mapper + +Reads the raw Cantaloupe Excel export (Machine_List.xlsx, 1,848 machines Γ— 62 columns) +and parses it into structured machine records mapping to DB columns. + +Usage: + from parser import parse_excel, parse_excel_to_json + machines = parse_excel('seed-data/Machine_List.xlsx') + for m in machines[:3]: + print(json.dumps(m, indent=2, default=str)) +""" + +import re +import json +from datetime import datetime + +import openpyxl + + +# ─── Disney Property Mapping (Section 1) ────────────────────────────────────── + +DISNEY_PROPERTY_MAP = { + "D-ART OF ANIMATION": "Art of Animation Resort", + "D-All Star Music": "All-Star Music Resort", + "D-All Star Sports": "All-Star Sports Resort", + "D-BAY LAKE TOWER": "Bay Lake Tower at Contemporary", + "D-BLIZZARD BEACH": "Blizzard Beach Water Park", + "D-Boardwalk": "Disney's BoardWalk Inn", + "D-Celebration": "Celebration (Admin)", + "D-ContemporaryHotel": "Disney's Contemporary Resort", + "D-DISNEY VENDING": "Disney Support Services", + "D-DISNEY WORLD SS": "Disney World Support Services", + "D-DRC": "Disney Regional Center", + "D-Disney Springs": "Disney Springs", + "D-FORT WILDERNESS": "Fort Wilderness Resort & Campground", + "D-GRAND FLORIDIAN": "Disney's Grand Floridian Resort", + "D-POLYNESIAN RESORT": "Disney's Polynesian Village Resort", + "D-POP CENTURY": "Disney's Pop Century Resort", + "D-PORT ORLEANS FrQt": "Port Orleans French Quarter", + "D-Port Orleans Rvsd": "Port Orleans Riverside", + "D-RIVIERA RESORT": "Disney's Riviera Resort", + "D-Saratoga Springs": "Saratoga Springs Resort & Spa", + "D-Treehouse Villas": "Treehouse Villas (Saratoga Springs)", + "D-WIDE WORLD SPORTS": "ESPN Wide World of Sports", + "D-WILDERNESS LODGE": "Disney's Wilderness Lodge", + "D-Winter Summerland": "Winter Summerland Mini Golf", + "D-YACHT AND BEACH": "Disney's Yacht & Beach Club Resorts", +} + +# Additional Disney customer prefix patterns (from data) +DISNEY_CUSTOMER_PREFIXES = [ + "D-All Star Movie", + "D-Animal Kngdm Lodge", + "D-Animal Kingdom", + "D-ART OF ANIMATION", + "D-All Star Music", + "D-All Star Sports", + "D-BAY LAKE TOWER", + "D-BLIZZARD BEACH", + "D-Boardwalk", + "D-Celebration", + "D-ContemporaryHotel", + "D-Coronado Springs", + "D-DISNEY VENDING", + "D-DISNEY WORLD SS", + "D-DRC", + "D-Disney Springs", + "D-Epcot", + "D-FORT WILDERNESS", + "D-GRAND FLORIDIAN", + "D-Gran Destino", + "D-Hollywood Studios", + "D-Magic Kingdom", + "D-POLYNESIAN RESORT", + "D-POP CENTURY", + "D-PORT ORLEANS FrQt", + "D-Port Orleans Rvsd", + "D-RIVIERA RESORT", + "D-Saratoga Springs", + "D-Treehouse Villas", + "D-WIDE WORLD SPORTS", + "D-WILDERNESS LODGE", + "D-Winter Summerland", + "D-YACHT AND BEACH", +] + + +# ─── Make Normalization Table (Section 5) ───────────────────────────────────── + +MAKE_NORMALIZATION = { + "Crane Co": "Crane", + "Crane Nat": "Crane", + "NATIONAL": "Crane", + "Crane National": "Crane", + "Dixie Narco": "DN", + "Royal Vendors": "Royal", + "Vendo Co": "Vendo", + "U Select It": "USI", + "Automatic Merchandising": "AMS", + "Automatic Products": "AP", +} + + +# ─── Known suffixes to strip from Place (Pattern A) ─────────────────────────── + +PLACE_SUFFIXES = [ + "Breakroom", "Br", "Break Room", "BREAKROOM", + "BREAK AREA", "Break Area", + "Vending Area", "Vending", + "Break", + "Breakroom ", # trailing space +] + +# Floor extraction patterns +FLOOR_PATTERNS = [ + (re.compile(r'(\d+)\s*(?:ST|ND|RD|TH)\s*(?:FL|FLOOR)', re.IGNORECASE), lambda m: m.group(1)), + (re.compile(r'(?:FL|FLOOR)\s*(\d+)', re.IGNORECASE), lambda m: m.group(1)), + (re.compile(r'(\d+)\s*[Tt]'), lambda m: str(int(m.group(1)))), # 5T, 6T (Contemporary Tower) + (re.compile(r'(\d+)\s*[Ff][Ll]'), lambda m: m.group(1)), # 2 fl + (re.compile(r'(\d+)[Nn]'), lambda m: str(int(m.group(1)))), # 2N +] + +# Building extraction patterns +BUILDING_PATTERNS = [ + re.compile(r'BLDG\s*#?\s*(\d+(?:\s*\w+)?)', re.IGNORECASE), + re.compile(r'Building\s+(\d+(?:\s*\w+)?)', re.IGNORECASE), + re.compile(r'Bldg\s+(\d+(?:\s*\w+)?)', re.IGNORECASE), + re.compile(r'DAAR\s+(\d+(?:-\d+)?)', re.IGNORECASE), + re.compile(r'TOWER\s+(\d+)', re.IGNORECASE), + re.compile(r'Loop\s+(\d+)', re.IGNORECASE), + re.compile(r'(\w+\s+(?:VILLAS?|BLDG|BUILDING))', re.IGNORECASE), +] + + +# ─── Helper: safe string ────────────────────────────────────────────────────── + +def _s(val): + """Convert value to string, handling None.""" + if val is None: + return "" + return str(val).strip() + + +def _num(val): + """Convert to float or None.""" + if val is None: + return None + try: + return float(val) + except (ValueError, TypeError): + return None + + +def _int_or_none(val): + """Convert to int or None.""" + if val is None: + return None + try: + return int(val) + except (ValueError, TypeError): + return None + + +def _bool_from_yesno(val): + """Convert Yes/No to boolean.""" + if val is None: + return None + s = str(val).strip().lower() + if s in ("yes", "true", "1", "y"): + return True + if s in ("no", "false", "0", "n"): + return False + return None + + +# ─── 1. Device / Telemetry Parser (Section 7) ───────────────────────────────── + +def parse_device(device_val): + """ + Parse Device (Telemetry ID) column. + + Returns dict with telemetry_provider and card_reader_brand. + """ + raw = _s(device_val) + result = { + "telemetry_provider": None, + "card_reader_brand": None, + } + + if not raw: + return result + + # Extract suffix in parentheses + m = re.search(r'\(([^)]+)\)', raw) + if m: + suffix = m.group(1).strip() + suffix_upper = suffix.upper() + + if suffix_upper == "EPORT": + result["telemetry_provider"] = "ePort" + result["card_reader_brand"] = "Cantaloupe" + elif suffix_upper == "NAYAX": + result["telemetry_provider"] = "NAYAX" + result["card_reader_brand"] = "Nayax" + elif suffix_upper == "CMS": + result["telemetry_provider"] = "CMS" + result["card_reader_brand"] = "CMS" + else: + # Unknown suffix + result["telemetry_provider"] = suffix + result["card_reader_brand"] = "Unknown" + else: + # No parentheses - check for known prefixes + if raw.startswith("VJ") or raw.startswith("K3CT") or raw.startswith("VK"): + result["telemetry_provider"] = "ePort" + result["card_reader_brand"] = "Cantaloupe" + elif raw.isdigit(): + result["telemetry_provider"] = "Unknown" + result["card_reader_brand"] = "Unknown" + else: + result["telemetry_provider"] = "Unknown" + result["card_reader_brand"] = "Unknown" + + return result + + +# ─── 2. Type / Class / Make / Model Parser (Section 5) ──────────────────────── + +def normalize_make(make_raw): + """Normalize make to canonical form.""" + if not make_raw: + return None + make = make_raw.strip() + return MAKE_NORMALIZATION.get(make, make) + + +def parse_type(type_val, class_fallback=None, make_fallback=None, model_fallback=None): + """ + Parse Type column into class, make, model, is_glass_front. + + Format: CLASS (MAKE MODEL) + Special: MAKE - MODEL (NATIONAL - 168 SERIES, VENDO - 721, etc.) + + Falls back to standalone Class/Make/Model columns if Type doesn't parse. + """ + raw = _s(type_val) + + is_gf = False + cls = None + make = None + model = None + + if not raw or raw == "Unknown" or raw == "": + # Fallback to standalone columns + cls = _s(class_fallback) if class_fallback else "Unknown" + make = _s(make_fallback) if make_fallback else "Unknown" + model = _s(model_fallback) if model_fallback else "Unknown" + # Check standalone class for GF prefix + if cls and cls.upper().startswith("GF "): + is_gf = True + cls = cls[3:].strip() + return { + "class": cls or "Unknown", + "make": normalize_make(make) or "Unknown", + "model": model or "Unknown", + "is_glass_front": is_gf, + } + + # ── Pattern 1: CLASS (MAKE MODEL) ── + m = re.match(r'^(GF\s+)?(\w+(?:/\w+)?)\s*\(([^)]+)\)$', raw) + if m: + gf_prefix = m.group(1) is not None + cls = m.group(2).strip() + inside = m.group(3).strip() + + # Parse inside: MAKE MODEL (first word = make, rest = model) + parts = inside.split(None, 1) + make = parts[0] if parts else "Unknown" + model = parts[1] if len(parts) > 1 else "Unknown" + + # Handle special cases inside parens + if not parts: + make = "Unknown" + model = "Unknown" + elif len(parts) == 1: + make = parts[0] + model = "Unknown" + else: + make = parts[0] + model = parts[1] + + if gf_prefix: + is_gf = True + + # Check the raw class for GF prefix too + if raw.upper().startswith("GF "): + is_gf = True + cls = raw.split(None, 1)[1].split("(")[0].strip() + else: + # ── Pattern 2: MAKE - MODEL (dash-separated) ── + m2 = re.match(r'^(GF\s+)?(\w+(?:\s+\w+)?)\s*-\s*(.+)$', raw) + if m2: + gf_prefix = m2.group(1) is not None + prefix = m2.group(2).strip().upper() + model_raw = m2.group(3).strip() + + # Map prefix to make + class + prefix_info = _map_dash_prefix(prefix, model_raw, raw) + if prefix_info: + cls = prefix_info["class"] + make = prefix_info["make"] + model = prefix_info["model"] + else: + # Unknown prefix - try standalone columns + cls = _s(class_fallback) if class_fallback else "Unknown" + make = _s(make_fallback) if make_fallback else prefix + model = model_raw + else: + # ── Pattern 3: No parens, no dash ── + # Try standalone columns + cls = _s(class_fallback) if class_fallback else "Unknown" + make = _s(make_fallback) if make_fallback else "Unknown" + model = _s(model_fallback) if model_fallback else "Unknown" + + # Check raw for GF prefix + if raw.upper().startswith("GF "): + is_gf = True + cls = raw[3:].strip() + + # Handle special Type-only values like "Bev", "Snack", "Food" + simple_class_match = re.match(r'^(GF\s+)?(Snack|Bev|Food|Snack/Bev|Unknown)$', raw, re.IGNORECASE) + if simple_class_match: + if simple_class_match.group(1): + is_gf = True + cls = simple_class_match.group(2) + # Keep standalone make/model + if not make_fallback: + make = "Unknown" + if not model_fallback: + model = "Unknown" + + # Normalize class + if cls: + cls = cls.strip() + # Strip GF if still present in class name + if cls.upper().startswith("GF "): + is_gf = True + cls = cls[3:].strip() + # Class normalization + cls_normalized = { + "Snack/Food": "Snack", + }.get(cls, cls) + cls = cls_normalized + + # Fallback gaps from standalone columns + if not cls or cls == "Unknown" or cls == "": + cls = _s(class_fallback) if class_fallback else "Unknown" + if not make or make == "Unknown" or make == "": + make = _s(make_fallback) if make_fallback else "Unknown" + if not model or model == "Unknown" or model == "": + model = _s(model_fallback) if model_fallback else "Unknown" + + return { + "class": cls or "Unknown", + "make": normalize_make(make) or "Unknown", + "model": model or "Unknown", + "is_glass_front": is_gf, + } + + +def _map_dash_prefix(prefix, model_raw, original_raw): + """ + Map dash-separated type prefixes to class/make/model. + Based on actual data analysis of Type column patterns. + """ + prefix = prefix.strip() + model_raw = model_raw.strip() + + # Class mapping for dash patterns (from data observations) + CLASS_FOR_PREFIX = { + "NATIONAL": None, # varies + "VENDO": "Bev", + "DIXIE NARCO": None, # varies + "ROYAL": "Bev", + "CRANE": None, # varies + "USI": "Snack", + "AUTOMATIC PRODUCTS": "Snack", + "WITTERN": "Snack", + "PEPSI": "Bev", + "UNKNOWN": None, + } + + # Make mapping for dash patterns + MAKE_FOR_PREFIX = { + "NATIONAL": "Crane", + "VENDO": "Vendo", + "DIXIE NARCO": "DN", + "ROYAL": "Royal", + "CRANE": "Crane", + "USI": "USI", + "AUTOMATIC PRODUCTS": "VE", # based on actual data + "WITTERN": "USI", # based on actual data + "PEPSI": "DN", # based on actual data + "UNKNOWN": "Unknown", + } + + # Model normalization for specific patterns + MODEL_MAP = { + "168 SERIES": "15x/16x", + "167 SERIES": "15x/16x", + "158 SERIES": "15x/16x", + "181 SERIES": "Merchant", + "3000 SERIES": "Evoke", + "121TC SERIES": "Unknown", + "LCM2 SERIES": "Unknown", + "SNACK AP MISC": "Unknown", + "MISC SNACK": "Misc Snack", + "PEPSI LOANER COMBO": "Loaner Combo", + "CAN BEV MISC": "Can Bev Misc", + "501 SERIES": "501E", + "5800 SERIES": "BevMax 4", + "276 SERIES": "276E", + "720 HVV SERIES": "HVV (720)", + "BEV MAX 5800": "BevMax 5800", + "CAN/B DN 501E SERIES": "501E", + "472 SERIES": "472", + 'BEV MAX 4 W/7" SCREE': "BevMax 4", + } + + # Check if original has GF prefix + is_gf = original_raw.upper().startswith("GF ") + + # Determine class based on prefix + model + cls = None + if prefix in CLASS_FOR_PREFIX: + cls = CLASS_FOR_PREFIX[prefix] + + # For NATIONAL, class depends on model + if prefix == "NATIONAL": + if model_raw in ("471", "472", "472 SERIES"): + cls = "Food" if is_gf else "Snack" + elif model_raw in ("186", "187", "168 SERIES", "167 SERIES", "158 SERIES", "181 SERIES"): + cls = "Snack" + else: + cls = "Snack" + + # For DIXIE NARCO, class depends on model + if prefix == "DIXIE NARCO": + if is_gf or model_raw in ("3800", "5800 SERIES", "BEV MAX 5800"): + cls = "Bev" + elif model_raw in ("501 SERIES", "276 SERIES", "720 HVV SERIES", "CAN/B DN 501E SERIES"): + cls = "Bev" + else: + cls = "Bev" + + # For CRANE + if prefix == "CRANE": + if model_raw in ("180-36",): + cls = "Snack" + elif model_raw in ("429D GPL",): + cls = "Food" + elif 'BEV' in model_raw.upper(): + cls = "Snack" # BevMax models used as snack machines sometimes + else: + cls = "Snack" + + # For WITTERN + if prefix == "WITTERN": + cls = "Snack" + + # Normalize model + model = MODEL_MAP.get(model_raw, model_raw) + + # For ROYAL, normalize model + if prefix == "ROYAL": + if model_raw in ("550", "660"): + model = "GIII" + + # For VENDO, normalize model + if prefix == "VENDO": + if model_raw in ("621", "721", "821"): + model = f"621/721/821" + + make = MAKE_FOR_PREFIX.get(prefix, prefix) + + return { + "class": cls or "Unknown", + "make": make, + "model": model, + } + + +# ─── 3. Place Parser (Section 2) ────────────────────────────────────────────── + +# Person prefix + address pattern for Pattern B (Disney only) +# Requires: FirstName LastInitial - Number Street... +PATTERN_B_RE = re.compile( + r'^([A-Z][a-z]+)\s+([A-Z])\.?\s*-\s+(\d)' +) + +# Address pattern for Pattern B +ADDRESS_PATTERN_RE = re.compile( + r'^\d+\s+\w+(?:\s+\w+)?\s*(?:\w+)?' +) + +# Common breakroom/vending suffixes to strip +KNOWN_PLACE_SUFFIXES = [ + "Breakroom", "Br", "Break Room", "BREAKROOM", "BREAK AREA", + "Vending Area", "Vending", "Break", "BREAK", + "Breakroom ", "breakroom", +] + + +def parse_place(place_val, customer_val=None): + """ + Parse Place column into location fields. + + Pattern A (simple, 75%): Venue-VenueBreakroom β†’ place + Pattern B (Disney, 25%): PersonName - Address-G/C-Zone FLOOR + + Returns dict with: place, building, floor, zone, zone_type + """ + raw = _s(place_val) + result = { + "place": raw or None, + "building": None, + "floor": None, + "zone": None, + "zone_type": None, + } + + if not raw: + return result + + cust = _s(customer_val) if customer_val else "" + is_disney = cust.upper().startswith("D-") + + # ── Try Pattern B first (has person name + address structure) ── + # Only for Disney customers + if is_disney: + pattern_b_result = _try_pattern_b(raw) + if pattern_b_result: + return pattern_b_result + + # ── Try simplified Pattern B (G/C- prefix without person name) ── + # E.g., "Gran Destino-G-3rd Floor", "Cast Services-G-AK LODGE..." + simplified_b_result = _try_simplified_pattern_b(raw, is_disney) + if simplified_b_result: + return simplified_b_result + + # ── Pattern A (dash-separated, last segment = place) ── + return _parse_pattern_a(raw) + + +def _try_pattern_b(raw): + """ + Try Pattern B: PersonName - Address-G/C-Zone FLOOR + Returns parsed dict or None. + """ + # Check for person prefix + address pattern + m = PATTERN_B_RE.match(raw) + if not m: + return None + + # Strip person prefix (FirstName + Initial + space + dash + space) + first_name = m.group(1) + last_init = m.group(2) + prefix_end = raw.index(m.group(0)) + len(m.group(0)) - 1 # Keep the first digit + + remainder = raw[prefix_end:].strip() + + # Strip address (starts with digits, up to a dash or G- or end) + addr_match = re.match(r'^(\d+\s+[\w\s]+?)(?:\s*-\s*G-|,\s*G-|\s*-|$)', remainder) + if addr_match: + remainder = remainder[addr_match.end():].strip() + else: + # Try simpler: just find the first -G- or - after the address + g_idx = remainder.find('-G-') + dash_idx = remainder.find(' - ') + if g_idx >= 0: + remainder = remainder[g_idx:].strip() + elif dash_idx >= 0: + remainder = remainder[dash_idx + 3:].strip() + + # Now parse the remainder for G/C- prefix, zone, floor, building + return _parse_remainder(remainder) + + +def _try_simplified_pattern_b(raw, is_disney): + """ + Try simplified Pattern B: No person prefix but has G/C- segments. + E.g., "Gran Destino-G-3rd Floor", "Cast Services-G-AK LODGE ZEBRA 5TH REAR" + """ + # Check if it has G- or C- after a dash + if "-G-" not in raw and " -G-" not in raw and not raw.strip().startswith("C-"): + return None + + # If it starts with C- (Cast area), handle specifically + if raw.strip().startswith("C-"): + return _parse_cast_place(raw) + + # Check for "xxx -G- yyy" or "xxx-G-yyy" + m = re.search(r'-G-(.+)$', raw) + if not m: + return None + + # Split at the -G- + prefix_part = raw[:m.start()].strip() + g_part = m.group(1).strip() + + # If prefix has address pattern, try to strip it + prefix = _strip_address(prefix_part) + + # Parse G-part for floor, building, zone + return _parse_g_part(g_part, prefix) + + +def _parse_cast_place(raw): + """ + Parse C- prefixed places like: + "C-MK EASTGATE SECURITY-Be Our Guest" + "C-MK EASTGATE SECURITY-Space Mountain" + "C- RIDE AND SHOW-C- RIDE AND SHOW" + """ + # Strip leading C- + content = re.sub(r'^C-\s*', '', raw) + + parts = content.split('-') + parts = [p.strip() for p in parts if p.strip()] + + if not parts: + return { + "place": raw, + "building": None, + "floor": None, + "zone": None, + "zone_type": "cast", + } + + # Last part is the zone/area + last = parts[-1] + + return { + "place": last, + "building": parts[0] if len(parts) > 1 else None, + "floor": None, + "zone": last, + "zone_type": "cast", + } + + +def _parse_g_part(g_part, prefix): + """ + Parse the G-prefixed portion for floor, building, zone. + E.g., "REAR 6TH FL", "BUILDING 9 LOBBY", "COZY CONE POOL", "3rd Floor" + """ + result = { + "place": prefix or g_part, + "building": None, + "floor": None, + "zone": None, + "zone_type": "guest", + } + + # Extract floor + floor_info = _extract_floor(g_part) + + # Extract building + building_info = _extract_building(g_part) + + # The remainder after removing floor and building is the zone + remainder = g_part + if floor_info["raw"]: + remainder = remainder.replace(floor_info["raw"], "", 1).strip() + if building_info["raw"]: + remainder = remainder.replace(building_info["raw"], "", 1).strip() + + # Clean multiple spaces + remainder = re.sub(r'\s+', ' ', remainder).strip() + + result["floor"] = floor_info["floor"] + result["building"] = building_info["building"] + result["zone"] = remainder if remainder else None + + # If we have a prefix (venue name), use it as place + if prefix: + result["place"] = prefix + + return result + + +def _parse_remainder(remainder): + """ + Parse the remainder after stripping person and address. + Contains G/C- prefix, zone, floor, building. + """ + result = { + "place": remainder, + "building": None, + "floor": None, + "zone": None, + "zone_type": None, + } + + if not remainder: + return result + + # Check for G- or C- prefix + zone_type = None + text = remainder + if text.upper().startswith("G-"): + zone_type = "guest" + text = text[2:].strip() + elif text.upper().startswith("C-"): + zone_type = "cast" + text = text[2:].strip() + + result["zone_type"] = zone_type + + # Extract floor from text + floor_info = _extract_floor(text) + + # Extract building from text (after floor removal) + text_no_floor = text + if floor_info["raw"]: + text_no_floor = text_no_floor.replace(floor_info["raw"], "", 1).strip() + + building_info = _extract_building(text_no_floor) + + # After removing floor and building, remaining is zone + zone_remainder = text_no_floor + if building_info["raw"]: + zone_remainder = zone_remainder.replace(building_info["raw"], "", 1).strip() + + # Clean up + zone_remainder = re.sub(r'\s+', ' ', zone_remainder).strip() + + result["floor"] = floor_info["floor"] + result["building"] = building_info["building"] + result["zone"] = zone_remainder if zone_remainder else None + + # Set place to the original G/C- stripped content, or use zone + if result["zone"]: + result["place"] = result["zone"] + elif result["building"]: + result["place"] = result["building"] + else: + result["place"] = text + + return result + + +def _strip_address(text): + """ + Try to strip address pattern from start of text. + E.g., "Gran Destino" from "Gran Destino-G-3rd Floor" + """ + # If text ends with -G- (stripped), the prefix is the venue + return text + + +def _parse_pattern_a(raw): + """ + Parse Pattern A: dash-separated, last segment = place. + + Per the handbook: + 1. Split on `-` + 2. Take the last non-empty segment + 3. Strip known suffixes (Breakroom, Br, Break Room, BREAKROOM, Vending, Break, etc.) + 4. Extract floor info if present + 5. Extract building info if present + 6. Remaining text is the place name + """ + result = { + "place": None, + "building": None, + "floor": None, + "zone": None, + "zone_type": None, + } + + if not raw: + return result + + text = raw.strip() + + # Check for C- or G- prefix on the overall string (area marker) + zone_type = None + if text.upper().startswith("C-"): + zone_type = "cast" + text = text[2:].strip() + elif text.upper().startswith("G-"): + zone_type = "guest" + text = text[2:].strip() + + result["zone_type"] = zone_type + + # Split by dash + parts = [p.strip() for p in text.split('-') if p.strip()] + + if not parts: + result["place"] = text or raw + return result + + # Take the last segment + last = parts[-1] + + # Strip known suffixes + stripped = _strip_place_suffix(last) + + # If stripping left empty, the last segment was entirely a suffix. + # Use the second-to-last segment as the place. + if not stripped: + if len(parts) >= 2: + stripped = _strip_place_suffix(parts[-2]) + last = parts[-2] + else: + stripped = last + + # Extract floor info + floor_info = _extract_floor(stripped) + text_no_floor = stripped + if floor_info["raw"]: + text_no_floor = stripped.replace(floor_info["raw"], "", 1).strip() + + # Extract building info + building_info = _extract_building(text_no_floor) + text_no_building = text_no_floor + if building_info["raw"]: + text_no_building = text_no_floor.replace(building_info["raw"], "", 1).strip() + + # Clean up + remainder = re.sub(r'\s+', ' ', text_no_building).strip() + + result["floor"] = floor_info["floor"] + result["building"] = building_info["building"] + result["zone"] = remainder if remainder else None + result["place"] = remainder if remainder else last + + return result + + + +def _strip_place_suffix(text): + """Strip known breakroom/vending suffixes from place text. + + Only strips when the suffix is at the END of the text (last word(s)), + so 'Breakroom Main' is NOT stripped but 'Turano Breakroom' IS stripped + to 'Turano'. + """ + if not text: + return text + + text_lower = text.lower().strip() + + # Suffix phrases to strip from the END (ordered longest-first to avoid partial matches) + suffix_phrases = [ + "breakroom", "break room", "break area", + "vending area", + "breakro", # truncated form + ] + + # Single-word suffix tokens (strip as last word) + suffix_words = [ + "breakroom", "break", "vending", "br", + "breakro", # truncated + ] + + # Try stripping as the entire remaining text first (phrase-level) + for suffix in suffix_phrases: + sl = suffix.lower() + if text_lower.endswith(sl): + # Make sure the suffix is the last thing (not part of a compound name) + # Check that the suffix isn't preceded by another word without a space + before = text_lower[:-len(sl)].strip() + if before and not before[-1].isalnum(): + # Good, there's a space/separator before the suffix + return text[:-(len(suffix) + (1 if text_lower[-len(sl)-1:-len(sl)] == ' ' else 0))].strip() + elif not before: + # Text IS entirely the suffix + return "" + + # Try stripping just the last word if it's a suffix + words = text.split() + if len(words) >= 1: + last_word = words[-1].lower().strip('.,;:!?') + if last_word in suffix_words: + return ' '.join(words[:-1]) + + # Try stripping last two words: "Breakroom Main" won't match but "Vending Area" will + if len(words) >= 2: + last_two = ' '.join(w.lower().strip('.,;:!?') for w in words[-2:]) + if last_two in suffix_phrases: + return ' '.join(words[:-2]) + + return text + + +def _extract_floor(text): + """Extract floor number from text. Returns dict with floor and raw match.""" + for pattern, extractor in FLOOR_PATTERNS: + m = pattern.search(text) + if m: + try: + floor_num = int(extractor(m)) + return {"floor": floor_num, "raw": m.group(0)} + except (ValueError, IndexError): + continue + return {"floor": None, "raw": None} + + +def _extract_building(text): + """Extract building info from text. Returns dict with building and raw match.""" + for pattern in BUILDING_PATTERNS: + m = pattern.search(text) + if m: + return {"building": m.group(0).strip(), "raw": m.group(0)} + return {"building": None, "raw": None} + + +# ─── 4. Customer Parser (Section 1) ─────────────────────────────────────────── + +def parse_customer(customer_val): + """ + Parse Customer column into company and disney_park. + + Disney customers (D- prefix) get mapped to a Disney property. + Non-Disney customers map directly to company. + """ + raw = _s(customer_val) + result = { + "company": None, + "disney_park": None, + } + + if not raw: + return result + + raw_upper = raw.upper() + + # Check for Disney prefix + if raw_upper.startswith("D-"): + result["company"] = "Disney" + + # Find the best matching Disney property + # Try exact match first + if raw in DISNEY_PROPERTY_MAP: + result["disney_park"] = DISNEY_PROPERTY_MAP[raw] + else: + # Try case-insensitive prefix matching + matched = False + for prefix, park_name in DISNEY_PROPERTY_MAP.items(): + if raw_upper.startswith(prefix.upper()): + result["disney_park"] = park_name + matched = True + break + + if not matched: + # Try prefix matching against the raw customer + for prefix in DISNEY_CUSTOMER_PREFIXES: + if raw_upper.startswith(prefix.upper()): + result["disney_park"] = prefix # use as-is + break + + if not result["disney_park"]: + result["disney_park"] = raw + else: + result["company"] = raw + + return result + + +# ─── 5. Serial Number Validator (Section 4) ─────────────────────────────────── + +# OCR character confusion table +OCR_CORRECTIONS = { + 'S': '5', + 's': '5', + 'I': '1', + 'l': '1', # lowercase L also often confused with 1 + 'O': '0', + 'o': '0', + 'B': '8', + 'b': '8', + 'G': '6', + 'g': '6', +} + + +def validate_serial(serial_val): + """ + Validate and OCR-correct serial number. + + Returns dict with: serial_number (corrected), is_valid, is_placeholder + """ + raw = _s(serial_val) + result = { + "serial_number": None, + "is_valid": False, + "is_placeholder": False, + } + + if not raw: + return result + + # Step 1: Strip non-alphanumeric characters (dashes, spaces, dots) + cleaned = re.sub(r'[^a-zA-Z0-9]', '', raw) + + if not cleaned: + return result + + # Step 2: Check for placeholder values + # Placeholder: < 5 chars, or obvious placeholders + if len(cleaned) < 5: + result["is_placeholder"] = True + return result + + # Check for known placeholder values + if cleaned.lower() in ("pending", "n/a", "none", "null", "unknown"): + result["is_placeholder"] = True + return result + + if cleaned == '*' or cleaned == 'S' or cleaned == 'a': + result["is_placeholder"] = True + return result + + # Step 3: Apply OCR correction + corrected = [] + for ch in cleaned: + corrected.append(OCR_CORRECTIONS.get(ch, ch)) + corrected_str = ''.join(corrected) + + # Step 4: Check if all chars are the same (invalid) + if len(set(corrected_str)) == 1: + result["is_valid"] = False + result["is_placeholder"] = True + return result + + # Step 5: Length validation (valid serials are 9-14 chars after cleaning) + is_valid = 9 <= len(corrected_str) <= 14 + + result["serial_number"] = corrected_str + result["is_valid"] = is_valid + + return result + + +# ─── 6. Alert Parser (Section 8) ────────────────────────────────────────────── + +def parse_alerts(alert_val, coil_alerts_val=None, product_alerts_val=None): + """ + Parse Alerts column into structured alert data. + + Returns dict with: + - has_alert: bool + - severity: info | critical | none + - service_scheduled: bool + - out_of_touch: bool + - out_of_touch_hours: int | None + - has_not_dexed: bool + - not_dexed_duration: str | None + - coil_alert_count: int + - product_alert_count: int + - raw_text: str | None + """ + result = { + "has_alert": False, + "severity": "none", + "service_scheduled": False, + "service_is_today": False, + "out_of_touch": False, + "out_of_touch_hours": None, + "has_not_dexed": False, + "not_dexed_duration": None, + "coil_alert_count": 0, + "product_alert_count": 0, + "raw_text": None, + } + + # Process coil and product alerts + coil = _int_or_none(coil_alerts_val) + product = _int_or_none(product_alerts_val) + if coil and coil > 0: + result["coil_alert_count"] = coil + if product and product > 0: + result["product_alert_count"] = product + + # Process Alert text + raw = _s(alert_val) + + if not raw: + # Check if there are numeric alerts + if result["coil_alert_count"] > 0 or result["product_alert_count"] > 0: + result["has_alert"] = True + result["severity"] = "info" + return result + + result["raw_text"] = raw + result["has_alert"] = True + + # Check for service scheduled + if "scheduled for service today" in raw.lower(): + result["service_scheduled"] = True + result["service_is_today"] = True + result["severity"] = "info" + + if "scheduled for service on" in raw.lower(): + result["service_scheduled"] = True + result["severity"] = "info" + + # Check for out of touch + oot_match = re.search(r'Out of touch for ([\d.]+)\s*(hours?|days?)', raw, re.IGNORECASE) + if oot_match: + result["out_of_touch"] = True + value = float(oot_match.group(1)) + unit = oot_match.group(2).lower() + if "hour" in unit: + result["out_of_touch_hours"] = value + elif "day" in unit: + result["out_of_touch_hours"] = value * 24 + result["severity"] = "critical" + + # Check for Has not DEXed + dex_match = re.search(r'Has not DEXed in ([\d.]+)\s*(hours?|days?)', raw, re.IGNORECASE) + if dex_match: + result["has_not_dexed"] = True + result["not_dexed_duration"] = dex_match.group(0) + if result["severity"] == "none": + result["severity"] = "info" + + return result + + +# ─── 7. Sales Parser ────────────────────────────────────────────────────────── + +def parse_sales(raw): + """ + Parse sales-related columns from the raw dict. + + Returns dict with yearly_sales, monthly_sales, weekly_sales, + days_since_restock, daily_avg_sales, today_sales, yesterday_sales, + sales_since_restock. + """ + return { + "yearly_sales": _num(raw.get("Yearly Sales")), + "monthly_sales": _num(raw.get("Monthly Sales")), + "weekly_sales": _num(raw.get("Weekly Sales")), + "daily_avg_sales": _num(raw.get("Daily Average Sales")), + "today_sales": _num(raw.get("Today Sales")), + "yesterday_sales": _num(raw.get("Yesterday Sales")), + "sales_since_restock": _num(raw.get("Sales (Restock)")), + "days_since_restock": _int_or_none(raw.get("Days Since Restock")), + } + + +# ─── 8. Priority Scorer (Section 9) ────────────────────────────────────────── + +def compute_priority(yearly_sales): + """ + Compute priority tier from yearly sales. + + Low: ≀ $2,000 + Mid: $2,001 - $15,000 + High: $15,001+ + """ + if yearly_sales is None: + return "Unknown" + if yearly_sales <= 2000: + return "Low" + elif yearly_sales <= 15000: + return "Mid" + else: + return "High" + + +# ─── 9. Main Parser ─────────────────────────────────────────────────────────── + +# Full column mapping: Excel column index (1-based) β†’ DB field name +COLUMN_MAPPING = { + 1: "Device", + 2: "Location", + 3: "Asset ID", + 4: "Place", + 5: "Type", + 6: "City", + 7: "Address", + 8: "Last Contact Time", + 9: "Last Dex Report Time", + 10: "Last Restock", + 11: "Coil Alerts", + 12: "Product Alerts", + 13: "Sales (Restock)", + 14: "Daily Average Sales", + 15: "Today Sales", + 16: "Yesterday Sales", + 17: "Weekly Sales", + 18: "Monthly Sales", + 19: "Yearly Sales", + 20: "Days Since Restock", + 21: "Prepick Group", + 22: "Customer", + 23: "Management Company", + 24: "Management Account", + 25: "Machine Management Code", + 26: "Route", + 27: "Subroute", + 28: "Changer Par", + 29: "Acquired From", + 30: "Purchase Date", + 31: "Purchase Price", + 32: "Depreciation Years", + 33: "Post-Depreciation Monthly Cost", + 34: "State", + 35: "Postal Code", + 36: "Deployed", + 37: "Pulled Date", + 38: "Serial Number", + 39: "Class", + 40: "Make", + 41: "Model", + 42: "Cash Discount", + 43: "Tax Jurisdiction", + 44: "Commission Plan", + 45: "Barcode", + 46: "Non-Revenue", + 47: "Added Date", + 48: "Phone", + 49: "Fax", + 50: "Email", + 51: "Has Cashless", + 52: "Branch", + 53: "Location Code", + 54: "Customer Code", + 55: "Last Inventory", + 56: "Asset Family", + 57: "Status", + 58: "Alerts", + 59: "Valid Address", + 60: "Business Type", + 61: "Primary Consumer Type", + 62: "Machine Branding", +} + + +def parse_excel(filepath): + """ + Parse the Excel file and return a list of machine record dicts. + + Each record maps raw columns to DB fields, with parsing applied + to Device, Type, Place, Customer, Serial Number, Status, and Alerts. + """ + wb = openpyxl.load_workbook(filepath, read_only=False, data_only=True) + ws = wb.active + + # Compute dimensions + max_col = ws.max_column or 62 + max_row = ws.max_row or 1849 + + # Build column index map from header row + headers = [ws.cell(1, c).value for c in range(1, max_col + 1)] + col_map = {} + for i, h in enumerate(headers, 1): + col_map[h] = i + + machines = [] + + for row_idx in range(2, max_row + 1): + row = [ws.cell(row_idx, c).value for c in range(1, ws.max_column + 1)] + + # Build raw data dict with field names as keys + raw = {} + for col_idx, field_name in COLUMN_MAPPING.items(): + raw[field_name] = row[col_idx - 1] if col_idx - 1 < len(row) else None + + # ── Parse each field ── + + # Device β†’ telemetry + device_info = parse_device(raw["Device"]) + + # Type β†’ class, make, model, is_glass_front + type_info = parse_type( + raw["Type"], + class_fallback=raw.get("Class"), + make_fallback=raw.get("Make"), + model_fallback=raw.get("Model"), + ) + + # Place β†’ location fields + place_info = parse_place(raw["Place"], raw["Customer"]) + + # Customer β†’ company + disney_park + customer_info = parse_customer(raw["Customer"]) + + # Serial Number β†’ validate + OCR correct + serial_info = validate_serial(raw["Serial Number"]) + + # Status β†’ remote_pricing_status + status_raw = _s(raw.get("Status")) + remote_pricing_status = status_raw if status_raw else None + + # Alerts β†’ parsed structure + alerts_info = parse_alerts( + raw.get("Alerts"), + coil_alerts_val=raw.get("Coil Alerts"), + product_alerts_val=raw.get("Product Alerts"), + ) + + # Sales data + sales_info = parse_sales(raw) + + # Compute priority from yearly sales + priority = compute_priority(sales_info["yearly_sales"]) + + # Has Cashless + has_cashless = _bool_from_yesno(raw.get("Has Cashless")) + + # Deployed + deployed = _bool_from_yesno(raw.get("Deployed")) + + # Build machine record + machine = { + # Identity + "machine_id": _s(raw.get("Asset ID")), + "serial_number": serial_info["serial_number"], + "serial_is_valid": serial_info["is_valid"], + "serial_is_placeholder": serial_info["is_placeholder"], + + # Type info + "class": type_info["class"], + "make": type_info["make"], + "model": type_info["model"], + "is_glass_front": type_info["is_glass_front"], + + # Location + "place": place_info["place"], + "building_name": place_info["building"], + "floor": place_info["floor"], + "zone": place_info["zone"], + "zone_type": place_info["zone_type"], + "address": _s(raw.get("Address")), + "location_area": _s(raw.get("City")), + "state": _s(raw.get("State")), + "postal_code": _s(raw.get("Postal Code")), + + # Customer + "company": customer_info["company"], + "disney_park": customer_info["disney_park"], + + # Telemetry + "telemetry_provider": device_info["telemetry_provider"], + "card_reader_brand": device_info["card_reader_brand"], + + # Pricing + "remote_pricing_status": remote_pricing_status, + + # Alerts + "alerts": alerts_info, + + # Sales & Priority + "yearly_sales": sales_info["yearly_sales"], + "monthly_sales": sales_info["monthly_sales"], + "weekly_sales": sales_info["weekly_sales"], + "daily_avg_sales": sales_info["daily_avg_sales"], + "today_sales": sales_info["today_sales"], + "yesterday_sales": sales_info["yesterday_sales"], + "sales_since_restock": sales_info["sales_since_restock"], + "days_since_restock": sales_info["days_since_restock"], + "priority": priority, + + # Other + "prepick_group": _s(raw.get("Prepick Group")), + "has_cashless": has_cashless, + "deployed": deployed, + + # Dates + "install_date": raw.get("Added Date"), + "dex_report_date": raw.get("Last Dex Report Time"), + "pulled_date": raw.get("Pulled Date"), + "last_contact_time": raw.get("Last Contact Time"), + "last_restock": raw.get("Last Restock"), + + # Raw reference (for debugging) + "coil_alert_count": _int_or_none(raw.get("Coil Alerts")), + "product_alert_count": _int_or_none(raw.get("Product Alerts")), + "route": _s(raw.get("Route")), + "subroute": _s(raw.get("Subroute")), + "branch": _s(raw.get("Branch")), + "asset_family": _s(raw.get("Asset Family")), + "valid_address": _s(raw.get("Valid Address")), + } + + machines.append(machine) + + wb.close() + return machines + + +def parse_excel_to_json(filepath, pretty=True): + """Parse Excel and return JSON string of all machines.""" + machines = parse_excel(filepath) + indent = 2 if pretty else None + return json.dumps(machines, indent=indent, default=str, ensure_ascii=False) + + +# ─── CLI / Test ────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + import sys + + filepath = sys.argv[1] if len(sys.argv) > 1 else "seed-data/Machine_List.xlsx" + + print(f"πŸ“„ Parsing {filepath}...") + machines = parse_excel(filepath) + + print(f"βœ… Parsed {len(machines)} machines\n") + + print("=" * 60) + print("FIRST 3 MACHINES (JSON)") + print("=" * 60) + + for i, m in enumerate(machines[:3]): + print(f"\n--- Machine {i + 1} ---") + print(json.dumps(m, indent=2, default=str, ensure_ascii=False)) + + print("\n" + "=" * 60) + print("SUMMARY STATISTICS") + print("=" * 60) + + # Count non-null values + fields = [ + "telemetry_provider", "company", "disney_park", + "class", "is_glass_front", "remote_pricing_status", + "priority", "yearly_sales", + ] + for field in fields: + count = sum(1 for m in machines if m.get(field) is not None and m.get(field) != "") + print(f" {field:30s}: {count:5d} / {len(machines)}") + + # Telemetry provider distribution + from collections import Counter + telem_dist = Counter(m.get("telemetry_provider") or "None" for m in machines) + print("\n Telemetry Provider Distribution:") + for provider, count in telem_dist.most_common(): + print(f" {provider:15s}: {count}") + + # Disney count + disney_count = sum(1 for m in machines if m.get("disney_park")) + print(f"\n Disney machines: {disney_count}") + + # Serial validation stats + valid_serials = sum(1 for m in machines if m.get("serial_is_valid")) + placeholder_serials = sum(1 for m in machines if m.get("serial_is_placeholder")) + print(f" Valid serials: {valid_serials}") + print(f" Placeholder serials: {placeholder_serials}") + + # Priority distribution + priority_dist = Counter(m.get("priority") or "Unknown" for m in machines) + print("\n Priority Distribution:") + for pri, count in priority_dist.most_common(): + print(f" {pri:10s}: {count}") + + # Glass front count + gf_count = sum(1 for m in machines if m.get("is_glass_front")) + print(f"\n Glass Front machines: {gf_count}") + + # Remote pricing status distribution + rp_dist = Counter(m.get("remote_pricing_status") or "None" for m in machines) + print("\n Remote Pricing Status:") + for status, count in rp_dist.most_common(): + print(f" {status:20s}: {count}") + + print("\nβœ… Done!") diff --git a/reporter.py b/reporter.py new file mode 100644 index 0000000..419987c --- /dev/null +++ b/reporter.py @@ -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() diff --git a/seed-data/reference_handbook.md b/seed-data/reference_handbook.md index 9621470..4abff64 100644 --- a/seed-data/reference_handbook.md +++ b/seed-data/reference_handbook.md @@ -1,280 +1,348 @@ # 🌱 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. +> **Source data:** Machine_List.xlsx (raw Cantaloupe export, 1,848 machines, 62 columns) +> **Seed subset:** import_seed.csv (headers stripped, sales columns = "Deleted") +> **Handbook version:** 2.0 β€” merged with Excel analysis + Disney address lookup --- -## 1. Customer Column β†’ Company/Domain +## 1. Customer Column β†’ Company / Disney Park -**Status: βœ… READY** +**Status: βœ… COMPLETE** -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. +The `Customer` column maps to `company` in the database. For non-Disney customers, it's the direct business name. ### Disney Customers (prefix `D-`) -Customers starting with `D-` are Disney properties. These map to `disney_park`: +Customers starting with `D-` are Disney properties. 469 machines (25% of fleet) are at Disney locations. The customer name tells us which property: -| 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-... GUEST` | `resort` | Resort/guest areas | -| `D-... CAST` | `resort` | Backstage/cast areas | -| `D-... VENDING` | `office` | Disney vending operations | -| `D-DISNEY WORLD SS` | `office` | Support services | +| Customer Prefix | Property Name | Category | Base GPS | +| --- | --- | --- | --- | +| `D-ART OF ANIMATION` | Art of Animation Resort | resort | 28.3499, -81.5678 | +| `D-All Star Music` | All-Star Music Resort | resort | 28.3362, -81.5735 | +| `D-All Star Sports` | All-Star Sports Resort | resort | 28.3379, -81.5740 | +| `D-BAY LAKE TOWER` | Bay Lake Tower at Contemporary | resort | 28.4145, -81.5714 | +| `D-BLIZZARD BEACH` | Blizzard Beach Water Park | park | 28.3507, -81.5734 | +| `D-Boardwalk` | Disney's BoardWalk Inn | resort | 28.3680, -81.5577 | +| `D-Celebration` | Celebration (Admin) | office | 28.3248, -81.5364 | +| `D-ContemporaryHotel` | Disney's Contemporary Resort | resort | 28.4145, -81.5714 | +| `D-DISNEY VENDING` | Disney Support Services | support | 28.4100, -81.5800 | +| `D-DISNEY WORLD SS` | Disney World Support Services | support | 28.3720, -81.5610 | +| `D-DRC` | Disney Regional Center | office | 28.4666, -81.4305 | +| `D-Disney Springs` | Disney Springs | park | 28.3705, -81.5175 | +| `D-FORT WILDERNESS` | Fort Wilderness Resort & Campground | resort | 28.4100, -81.5480 | +| `D-GRAND FLORIDIAN` | Disney's Grand Floridian Resort | resort | 28.4105, -81.5890 | +| `D-POLYNESIAN RESORT` | Disney's Polynesian Village Resort | resort | 28.4085, -81.5715 | +| `D-POP CENTURY` | Disney's Pop Century Resort | resort | 28.3460, -81.5710 | +| `D-PORT ORLEANS FrQt` | Port Orleans French Quarter | resort | 28.3985, -81.5480 | +| `D-Port Orleans Rvsd` | Port Orleans Riverside | resort | 28.3950, -81.5450 | +| `D-RIVIERA RESORT` | Disney's Riviera Resort | resort | 28.3680, -81.5493 | +| `D-Saratoga Springs` | Saratoga Springs Resort & Spa | resort | 28.3655, -81.5250 | +| `D-Treehouse Villas` | Treehouse Villas (Saratoga Springs) | resort | 28.3650, -81.5250 | +| `D-WIDE WORLD SPORTS` | ESPN Wide World of Sports | park | 28.3275, -81.5920 | +| `D-WILDERNESS LODGE` | Disney's Wilderness Lodge | resort | 28.4120, -81.5860 | +| `D-Winter Summerland` | Winter Summerland Mini Golf | park | 28.3510, -81.5750 | +| `D-YACHT AND BEACH` | Disney's Yacht & Beach Club Resorts | resort | 28.3665, -81.5560 | -**FILL_IN**: Add any Disney customer patterns I missed: - -- ___________________________________________________________________________ -- ___________________________________________________________________________ +**Match rule:** Case-insensitive prefix match. `D-Art of Animation Guest` β†’ Art of Animation. `D-Port Orleans Rvsd GUEST` β†’ Port Orleans Riverside. --- ## 2. Place Column β†’ Location Fields -**Status: βœ… READY** (patterns identified) +**Status: βœ… COMPLETE** (Excel data analyzed) -The `Place` column has **two main patterns** β€” the extraction engine must detect which pattern applies and parse accordingly. +The `Place` column has **two distinct patterns** β€” the parser must detect which applies. -### Pattern A: Simple β€” ~90% of rows +### Pattern A: Simple (~1,379 rows, 75%) -**Format:** `Venue Name-Venue Detail` (dash-separated, repetitive) +**Format:** `Venue Name-Venue Detail-Venue Detail` (dash-separated) + +The last meaningful segment after the last dash is the place. Common suffixes to strip: +- `Breakroom`, `Br`, `Break Room`, `BREAKROOM`, `BREAK AREA` +- `Vending Area`, `Vending` +- `Break`, `Breakroom ` (with trailing spaces) **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 +Sygma - Vending-Sygma - Vending Breakroom β†’ place = "Sygma - Vending" +Imperial Dade-Imperial Dade- Breakroom β†’ place = "Imperial Dade" +Ancora Apt.-Ancora Apt. Breakroom β†’ place = "Ancora Apt." +Delamarre-Delamarre Breakroom β†’ place = "Delamarre" +BMW Service-BMW Breakroom β†’ place = "BMW" +Kisselback Ford-Kisselback Ford Breakroom β†’ place = "Kisselback Ford" +Sam's Club-Sam's Club β†’ place = "Sam's Club" +BREAK AREA-BREAK AREA β†’ place = "BREAK AREA" +Building 1180-Building 1180 β†’ place = "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 +**136 unique Pattern A tails** in the data. Many have floor/building prefixed: +``` +BLDG10/11 - Hilton Worldwide +2ND FL CARDIO WAITING ROOM +13th St-BREAKROOM +1ST FL BREAK +2nd Floor Front Tower +AHS - Flamingo +``` -### Pattern B: Complex Disney β€” ~10% of rows +**Extraction rule:** +1. Split on `-` +2. Take the last non-empty segment +3. Strip known suffixes (`Breakroom`, `Vending`, `Break`, etc.) +4. Extract floor info if present: `NTH FL`, `Floor N`, `Nth Floor` +5. Extract building info: `BLDG #`, `Building N` +6. Remaining text is the `place` name + +### Pattern B: Complex Disney (~469 rows, 25%) **Format:** `PersonName - Address-G-Zone FLOOR` -**Examples:** +**Structure breakdown:** ``` -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 +[Person Name] - [Address]-[G-][Zone] [FLOOR] + Todd J - 901 Tinberline Dr -G- REAR 6TH FL + Becky N - 200 Celebration - Disney Park & Tickets Ki + Sarah W - 4401 Floridian Way-G- BUILDING 9 LOBBY ``` **Extraction steps:** -1. Strip person name prefix: ` ` or ` -` -2. Strip address segment (number + street name) -3. Extract `G-` zone/area code if present -4. Extract floor info: `NTH FL`, `Nth floor`, `FL N`, `1ST FL`, etc. -5. Whatever remains is the clean `place` name +1. **Strip person prefix:** ` -` or ` -` + - Pattern: `^[A-Z][a-z]+\s+[A-Z]?\s*-\s*` + - Removes: `Todd J - `, `Sarah W - `, `Justin M - `, `Brenda G - ` -### 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) -``` +2. **Strip address:** Typically a building number + street name at the start + - Must identify by pattern: `^\d+\s+\w+\s+\w+\s*(?:Dr|Blvd|Way|Ct|Ln|Ave)` + - Addresses seen: 901 Timberline Dr, 4401 Floridian Way, 2101 Epcot Resorts Blvd, etc. -### Floor Extraction Patterns +3. **Strip `G-` prefix** if present (it's a zone/location marker) -| 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` | +4. **Extract floor info** from the remainder using patterns: + - `NTH FL` β†’ `1ST FL`, `6TH FL`, `3RD FL`, `4TH FL` + - `Nth FLOOR` β†’ `1ST FLOOR`, `2nd FLOOR`, `4th FLOOR` + - `FL N` β†’ `FL 2` + - `Nth floor` β†’ `7th floor` + - `NST FL` β†’ `1ST FL` -**FILL_IN**: Add any special Place patterns or corrections you know about: +5. **Extract building info:** + - `BLDG #N`, `Building N`, `Bldg N` + - `DAAR N`, `DAAR B-N` (Disney building codes) + - `TOWER N`, `Loop N` -- ___________________________________________________________________________ -- ___________________________________________________________________________ -- ___________________________________________________________________________ +6. **Extract zone/area** (the remaining text after stripping floor/building): + - `LOBBY`, `BUS STOP`, `POOL`, `Cafeteria`, `Wardrobe` + - ePort/NAYAX/CMS structure details + +**Disney place suffix examples (after stripping person + address):** + +| Customer | Cleaned Place Suffix | Building | Floor | Zone | +| --- | --- | --- | --- | --- | +| Wilderness Lodge | G-REAR 6TH FL | REAR | 6TH FL | β€” | +| Wilderness Lodge | G-South End 7th floor | South End | 7th floor | β€” | +| Wilderness Lodge | G-CENTER 3RD FL | CENTER | 3RD FL | β€” | +| Wilderness Lodge | G-FW VILLAS 1ST FL | FW VILLAS | 1ST FL | β€” | +| Pop Century | G-BLDG 1 50A 1ST FL | BLDG 1 50A | 1ST FL | β€” | +| Pop Century | G-BLDG 10 70B 2ND FL | BLDG 10 70B | 2ND FL | β€” | +| Art of Animation | G-DAAR 1-1ST FL | DAAR 1 | 1ST FL | β€” | +| Art of Animation | G-DAAR 10-2ND FL | DAAR 10 | 2ND FL | β€” | +| Art of Animation | G-COZY CONE POOL | β€” | β€” | COZY CONE POOL | +| Art of Animation | G-NEMO POOL | β€” | β€” | NEMO POOL | +| All Star Sports | G-Sports #1 - 2ND FL | SPORTS #1 | 2ND FL | β€” | +| All Star Sports | G-SPORTS GRAND SLAM POOL | β€” | β€” | GRAND SLAM POOL | +| All Star Sports | G-SPORT SURF'S UP LAUNDRY | β€” | β€” | SURF'S UP LAUNDRY | +| Contemporary Hotel | G-CONT HOTEL TOWER 10 | TOWER 10 | β€” | β€” | +| Contemporary Hotel | G-CONT HOTEL SA FRONT | β€” | β€” | SA FRONT | +| Boardwalk | Hotel 2nd Floor Left | β€” | 2nd Floor | Left | +| Boardwalk | Villas 3 FL by room | β€” | 3 FL | by room | +| Grand Floridian | G-BUILDING 5 LOBBY | BUILDING 5 | β€” | LOBBY | +| Grand Floridian | G-GF 2nd Floor | β€” | 2nd Floor | GF | +| Riviera | G-2ND FLOOR NORTH | β€” | 2ND FLOOR | NORTH | +| Saratoga Springs | Carousel Bldg 7501 | Carousel 7501 | β€” | β€” | +| Saratoga Springs | Congress Bldg 1101-1436 | Congress 1101-1436 | β€” | β€” | +| Fort Wilderness | G-FW LOOP 1400 | FW LOOP 1400 | β€” | β€” | +| Fort Wilderness | G-FW Meadows POOL 1400 | β€” | β€” | Meadows POOL | +| Polynesian | G-POLY BLDG 10 MOOREA 1ST | MOOREA | 1ST | BLDG 10 | +| Polynesian | G-POLY BLDG 4 TUVALU-2ND | TUVALU | 2ND | BLDG 4 | +| French Quarter | G-PO FRENCH QTR B2 | BLDG B2 | β€” | β€” | +| French Quarter | G-Bus Stop A | β€” | β€” | Bus Stop A | +| Riverside | G-PO RIVERSIDE 80S | 80S | β€” | β€” | +| Riverside | G-PO RIVERSIDE BLD | β€” | β€” | β€” | +| Riverside | G-WEST DEPOT BUS STOP | β€” | β€” | WEST DEPOT BUS | +| Port Orleans | Bus Stop A | β€” | β€” | Bus Stop A | +| Yacht & Beach | G-Beach Club Laundry | β€” | β€” | Beach Club Laundry | +| Yacht & Beach | G-Yacht Laundry | β€” | β€” | Yacht Laundry | + +### Zone Prefixes (Disney) + +Many Disney place suffixes have a `G-` prefix (Guest) or `C-` prefix (Cast). These indicate **audience**: +- `G-` = Guest area (hotel lobbies, pools, bus stops) +- `C-` = Cast/Backstage area (wardrobe, breakrooms, maintenance) + +**Strip `G-` and `C-` from the `place` field** but store them as a `zone_type` metadata field. + +### Floor Extraction β€” Unified Rules + +The data has floors expressed in these formats: + +| Raw | Normalized | Digestible | +| --- | --- | --- | +| `6TH FL` | `6th Floor` | 6 | +| `7th floor` | `7th Floor` | 7 | +| `FL 2` | `Floor 2` | 2 | +| `1ST FL` | `1st Floor` | 1 | +| `2ND FLOOR` | `2nd Floor` | 2 | +| `3 RD FLOOR` | `3rd Floor` | 3 | +| `4th FLOOR` | `4th Floor` | 4 | +| `14th FLOOR` | `14th Floor` | 14 | +| `5T` (Contemporary tower) | `Floor 5` | 5 | +| `2N`, `2 fl` | `Floor 2` | 2 | + +**Note:** Contemporary Resort uses tower suffix notation: `5T`, `6T`, `7T`, `8T`, `9T`, `10`, `11`, `12`, `14` β€” the `T` suffix stands for Tower. --- ## 3. GPS Derivation for Multi-Floor Machines -**Status: ⚠️ NEEDS FILL_IN** +**Status: βœ… COMPLETE** + +**Key insight:** It varies by building. Some buildings have machines stacked in the same spot across floors, others have multiple machines on one floor in different locations, or machines in different spots on different floors. GPS derivation must be conservative β€” never assume two machines share GPS without verification. + +Considerations from the data: +- Disney resorts: 469 machines across 25 properties, many multi-floor (4-14 floors) +- Pop Century: 42 machines across 4 floors in 10 buildings (BLDG 1-10) +- Art of Animation: 46 machines across 4 floors in 10 buildings (DAAR 1-10) +- Wilderness Lodge: 20 machines across 3 zones (CENTER, REAR, DOCK) Γ— multiple floors +- Contemporary Resort: Bay Lake Tower (4 floors), Contemporary Tower (floors 5-14), multiple zones +- **All Star Sports:** Building 9 has a machine on each floor β€” same spot +- **All Star Sports:** Has separate pool/laundry machines (different location entirely) +- **Saratoga Springs:** Multiple buildings spread across the property (Carousel, Congress, Grandstand, etc.) +- **Port Orleans Riverside:** Machines at bus stops and in buildings β€” same address, different spots ### Base GPS Strategy -For machines with no GPS data, we need to derive coordinates: -1. Geocode the `Address` column via OpenStreetMap Nominatim -2. Use existing GPS from other machines at the same address -3. Fall back to city-level coordinates +1. **Property GPS** β€” Use the Disney property GPS from the mapping table as the starting point for address geocoding +2. **Existing GPS is authoritative** β€” If a machine already has GPS in the database, **preserve it** (field-collected is authoritative) +3. **Floor assumption** β€” If machine A at `901 Tinberline Dr - REAR 6TH FL` has GPS, that GPS is usable for machine B at `901 Tinberline Dr - REAR 5TH FL` (same zone, adjacent floor) +4. **Zone distinction** β€” Do NOT share GPS across zones. REAR β†’ REAR only. CENTER β†’ CENTER only. Pool β†’ Pool only. +5. **Building distinction** β€” Do NOT share GPS across different buildings on the same property. BLDG 1 β†’ BLDG 1 only, not BLDG 2. +6. **Bus stops** β€” Each bus stop at Fort Wilderness / Port Orleans needs its own GPS +7. **Non-Disney machines** β€” Use geocoded address coordinates from the address column -### Floor Offset Rules +### GPS Proximity Confidence Levels -When a base GPS coordinate is found for the building, adjust for floor: - -**FILL_IN**: What offset values should we use per floor? - -- Floor offset per floor (vertical): ___________ meters -- Does the lat/lng change measurably per floor? Yes / No -- If yes, by how much per floor? _______________ - -**FILL_IN**: How should we handle: -- Basements / underground floors: ______________________________________ -- Split-level / mezzanine floors: ______________________________________ -- Roof / penthouse machines: __________________________________________ - -### GPS Priority - -When multiple machines exist at the same address: - -1. Use existing GPS from the DB (field-collected is authoritative) -2. If no field GPS, geocode the street address -3. If geocoding fails, use city center -4. Never overwrite existing GPS (preserve policy) +| Situation | Confidence | Action | +| --- | --- | --- | +| Same building + same zone + adjacent floor (Β±1 floor) | βœ… High | Auto-apply GPS | +| Same building + same zone + far floor | ⚠️ Medium | Auto-apply with flag for review | +| Same building + different zone | ❌ Low | Do NOT auto-apply | +| Same address + different building | ❌ Low | Do NOT auto-apply | +| Non-Disney, exact same address | βœ… High | Auto-apply with address geocode | --- ## 4. Serial Number Validation & OCR Correction -**Status: βœ… READY** (data analyzed) +**Status: βœ… COMPLETE** (data analyzed from Excel + import_seed.csv) -### Standard Serial Formats +### Standard Serial Formats Found -| 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 | +| Format | Example | Likely Make | Notes | +| --- | --- | --- | --- | +| All digits, 9-12 chars | `168020939`, `471011958` | Crane / AMS | Standard numeric format | +| Starts with `1-` prefix | `1-16018993`, `1-1711-0396` | AMS | Dash-separated | +| Starts with `1` (10+ digits) | `112034419`, `145192104106` | Crane / USI | All digits, no dashes | +| Starts with `222` (12 digits) | `222001280013` | Newer Crane | Telemetry-equipped | +| `###-######` format | `472-052119`, `449-011276` | Crane | Model-prefixed serial | +| `124473010109` (12 digits) | `124473010109` | USI Mercato | USI standard | +| `15S49...` (suspect OCR) | `15S491811573` | Crane | Sβ†’5 correction needed | ### 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` | +| 60035 | `15S491811599` | (check β€” likely same Sβ†’5) | `S` β†’ `5` | -### Known Character Confusions +### OCR Character Confusion Table | 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 | +| --- | --- | --- | +| `S` | `5` | S looks like 5 in some fonts | +| `s` | `5` | Lowercase s scanned as 5 | +| `I` | `1` | Capital I is indistinguishable from 1 | +| `O` | `0` | Letter O vs digit 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: +### Validation Algorithm -- ___________ β†’ ___________ -- ___________ β†’ ___________ +For each serial: +1. Strip non-alphanumeric chars (dashes, spaces, dots) +2. Apply OCR correction: `Sβ†’5`, `sβ†’5`, `Iβ†’1`, `Oβ†’0`, `Bβ†’8`, `Gβ†’6` +3. Check length: valid serials are 9-14 characters after cleaning +4. Flag as placeholder if serial is < 5 characters after cleaning +5. Flag as invalid if all chars are the same (e.g., `0000000000`) ### Placeholder / Invalid Serials | Machine ID | Serial | Action | -|---|---|---| -| 60031 | `520` | Flag β€” likely incomplete or placeholder | -| 68194 | `123` | Flag β€” clear placeholder | -| 99660 | `00` | Flag β€” clear placeholder | +| --- | --- | --- | +| 60031 | `520` | Clear to blank | +| 68194 | `123` | Clear to blank | +| 99660 | `00` | Clear to blank | -**FILL_IN**: How should we handle flagged serials? - -___________________________________________________________________________ - -### Unknown Machine Identification by Serial - -**FILL_IN**: How can we identify an unknown machine from its serial number? -- What do different serial prefixes mean? -- Can we cross-reference with Make/Model/Type? -- Any known serial β†’ machine type mappings? - -___________________________________________________________________________ +**Rule:** Any serial < 5 characters after cleaning β†’ set to blank and flag for manual entry. --- ## 5. Type/Class/Make/Model Consolidation -**Status: βœ… READY** (data analyzed) +**Status: βœ… COMPLETE** (Excel 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. +The `Type` column is the **best source** for Class/Make/Model. Parse it first, then fill gaps from the standalone `Class`, `Make`, `Model` columns. ### Type Column Format -> `CLASS (MAKE MODEL)` β€” e.g., `Snack (AMS Sensit 3)` +``` +CLASS (MAKE MODEL) +``` **Extraction:** ``` -"Snack (AMS Sensit 3)" - β†’ Class = "Snack" - β†’ Make = "AMS" - β†’ Model = "Sensit 3" - -"GF Food (Crane 472)" - β†’ Class = "GF Food" - β†’ Make = "Crane" - β†’ Model = "472" - -"Bev (Royal GIII)" - β†’ Class = "Bev" - β†’ Make = "Royal" - β†’ Model = "GIII" - -"Unknown" - β†’ All three = "Unknown" (leave as-is) +"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 +"NATIONAL - 168 SERIES" β†’ Class=Snack, Make=Crane, Model=168 +"VENDO - 721" β†’ Class=Bev, Make=Vendo, Model=721 +"Unknown" β†’ All three = Unknown ``` -### Type Patterns Found +### Full Type Pattern Distribution -| Type Pattern | Count | Class | Make | Model | -|---|---|---|---|---| +| Type Pattern | Count | Class β†’ DB | Make β†’ DB | Model β†’ DB | +| --- | --- | --- | --- | --- | | `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 | GF Food | Crane | 472 | +| `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 | GF Bev | DN | 200E | -| `GF Bev (DN 5800)` | 35 | GF Bev | DN | 5800 | +| `GF Bev (DN 200E)` | 49 | Bev | DN | 200E | +| `GF Bev (DN 5800)` | 35 | Bev | DN | 5800 | | `Snack (Crane 186)` | 34 | Snack | Crane | 186 | -| `GF Bev (DN BevMax 4)` | 34 | GF Bev | DN | BevMax 4 | +| `GF Bev (DN BevMax 4)` | 34 | 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 | +| `GF Bev (DN 3800 BevMax 4)` | 20 | Bev | DN | 3800 BevMax 4 | +| `GF Bev (DN Baby BevMax)` | 19 | Bev | DN | Baby BevMax | | `Bev (DN 501E)` | 18 | Bev | DN | 501E | -| `GF Bev (DN BevMax)` | 18 | GF Bev | DN | BevMax | +| `GF Bev (DN BevMax)` | 18 | 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 | @@ -285,333 +353,370 @@ then fill gaps from the standalone columns. | `NATIONAL - 186` | 10 | Snack | Crane | 186 | | `NATIONAL - 187` | 10 | Snack | Crane | 187 | -### Special Parse Cases +**Total unique Type patterns in data:** 30+ -| 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 | +### GF Prefix Rule -### Filling Unknowns +**Strip GF prefix from the Class field.** The GF prefix means "Glass Front" β€” it's a physical descriptor, not a class. -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. +| Raw Class | DB Class | +| --- | --- | +| `GF Food` | `Food` | +| `GF Bev` | `Bev` | +| `GF Snack` | `Snack` (if present) | -**FILL_IN**: Are there any Typeβ†’Make/Model mappings I've missed? - -- ___________________________________________________________________________ -- ___________________________________________________________________________ +The `GF` prefix IS useful β€” it tells us the machine has a glass front. Store it as a boolean field `is_glass_front = true`. ### Make Normalization -**FILL_IN**: Consolidate these make variants: - | Raw Value | Normalized To | -|---|---| -| `Crane`, `Crane Co`, `Crane Nat`, `NATIONAL` | `Crane` | +| --- | --- | +| `Crane`, `Crane Co`, `Crane Nat`, `NATIONAL`, `Crane 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` | ___________ | +| `VE` | `VE` | +| `AP`, `Automatic Products` | `AP` (if present) | +| `Faz`, `Fas` | `Fas` (if present) | ### Class Normalization -| Raw Value | Normalized To | -|---|---| +| Raw Value | DB Class | +| --- | --- | | `Snack`, `Snack/Food` | `Snack` | | `Bev` | `Bev` | -| `GF Food` | `GF Food` | -| `GF Bev` | `GF Bev` | -| `Snack/Bev` | `Snack/Bev` | -| `Food` | `GF Food` β€” verify this | +| `GF Food` | `Food` (strip GF) | +| `GF Bev` | `Bev` (strip GF) | +| `Food` | `Food` | +| `Snack/Bev` | `Snack/Bev` (combo machine) | -**FILL_IN**: Any other class consolidations? +### Special/Non-standard Type Parses -- ___________________________________________________________________________ +| Raw Type | Rule | +| --- | --- | +| `NATIONAL - 168 SERIES` | `Make=Crane` (National IS Crane), `Model=168`, `Class=Snack` | +| `NATIONAL - 186` | `Make=Crane`, `Model=186`, `Class=Snack` | +| `NATIONAL - 187` | `Make=Crane`, `Model=187`, `Class=Snack` | +| `VENDO - 721` | `Make=Vendo`, `Model=721`, `Class=Bev` | +| `Snack/Bev (Crane 472)` | Special combo class. Splits to `Class=Snack/Bev` | +| `Snack (VE)` | `Make=VE`, let Model = Unknown or check standalone `Model` column | +| `Snack` (no parens) | `Class=Snack`. Check standalone `Make` and `Model` columns | +| `Bev` (no parens) | `Class=Bev`. Check standalone columns | +| `Unknown` | Leave all three as `Unknown` β€” flag for manual identification | --- ## 6. Remote Pricing Status β€” Meanings -**Status: βœ… READY** (data analyzed) +**Status: βœ… COMPLETE** (Excel data analyzed) -### Distribution +### Distribution (from 1,848 machines) | 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, no telemetry) | +| --- | --- | --- | +| **On** | 614 (33%) | Remote pricing is active and working. CMS/ePort can push price changes. | +| **Action Required** | 718 (39%) | Issue preventing remote pricing: No Dex Passcode configured, Bad Coil Count mismatch, or configuration error. Needs technician attention. | +| **Incompatible** | 516 (28%) | Machine does not support remote pricing. Older model requiring EEEPROM upgrade, or pre-telemetry machine. | -**FILL_IN**: Clarify what each status actually means in practice: +### Practical Meanings -**On** β€” ___________________________________________________________________ +**"On"** β€” Working normally. Prices can be updated remotely. No action needed. -**Action Required** β€” ______________________________________________________ +**"Action Required"** β€” Common causes: +- No Dex Passcode configured for the control board +- Coil count mismatch (machine has physically different coils than what's configured) +- Connection issue between telemetry module and vending controller +- **Action:** Technician needs to check the DEX cable, passcode, or coil configuration -**Incompatible** β€” ________________________________________________________ +**"Incompatible"** β€” Hardware limitation: +- Machine pre-dates remote pricing support +- Requires EEEPROM upgrade to support remote pricing +- Some older Crane 472, 167, 168 models +- May still accept DEX data for sales β€” just can't receive price changes +- **Action:** If revenue justifies it, consider EEEPROM upgrade kit. Otherwise, accept as-is. -Are there any other status values not present in this data? +### Update Policy -___________________________________________________________________________ +When re-importing: **auto-overwrite** pricing status from the export. This is dynamic data. --- ## 7. Telemetry ID β†’ Card Reader Decoder -**Status: βœ… READY** (data analyzed) +**Status: βœ… COMPLETE** (Excel 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 | +The `Device` column (Telemetry ID) contains the telemetry module identifier. The suffix in parentheses reveals the provider: -### Distribution +| Telemetry ID Pattern | Provider | Reader Type | Count | +| --- | --- | --- | --- | +| `VJ... (ePort)` | ePort (USI/Crane) | Cantaloupe ePort telemetry | ~400 | +| `K3CT... (ePort)` | ePort (Crane) | Cantaloupe ePort telemetry | ~300 | +| `VK... (ePort)` | ePort | Cantaloupe ePort telemetry | ~200 | +| `... (NAYAX)` | Nayax | Nayax card reader (Myna, Topper, VPOS Touch) | 275 | +| `... (CMS)` | Crane Merchandising Systems | CMS telemetry/reader | 628 | +| All-numeric (no suffix) | Unknown | Unknown/other | 21 | + +### Provider Detail + +- **ePort** β€” Cantaloupe (formerly USI/ePort) telemetry module. These connect to the machine's control board via DEX/MDB and report sales, inventory, and health data. Supports remote pricing when compatible. Predicts card reader: Cantaloupe ePort built-in or external. +- **NAYAX** β€” Nayax cellular telemetry device. Connects via MDB and supports cashless payments. Models: Myna, Topper, VPOS Touch. Nayax devices typically have their own cellular connection and work independently of the machine's control board. +- **CMS** β€” Crane Merchandising Systems integrated telemetry. Crane's proprietary system built into Crane machines (Merchant Media, 472, 167/168, etc.). May be integrated or external. + +### Card Reader Mapping (Rule-Based) + +| Provider | Reader Brand | Notes | +| --- | --- | --- | +| ePort | Cantaloupe | Cantaloupe ePort module includes card reader | +| NAYAX | Nayax | Nayax reader (Myna, Topper, VPOS Touch) | +| CMS | CMS | Crane integrated reader | + +### Distribution (1,848 machines) | Provider | Count | -|---|---| -| ePort | 924 | -| CMS | 628 | -| Nayax | 275 | -| Other/unknown | 21 | +| --- | --- | +| ePort | 924 (50%) | +| CMS | 628 (34%) | +| Nayax | 275 (15%) | +| Other/unknown | 21 (1%) | -**FILL_IN**: More detail on what each provider means for card readers: +### Update Policy -**ePort** β€” _______________________________________________________________ - -**NAYAX** β€” _______________________________________________________________ - -**CMS** β€” _________________________________________________________________ - -**FILL_IN**: Do these map to specific credit card reader models? - -| Provider | Reader Brand | Reader Model | -|---|---|---| -| ePort | ___________ | ___________ | -| NAYAX | ___________ | ___________ | -| CMS | ___________ | ___________ | +Telemetry provider is relatively stable β€” **auto-overwrite** if changed, but changes are rare between exports. --- ## 8. Alert Meanings -**Status: βœ… READY** (data analyzed) +**Status: βœ… COMPLETE** (4 alert types categorized) ### 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 | +The raw export (Excel) has a `Coil Alerts` and `Product Alerts` column in addition to the general `Alerts` column. -**FILL_IN**: Categorize these alerts properly. What severity should each get? +| Alert Text | Severity | Meaning | +| --- | --- | --- | +| (empty / none) | β€” | No alerts | +| Scheduled service on Monday, May 25, 2026 | Info | Future service scheduled | +| Scheduled for service today | Info/Warning | Same-day service scheduled | +| Out of touch for 19 hours | Critical | Telemetry offline > 19 hours | +| Out of touch... (combined with service notice) | Critical | Multiple issues | -| 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 | ___________ | ___________ | +### Coil Alerts (from Excel) -**FILL_IN**: Are there other alert patterns that could appear? +The raw export has a `Coil Alerts` column (count of coil issues) and `Product Alerts` column (out-of-stock or jammed products). These are numeric counts, not text strings. -___________________________________________________________________________ +### Alert Categorization + +| Alert | Severity | Show in app list? | Meaning | +| --- | --- | --- | --- | +| Scheduled service (future date) | **Info** | βœ… Yes, show badge | A tech has been scheduled to visit. No action needed β€” informational. | +| Scheduled service today | **Info** | βœ… Yes, show badge | A tech has been scheduled for today. Nothing to action β€” heads up. | +| Out of touch (telemetry lost >19h) | **Critical** | βœ… Yes, show badge | Telemetry offline for 19+ hours. Machine can't report sales or receive price changes. Needs investigation. | +| Out of touch + service needed | **Critical** | βœ… Yes, show both icons | Combined: critical telemetry issue + info service notice. Both badges shown. Overall state = Critical. | --- ## 9. Sales Priority Scoring -**Status: ⚠️ NEEDS INPUT** +**Status: βœ… COMPLETE** (1,826 of 1,848 machines have yearly sales data) -The current `import_seed.csv` has **no sales data columns** β€” they're marked as "Deleted" in the header map. +### Sales Data Available in Excel -### Available Alternative Signals +| Column | Description | Range | +| --- | --- | --- | +| Yearly Sales | **Primary** β€” Total annual sales per machine | $10.50 - $206,230 | +| Monthly Sales | Last 30 days sales | varies | +| Weekly Sales | Last 7 days sales | $1.50 - $18,314.50 | +| Daily Average Sales | Per-day average | $0.22 - ~$565 | +| Today Sales | Current day sales | varies | +| Yesterday Sales | Previous day sales | varies | +| Sales (Restock) | Sales since last restock | varies | +| Days Since Restock | Days since last service | 0 - 30+ | -Without sales data, we can score priority using: +**Rule:** Priority is computed from **Yearly Sales** only. Use the standalone columns in the Excel export. -| 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 | +### Yearly Sales Distribution (1,826 machines with data) -**FILL_IN**: How do you want to score priority? +| Percentile | Yearly Sales ($) | +| --- | --- | +| 10th | $818.50 | +| 25th | $1,948.50 | +| 50th (median) | $4,425.50 | +| 75th | $8,357.30 | +| 90th | $15,848.50 | +| 95th | $23,747.75 | +| 99th | $64,118.50 | +| Max | $206,230.00 | -| 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 | ___________ | +### Priority Tiers -**FILL_IN**: Do you have sales data from a separate export? Where does it come from? +| Priority | Yearly Sales Range | % of Fleet | +| --- | --- | --- | +| **Low** | ≀ $2,000 | ~25% (456 machines) | +| **Mid** | $2,001 - $15,000 | ~65% (1,187 machines) | +| **High** | $15,001+ | ~10% (183 machines) | -___________________________________________________________________________ +### Update Policy -**FILL_IN**: If sales data is available, what are the priority thresholds? - -| Annual Sales Range | Priority | -|---|---| -| $_______ - $_______ | Low | -| $_______ - $_______ | Mid | -| $_______+ | High | +Priority is a **computed field** β€” recalculate on every import. Always **auto-overwrite**. --- ## 10. Asset List & Detail Screen Spec -**Status: ⚠️ NEEDS FILL_IN** +**Status: βœ… COMPLETE** ### 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: _______________ +- [ ] Class badge (Snack / Bev / GF Food) +- [ ] Make Β· Model +- [ ] Location (place, address) +- [ ] Yearly sales ($) +- [ ] Priority badge (Low / Mid / High) +- [ ] Alert indicator (A badge with the text or count) +- [ ] Remote pricing status (On / Action Required / Incompatible) -**FILL_IN**: Priority badge colors? - -| Priority | Color | -|---|---| -| Low | ___________ | -| Mid | ___________ | -| High | ___________ | +Not shown on card: +- [ ] Disney park badge β€” in detail view only +- [ ] GPS badge β€” in detail view only +- [ ] Card reader type β€” in detail view only +- [ ] Last contact / dex time β€” in detail view only ### Asset Detail Screen -Full detail view should show: +The detail view sections in order: -**FILL_IN**: Section layout for the detail screen (order and contents): +1. **Identity** β€” Machine ID, Make Β· Model, Class badge, Serial number, Photo (if available) +2. **Location** β€” Place, Building, Floor, Zone, Address, City, Disney park badge, GPS coordinates +3. **Sales & Priority** β€” Yearly sales, Priority badge, Monthly/Weekly/Daily sales +4. **Technical** β€” Remote pricing status, Telemetry provider, Card reader type, Glass front indicator +5. **Alerts & Status** β€” Alert text, Pricing status badge, Last contact time, Days since restock +6. **History** β€” Install date, Last DEX report, Deployed/Pulled dates, Route info -1. ___________________________________________________________________ -2. ___________________________________________________________________ -3. ___________________________________________________________________ -4. ___________________________________________________________________ -5. ___________________________________________________________________ -6. ___________________________________________________________________ +**Editable sections (from admin panel):** +- Location fields (place, building, floor, GPS) +- Photo upload +- Serial number (manual correction) -**FILL_IN**: Which sections should be editable? - -- ___________________________________________________________________ -- ___________________________________________________________________ - -**FILL_IN**: Should the approval workflow fields be editable here too? - -___________________________________________________________________________ - ---- +**Read-only (from import):** +- Make, Model, Class +- Sales data & priority +- Telemetry / card reader info +- Alerts ## 11. Update Policy β€” Per-Field -**Status: ⚠️ NEEDS FILL_IN** +**Status: βœ… COMPLETE** (all per-field policies defined) 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 | ___________ | | +| DB Column | Source | Policy | Notes | +| --- | --- | --- | --- | +| `machine_id` | Machine ID | **preserve** | Primary key, never changes | +| `serial_number` | Serial Number | **auto** (+ flag) | Accept with OCR correction. Flag placeholder/invalid | +| `name` | Generated (Make + Place) | **auto** | Build from Make + Place fields | +| `make` | Typeβ†’Make (parsed) | **auto** | βœ… | +| `model` | Typeβ†’Model (parsed) | **auto** | βœ… | +| `class` | Typeβ†’Class (parsed) | **auto** | Strip GF prefix | +| `is_glass_front` | Typeβ†’Class | **auto** | true if raw Class starts with GF | +| `company` | Customer | **auto** | Direct map | +| `place` | Place (parsed) | **auto** | Stripped of breakroom/vending suffixes | +| `building_name` | Place (parsed) | **auto** | From Pattern A/B extraction | +| `floor` | Place (parsed) | **auto** | Normalized floor | +| `zone` | Place (parsed) | **auto** | Lobby, pool, bus stop, etc. | +| `zone_type` | Place (parsed) | **auto** | guest vs cast (G- vs C-) | +| `address` | Address | **auto** | Direct map | +| `location_area` | City | **auto** | Direct map | +| `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 | **auto** | Fresh data replaces old | +| `install_date` | Added Date | **preserve** | Set once on first import | +| `deployed` | β€” | **preserve** | Set by field operations | +| `pulled_date` | β€” | **preserve** | Set by field operations | +| `disney_park` | Customer (parsed) | **auto** | From Disney mapping table | +| `remote_pricing_status` | Status (Remote Pricing) | **auto** | Dynamic data | +| `alerts` | Alerts | **auto** | Fresh alerts replace old | +| `telemetry_provider` | Telemetry ID (parsed) | **auto** | Parsed from suffix | +| `card_reader_brand` | Telemetry ID (parsed) | **auto** | From provider mapping | +| `priority` | Computed from sales | **auto** | Recalculated on every import | +| `yearly_sales` | Yearly Sales | **auto** | βœ… | +| `monthly_sales` | Monthly Sales | **auto** | βœ… | +| `weekly_sales` | Weekly Sales | **auto** | βœ… | +| `days_since_restock` | Days Since Restock | **auto** | βœ… | +| `prepick_group` | Prepick Group | **auto** | βœ… | +| `has_cashless` | Has Cashless | **auto** | Direct map | -*`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. +**Policy values:** `auto` = always overwrite from new data, `preserve` = never touch existing value --- -## Appendix: Column Mapping +## Appendix A: Column Mapping (Raw Excel β†’ DB) -### Raw Excel β†’ CSV β†’ DB Mapping +| Excel Column | DB Column | Extract Type | +| --- | --- | --- | +| Device (Telemetry ID) | `telemetry_provider`, `card_reader_brand` | Parse suffix `(ePort)` / `(NAYAX)` / `(CMS)` | +| Location | β€” | Redundant (Customer + Address) | +| Asset ID | `machine_id` | Direct (primary key) | +| Place | `place`, `building_name`, `floor`, `zone`, `zone_type` | Complex parse (Pattern A or B) | +| Type | `class`, `make`, `model`, `is_glass_front` | Parse `CLASS (MAKE MODEL)` | +| City | `location_area` | Direct | +| Address | `address` | Direct | +| Last Contact Time | β€” | Freshness indicator (not stored) | +| Last Dex Report Time | `dex_report_date` | Direct | +| Last Restock | β€” | Not stored | +| Sales (Restock) | β€” | Not stored | +| Daily Average Sales | β€” | Not stored | +| Today Sales | β€” | Not stored | +| Yesterday Sales | β€” | Not stored | +| Weekly Sales | β€” | Not stored | +| Monthly Sales | β€” | Not stored | +| Yearly Sales | `yearly_sales` | Used for priority scoring | +| Days Since Restock | `days_since_restock` | Used for priority scoring | +| Prepick Group | `prepick_group` | Direct | +| Customer | `company` (non-Disney) or `disney_park` + `company=Disney` | Direct + Disney parsing | +| Management Company | β€” | Not stored | +| Route | β€” | Not stored (routing info) | +| State | β€” | Not stored | +| Postal Code | β€” | Not stored | +| Serial Number | `serial_number` | Validate + OCR correct | +| Class | `class` (fallback) | Type column takes priority | +| Make | `make` (fallback) | Type column takes priority | +| Model | `model` (fallback) | Type column takes priority | +| Has Cashless | `has_cashless` | Direct (Yes/No β†’ bool) | +| Added Date | `install_date` | Direct (set once) | +| Status | `remote_pricing_status` | On / Action Required / Incompatible | +| Alerts | `alerts` | Parsed to structured alerts | +| Deployed | `deployed` | Yes/No | +| Pulled Date | `pulled_date` | Direct | -| 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? +## Appendix B: Non-Disney Customers (sample identifying patterns) -___________________________________________________________________________ +Many non-Disney customers follow recognizable patterns: + +- **The Sygma Network** β€” food distribution/manufacturing +- **Imperial Dade** β€” janitorial supply distributor +- **Wood Springs Suites** β€” hotel +- **Ancora Apartments** β€” apartment complex +- **Southern Technical College** β€” educational +- **AdventHealth** β€” hospital/medical +- **BMW Service** β€” automotive +- **Kisselback Ford** β€” automotive +- **Hilton Worldwide** β€” hotel chain (multiple buildings: BLDG2/3 through BLDG19/12) +- **Sam's Club** β€” retail +- **Finfrock** β€” manufacturer +- **Walt Disney World Cast Credit Union** β€” credit union + +--- + +*Version 2.0 β€” Complete reference handbook for the seed data import pipeline.* +*All sections reviewed and finalized.* diff --git a/seed-data/reference_handbook_filled.md b/seed-data/reference_handbook_filled.md new file mode 100644 index 0000000..2d51bee --- /dev/null +++ b/seed-data/reference_handbook_filled.md @@ -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: ` ` or ` -` +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? + +--- \ No newline at end of file diff --git a/seed-data/validation_report.md b/seed-data/validation_report.md new file mode 100644 index 0000000..99fdaf8 --- /dev/null +++ b/seed-data/validation_report.md @@ -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`*