Files
shawn ae3b114ebc feat: import Seed (mycantaloupe.com) data — 1,655 assets enriched
- Added import_seed.py — reads Machine List(8).xlsx, matches by Asset ID → machine_id
- 51 new columns on assets table (route, barcode, sales, contact info, etc.)
- seed_data table with full 62-field JSON per asset for detail enrichment
- Seed data card in frontend asset detail view
- GET /api/assets/{id} now returns result.seed and result.seed_imported_at
- GPS columns from Seed ignored per request
- 1,654 matched, 199 unmatched (Seed-only assets not in MSFS)

Refs #47
2026-05-28 23:28:08 -04:00

293 lines
12 KiB
Python

#!/usr/bin/env python3
"""
Import Seed (mycantaloupe.com) Excel data into the Canteen Asset Tracker.
Steps:
1. Read Machine List(8).xlsx — 62 columns of Seed export
2. Match rows by Asset ID → assets.machine_id
3. Add key columns to assets table
4. Create seed_data table with full JSON per asset
5. Update matched assets, report match stats
Usage:
python3 import_seed.py
"""
import json
import os
import sqlite3
import sys
from datetime import datetime, date
import openpyxl
# ── Paths ──────────────────────────────────────────────────────────────────
PROJECT_DIR = os.path.expanduser("~/projects/canteen-asset-tracker")
DB_PATH = os.path.join(PROJECT_DIR, "assets.db")
EXCEL_PATH = os.path.expanduser(
"/home/oplabs/.hermes/profiles/coder/cache/documents/doc_305e40fb92dd_Machine List(8).xlsx"
)
# ── Columns to extract as first-class DB columns ──────────────────────────
# (db_column, excel_header, type)
KEY_COLUMNS = [
("device", "Device", "TEXT DEFAULT ''"),
("seed_location", "Location", "TEXT DEFAULT ''"),
("seed_city", "City", "TEXT DEFAULT ''"),
("state", "State", "TEXT DEFAULT ''"),
("postal_code", "Postal Code", "TEXT DEFAULT ''"),
("route_name", "Route", "TEXT DEFAULT ''"),
("subroute_name", "Subroute", "TEXT DEFAULT ''"),
("seed_class", "Class", "TEXT DEFAULT ''"),
("customer_name", "Customer", "TEXT DEFAULT ''"),
("management_company", "Management Company", "TEXT DEFAULT ''"),
("branch", "Branch", "TEXT DEFAULT ''"),
("location_code", "Location Code", "TEXT DEFAULT ''"),
("customer_code", "Customer Code", "TEXT DEFAULT ''"),
("barcode", "Barcode", "TEXT DEFAULT ''"),
("has_cashless", "Has Cashless", "TEXT DEFAULT ''"),
("phone", "Phone", "TEXT DEFAULT ''"),
("fax", "Fax", "TEXT DEFAULT ''"),
("email", "Email", "TEXT DEFAULT ''"),
("asset_family", "Asset Family", "TEXT DEFAULT ''"),
("business_type", "Business Type", "TEXT DEFAULT ''"),
("primary_consumer_type","Primary Consumer Type","TEXT DEFAULT ''"),
("machine_branding", "Machine Branding", "TEXT DEFAULT ''"),
("valid_address", "Valid Address", "TEXT DEFAULT ''"),
("non_revenue", "Non-Revenue", "TEXT DEFAULT ''"),
("alerts", "Alerts", "TEXT DEFAULT ''"),
("coil_alerts", "Coil Alerts", "INTEGER DEFAULT 0"),
("product_alerts", "Product Alerts", "INTEGER DEFAULT 0"),
("daily_avg_sales", "Daily Average Sales", "REAL DEFAULT NULL"),
("monthly_sales", "Monthly Sales", "REAL DEFAULT NULL"),
("yearly_sales", "Yearly Sales", "REAL DEFAULT NULL"),
("today_sales", "Today Sales", "REAL DEFAULT NULL"),
("yesterday_sales", "Yesterday Sales", "REAL DEFAULT NULL"),
("weekly_sales", "Weekly Sales", "REAL DEFAULT NULL"),
("sales_restock", "Sales (Restock)", "REAL DEFAULT NULL"),
("last_restock", "Last Restock", "TEXT DEFAULT ''"),
("days_since_restock", "Days Since Restock", "INTEGER DEFAULT NULL"),
("last_contact_time", "Last Contact Time", "TEXT DEFAULT ''"),
("last_dex_report_time", "Last Dex Report Time","TEXT DEFAULT ''"),
("last_inventory", "Last Inventory", "TEXT DEFAULT ''"),
("prepick_group", "Prepick Group", "TEXT DEFAULT ''"),
("added_date", "Added Date", "TEXT DEFAULT ''"),
("purchase_date", "Purchase Date", "TEXT DEFAULT ''"),
("purchase_price", "Purchase Price", "REAL DEFAULT NULL"),
("depreciation_years", "Depreciation Years", "REAL DEFAULT NULL"),
("cash_discount", "Cash Discount", "REAL DEFAULT NULL"),
("tax_jurisdiction", "Tax Jurisdiction", "TEXT DEFAULT ''"),
("commission_plan", "Commission Plan", "TEXT DEFAULT ''"),
("acquirer_from", "Acquired From", "TEXT DEFAULT ''"),
("changer_par", "Changer Par", "TEXT DEFAULT ''"),
("machine_management_code","Machine Management Code","TEXT DEFAULT ''"),
]
# ── All 62 columns for the seed_data JSON dump ────────────────────────────
ALL_SEED_FIELDS = [
"Device", "Location", "Asset ID", "Place", "Type",
"City", "Address", "Last Contact Time", "Last Dex Report Time",
"Last Restock", "Coil Alerts", "Product Alerts", "Sales (Restock)",
"Daily Average Sales", "Today Sales", "Yesterday Sales", "Weekly Sales",
"Monthly Sales", "Yearly Sales", "Days Since Restock", "Prepick Group",
"Customer", "Management Company", "Management Account",
"Machine Management Code", "Route", "Subroute", "Changer Par",
"Acquired From", "Purchase Date", "Purchase Price", "Depreciation Years",
"Post-Depreciation Monthly Cost", "State", "Postal Code", "Deployed",
"Pulled Date", "Serial Number", "Class", "Make", "Model",
"Cash Discount", "Tax Jurisdiction", "Commission Plan", "Barcode",
"Non-Revenue", "Added Date", "Phone", "Fax", "Email", "Has Cashless",
"Branch", "Location Code", "Customer Code", "Last Inventory",
"Asset Family", "Status", "Alerts", "Valid Address",
"Business Type", "Primary Consumer Type", "Machine Branding",
]
# ── Schema migration helpers ──────────────────────────────────────────────
def get_db_columns(conn):
cursor = conn.execute("PRAGMA table_info(assets)")
return {row[1] for row in cursor.fetchall()}
def add_column_if_missing(conn, col_name, col_type):
existing = get_db_columns(conn)
if col_name not in existing:
sql = f"ALTER TABLE assets ADD COLUMN {col_name} {col_type}"
conn.execute(sql)
print(f" Added column: {col_name} {col_type}")
return True
return False
def create_seed_data_table(conn):
conn.execute("""
CREATE TABLE IF NOT EXISTS seed_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
asset_id INTEGER NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
raw_data TEXT NOT NULL,
imported_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(asset_id)
)
""")
print(" seed_data table ready")
# Add index
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_seed_data_asset_id
ON seed_data(asset_id)
""")
# ── Parse Excel ──────────────────────────────────────────────────────────
def read_excel():
"""Return (header_map, rows) where header_map is ExcelHeader->col_index."""
wb = openpyxl.load_workbook(EXCEL_PATH, data_only=True)
ws = wb.active
headers = {}
for col in range(1, ws.max_column + 1):
val = ws.cell(row=1, column=col).value
if val is not None:
headers[str(val).strip()] = col
rows = []
for row_idx in range(2, ws.max_row + 1):
row_data = {}
for hdr, col in headers.items():
val = ws.cell(row=row_idx, column=col).value
row_data[hdr] = val
row_data["_row"] = row_idx
rows.append(row_data)
return headers, rows
# ── Main import ──────────────────────────────────────────────────────────
def main():
print("═══ SEED DATA IMPORT ═══\n")
# 1. Connect to DB
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys=ON")
print(f"[DB] Connected: {DB_PATH}")
# 2. Schema migration
print("\n[MIGRATE] Adding new asset columns if missing...")
for col_name, excel_header, col_type in KEY_COLUMNS:
add_column_if_missing(conn, col_name, col_type)
print("\n[MIGRATE] Creating seed_data table...")
create_seed_data_table(conn)
conn.commit()
# 3. Read Excel
print(f"\n[READ] Reading {EXCEL_PATH}...")
headers, rows = read_excel()
print(f"[READ] Found {len(headers)} columns, {len(rows)} data rows")
# 4. Pre-build machine_id lookup
cursor = conn.execute("SELECT id, machine_id FROM assets")
machine_map = {} # machine_id -> asset_id
for row in cursor.fetchall():
machine_map[str(row["machine_id"]).strip()] = row["id"]
print(f"[MATCH] DB has {len(machine_map)} assets to match against")
# 5. Import
matched = 0
skipped_no_id = 0
unmatched = 0
key_field_updates = 0
json_inserts = 0
for row in rows:
asset_id_val = row.get("Asset ID")
if asset_id_val is None or str(asset_id_val).strip() == "":
skipped_no_id += 1
continue
asset_id_str = str(asset_id_val).strip()
db_asset_id = machine_map.get(asset_id_str)
if db_asset_id is None:
unmatched += 1
continue
# Update key columns on assets table
update_parts = []
update_vals = []
for col_name, excel_header, col_type in KEY_COLUMNS:
val = row.get(excel_header)
# Handle None → NULL for SQL
update_parts.append(f"{col_name} = ?")
if val is None:
update_vals.append(None)
else:
if "INTEGER" in col_type:
try:
update_vals.append(int(val))
except (ValueError, TypeError):
update_vals.append(0)
elif "REAL" in col_type:
try:
update_vals.append(float(val))
except (ValueError, TypeError):
update_vals.append(None)
else:
update_vals.append(str(val))
update_vals.append(db_asset_id)
conn.execute(
f"UPDATE assets SET {', '.join(update_parts)} WHERE id = ?",
update_vals,
)
key_field_updates += 1
# Insert/upsert seed_data JSON
raw_data = {}
for hdr in ALL_SEED_FIELDS:
val = row.get(hdr)
if val is not None:
raw_data[hdr] = val
# Convert non-serializable types (datetime, etc.) to strings
def _serialize(v):
if isinstance(v, (datetime, date)):
return v.isoformat()
return v
raw_json = json.dumps(raw_data, default=_serialize)
conn.execute(
"""INSERT OR REPLACE INTO seed_data (asset_id, raw_data, imported_at)
VALUES (?, ?, datetime('now'))""",
(db_asset_id, raw_json),
)
json_inserts += 1
matched += 1
conn.commit()
# 6. Stats
print(f"\n═══ RESULTS ═══")
print(f"Seed rows: {len(rows)}")
print(f"Matched (↔ assets): {matched}")
print(f"Unmatched (no asset): {unmatched}")
print(f"Rows without Asset ID: {skipped_no_id}")
print(f"Key field updates: {key_field_updates}")
print(f"JSON blobs inserted: {json_inserts}")
# Re-count assets with seed data
seed_count = conn.execute(
"SELECT COUNT(*) FROM seed_data"
).fetchone()[0]
print(f"\nseed_data table: {seed_count} rows")
# Show DB size
db_size = os.path.getsize(DB_PATH) / (1024 * 1024)
print(f"DB size: {db_size:.1f} MB")
conn.close()
print("\n═══ DONE ═══")
if __name__ == "__main__":
main()