0ac4beaaa8
- Add requireAuth() to admin-permissions.ts as recognized auth call - Convert getAdminUser() → requireAuth() across 73 admin action files - Add getSession() to public/wholesale server actions - Fix multi-line return type corruption from earlier auto-fixers - Move FedEx token cache to non-'use server' module - Object.freeze module-level constants: PRICE_KEYS, EMPTY_MOBILE_DASHBOARD, EMPTY_PAY_PERIOD, LOCALE_CART_SUBJECT, WELCOME_EMAILS - Update Stripe API version 2026-05-27 → 2026-06-24 - Fix wholesale employee portal: getEmployeeSessionAction + EmployeePortalClient - Fix 51 TypeScript errors (return type corruption, missing imports)
344 lines
11 KiB
Python
344 lines
11 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
seed_tuxedo_tour.py
|
||
|
||
Parses Tuxedo_Corn_2026_Tour_Schedule-3.xlsx and seeds the `stops` table for
|
||
the Tuxedo brand via the admin_create_stops_batch RPC.
|
||
|
||
Skips:
|
||
- Title / subtitle / legend rows (rows 1-3)
|
||
- Week header rows (col A = "Wk N", col D-J empty)
|
||
- Cross-Dock / Monday OFF rows (col D contains "OFF" or "Cross-Dock")
|
||
|
||
Joins with the Stop Directory sheet to enrich each stop with:
|
||
- address, phone, contact
|
||
|
||
Usage:
|
||
python3 scripts/seed_tuxedo_tour.py --dry-run # show what would be inserted
|
||
python3 scripts/seed_tuxedo_tour.py # actually insert
|
||
|
||
Requires:
|
||
- supabase CLI linked to project wnzkhezyhnfzhkhiflrp
|
||
"""
|
||
import argparse
|
||
import json
|
||
import re
|
||
import subprocess
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
from openpyxl import load_workbook
|
||
|
||
TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de"
|
||
YEAR = 2026
|
||
|
||
MONTH_MAP = {
|
||
"Jan": "01", "Feb": "02", "Mar": "03", "Apr": "04",
|
||
"May": "05", "Jun": "06", "Jul": "07", "Aug": "08",
|
||
"Sep": "09", "Oct": "10", "Nov": "11", "Dec": "12",
|
||
}
|
||
|
||
DEFAULT_XLSX = "Tuxedo_Corn_2026_Tour_Schedule-3.xlsx" # run from repo root; override with --xlsx /path/to/file.xlsx
|
||
|
||
|
||
def parse_excel_date(s):
|
||
"""'Jul 22' -> '2026-07-22'"""
|
||
if not s:
|
||
return None
|
||
m = re.match(r"^([A-Za-z]{3})\s+(\d{1,2})$", str(s).strip())
|
||
if not m:
|
||
return None
|
||
mm = MONTH_MAP.get(m.group(1))
|
||
if not mm:
|
||
return None
|
||
return f"{YEAR}-{mm}-{int(m.group(2)):02d}"
|
||
|
||
|
||
def parse_time_range(s):
|
||
"""'10:00 AM - 1:00 PM' -> '10:00 AM' (start time)"""
|
||
if not s:
|
||
return ""
|
||
cleaned = re.sub(r"[–—]", "-", str(s)).strip()
|
||
cleaned = re.sub(r"\s+", " ", cleaned)
|
||
m = re.match(r"^(\d{1,2}:\d{2}\s*[AP]M)", cleaned, re.IGNORECASE)
|
||
return m.group(1).upper().replace(" ", " ") if m else cleaned
|
||
|
||
|
||
def split_city_state(s):
|
||
"""'Cheyenne, WY' -> ('Cheyenne', 'WY')"""
|
||
if not s:
|
||
return "", ""
|
||
parts = [p.strip() for p in str(s).split(",")]
|
||
if len(parts) == 1:
|
||
return parts[0], ""
|
||
return parts[0], parts[1]
|
||
|
||
|
||
def slugify(s):
|
||
s = (s or "").lower()
|
||
s = re.sub(r"[^a-z0-9]+", "-", s)
|
||
return s.strip("-")
|
||
|
||
|
||
def is_week_header(row):
|
||
a = str(row[0] or "").strip()
|
||
d = str(row[3] or "").strip()
|
||
return re.match(r"^Wk\s", a) and d == ""
|
||
|
||
|
||
def is_off_row(row):
|
||
d = str(row[3] or "").strip()
|
||
return "OFF" in d or "Cross-Dock" in d or "Cross‑Dock" in d
|
||
|
||
|
||
def is_data_row(row):
|
||
d = str(row[3] or "").strip()
|
||
e = str(row[4] or "").strip()
|
||
if not d or not e:
|
||
return False
|
||
if "," not in e:
|
||
return False
|
||
return True
|
||
|
||
|
||
def load(xlsx_path):
|
||
wb = load_workbook(xlsx_path, data_only=True)
|
||
schedule = wb["Full Schedule"]
|
||
directory = wb["Stop Directory"]
|
||
|
||
# Build Stop Directory lookup: (truck, host_normalized) -> {address, phone, contact, ...}
|
||
dir_map = {}
|
||
for row in directory.iter_rows(min_row=2, values_only=True):
|
||
truck = str(row[0] or "").strip()
|
||
city = str(row[1] or "").strip()
|
||
state = str(row[2] or "").strip()
|
||
host = str(row[3] or "").strip()
|
||
address = str(row[4] or "").strip()
|
||
phone = str(row[5] or "").strip()
|
||
contact = str(row[6] or "").strip()
|
||
if not truck or not host:
|
||
continue
|
||
key = f"{truck}|{host.lower()}"
|
||
dir_map[key] = {
|
||
"city": city, "state": state, "host": host,
|
||
"address": address, "phone": phone, "contact": contact,
|
||
}
|
||
|
||
# Read Full Schedule (skip first 3 title/subtitle/legend rows)
|
||
stops = []
|
||
skipped = {"weekHeader": 0, "off": 0, "invalid": 0}
|
||
for row in schedule.iter_rows(min_row=4, values_only=True):
|
||
# Trim to 10 cols
|
||
cells = [("" if v is None else str(v).strip()) for v in row[:10]]
|
||
if is_week_header(cells):
|
||
skipped["weekHeader"] += 1
|
||
continue
|
||
if is_off_row(cells):
|
||
skipped["off"] += 1
|
||
continue
|
||
if not is_data_row(cells):
|
||
skipped["invalid"] += 1
|
||
continue
|
||
|
||
wk, region, date_text, day, city_state, host, time, truck, status, notes = cells
|
||
date_iso = parse_excel_date(date_text)
|
||
if not date_iso:
|
||
skipped["invalid"] += 1
|
||
continue
|
||
city, state = split_city_state(city_state)
|
||
if not city:
|
||
skipped["invalid"] += 1
|
||
continue
|
||
|
||
# Enrich from directory
|
||
dir_key = f"{truck}|{host.lower()}"
|
||
d = dir_map.get(dir_key)
|
||
|
||
stops.append({
|
||
"week": wk,
|
||
"region": region,
|
||
"date": date_iso,
|
||
"day": day,
|
||
"city": city,
|
||
"state": state or (d["state"] if d else ""),
|
||
"location": host,
|
||
"time": parse_time_range(time),
|
||
"time_range": time,
|
||
"truck": truck,
|
||
"status_text": status,
|
||
"notes": notes,
|
||
"address": d["address"] if d and d["address"] else None,
|
||
"phone": d["phone"] if d and d["phone"] else None,
|
||
"contact": d["contact"] if d and d["contact"] else None,
|
||
})
|
||
|
||
return stops, skipped, len(dir_map)
|
||
|
||
|
||
def assign_slugs(stops, dry_run):
|
||
used = set()
|
||
if not dry_run:
|
||
out = subprocess.run(
|
||
["supabase", "db", "query", "--linked",
|
||
f"SELECT slug FROM stops WHERE brand_id = '{TUXEDO_BRAND_ID}';"],
|
||
capture_output=True, text=True, timeout=120,
|
||
)
|
||
# Parse the table output - slugs are in second column between │
|
||
for m in re.finditer(r"│\s*([a-z0-9][a-z0-9-]*)\s*│", out.stdout):
|
||
used.add(m.group(1))
|
||
|
||
for s in stops:
|
||
base = f"{slugify(s['city'])}-{s['date']}"
|
||
slug = base
|
||
n = 0
|
||
while slug in used:
|
||
n += 1
|
||
slug = f"{base}-{n}"
|
||
used.add(slug)
|
||
s["slug"] = slug
|
||
|
||
|
||
def to_rpc_row(s):
|
||
return {
|
||
"city": s["city"],
|
||
"state": s["state"],
|
||
"location": s["location"],
|
||
"date": f"{s['date']} 00:00:00+00",
|
||
"time": s["time"],
|
||
"address": s["address"],
|
||
"zip": None,
|
||
"cutoff_time": None,
|
||
# active=true so the stops appear on the public storefront immediately.
|
||
# Matches the behavior of publishStop in src/actions/stops.ts.
|
||
"active": True,
|
||
}
|
||
|
||
|
||
def build_payload_json(batch):
|
||
"""Build a clean JSON string for use in a SQL file."""
|
||
return json.dumps(batch, ensure_ascii=False)
|
||
|
||
|
||
def insert_batch(batch):
|
||
"""Write SQL to a temp file and execute via --file to avoid shell escaping."""
|
||
payload_json = build_payload_json(batch)
|
||
sql = (
|
||
f"SELECT admin_create_stops_batch("
|
||
f"'{TUXEDO_BRAND_ID}'::uuid, "
|
||
f"$${payload_json}$$::jsonb);\n"
|
||
)
|
||
# Write to temp file
|
||
tmp_path = Path("/tmp/seed_tuxedo_tour.sql")
|
||
tmp_path.write_text(sql, encoding="utf-8")
|
||
try:
|
||
proc = subprocess.run(
|
||
["supabase", "db", "query", "--linked", "--file", str(tmp_path)],
|
||
capture_output=True, text=True, timeout=300,
|
||
)
|
||
finally:
|
||
tmp_path.unlink(missing_ok=True)
|
||
if proc.returncode != 0:
|
||
raise RuntimeError(f"RPC failed: {proc.stderr[:800]}")
|
||
return proc.stdout
|
||
|
||
|
||
def main():
|
||
ap = argparse.ArgumentParser()
|
||
ap.add_argument("--dry-run", action="store_true")
|
||
ap.add_argument("--xlsx", default=DEFAULT_XLSX)
|
||
args = ap.parse_args()
|
||
|
||
if not Path(args.xlsx).exists():
|
||
sys.exit(f"XLSX not found: {args.xlsx}")
|
||
|
||
stops, skipped, dir_count = load(args.xlsx)
|
||
assign_slugs(stops, dry_run=args.dry_run)
|
||
|
||
print(f"\nParsed {len(stops)} stops "
|
||
f"(skipped: {skipped['weekHeader']} week-headers, "
|
||
f"{skipped['off']} OFF days, {skipped['invalid']} invalid)")
|
||
print(f"Stop Directory: {dir_count} entries loaded for enrichment\n")
|
||
|
||
if not stops:
|
||
sys.exit("No stops to insert.")
|
||
|
||
print("Sample (first 3):")
|
||
for s in stops[:3]:
|
||
print(f" {s['date']} {s['time']:10s} | {s['city']:18s}, {s['state']:2s} | "
|
||
f"{s['location'][:35]:35s} | {s['truck']} | {s['status_text']} | {s['slug']}")
|
||
if s["notes"]:
|
||
print(f" notes: {s['notes'][:120]}")
|
||
if s["address"]:
|
||
print(f" addr: {s['address']} ph: {s['phone']} ctc: {s['contact']}")
|
||
print()
|
||
|
||
# Show counts by week and region
|
||
by_week = {}
|
||
by_region = {}
|
||
by_truck = {}
|
||
for s in stops:
|
||
by_week[s["week"]] = by_week.get(s["week"], 0) + 1
|
||
by_region[s["region"]] = by_region.get(s["region"], 0) + 1
|
||
by_truck[s["truck"]] = by_truck.get(s["truck"], 0) + 1
|
||
print("By week:", dict(sorted(by_week.items())))
|
||
print("By region:", by_region)
|
||
print("By truck:", by_truck)
|
||
print()
|
||
|
||
# Date range
|
||
dates = sorted(s["date"] for s in stops)
|
||
print(f"Date range: {dates[0]} to {dates[-1]}\n")
|
||
|
||
if args.dry_run:
|
||
batches = (len(stops) + 49) // 50
|
||
print(f"[DRY RUN] Would insert {len(stops)} stops in {batches} batch(es) of 50.")
|
||
return
|
||
|
||
BATCH = 50
|
||
total = 0
|
||
batches = (len(stops) + BATCH - 1) // BATCH
|
||
for i in range(0, len(stops), BATCH):
|
||
batch = [to_rpc_row(s) for s in stops[i:i + BATCH]]
|
||
bnum = i // BATCH + 1
|
||
sys.stdout.write(f" Inserting batch {bnum}/{batches} ({len(batch)} stops)... ")
|
||
sys.stdout.flush()
|
||
try:
|
||
insert_batch(batch)
|
||
total += len(batch)
|
||
print("OK")
|
||
except Exception as e:
|
||
print("FAIL")
|
||
print(f" {e}")
|
||
|
||
# The batch RPC hardcodes status='draft' on insert. The Tuxedo storefront
|
||
# page only filters on active=true (not status), so active=true is enough
|
||
# to make stops visible. But for consistency with the publishStop server
|
||
# action — which sets both — flip status to 'active' for the rows we just
|
||
# inserted. Slug-based so we only touch stops from this run, not the
|
||
# pre-existing "Olathe" test stop.
|
||
if total > 0:
|
||
slugs = [s["slug"] for s in stops]
|
||
# Build a safe IN list (slug is a text column)
|
||
slug_list = ", ".join(f"'{slug.replace(chr(39), chr(39)+chr(39))}'" for slug in slugs)
|
||
publish_sql = (
|
||
f"UPDATE stops SET status = 'active' "
|
||
f"WHERE brand_id = '{TUXEDO_BRAND_ID}' "
|
||
f"AND slug IN ({slug_list});"
|
||
)
|
||
tmp = Path("/tmp/seed_tuxedo_publish.sql")
|
||
tmp.write_text(publish_sql, encoding="utf-8")
|
||
try:
|
||
subprocess.run(
|
||
["supabase", "db", "query", "--linked", "--file", str(tmp)],
|
||
capture_output=True, text=True, timeout=120,
|
||
)
|
||
print(f"\n Published {total} stops (status -> 'active').")
|
||
finally:
|
||
tmp.unlink(missing_ok=True)
|
||
|
||
print(f"\nDone. Inserted {total}/{len(stops)} stops for Tuxedo brand.")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|