← back to Eur Recrawl

write_reonboard.py

102 lines

#!/usr/bin/env python3
"""
EUR- reonboard live write — GATED (Steve runs). Prices each priceable product's
sellable roll to retail = trade x1.810 (from the authed price list).

Per product (looked up by roll_sku):
  * if a roll variant (sku == roll_sku) already exists  -> UPDATE its price
    (this fixes the live BELOW-COST prices — many rolls are priced under cost).
  * if the product is Sample-only                       -> CREATE a roll variant
    on the "Size" option (value "Roll"), inventoryPolicy CONTINUE, tracked=false,
    Sample preserved (proven on canary EUR-71216).

SAFETY: DRY_RUN=1 default (prints planned change, writes nothing). Smoke-test:
  DRY_RUN=0 LIMIT=1 python3 write_reonboard.py   (verify in admin, then full run)
Never lowers a price below trade cost. Skips anything already at the target.
"""
import os, csv, json, time, subprocess, urllib.request

HERE = os.path.dirname(os.path.abspath(__file__))
DRY_RUN = os.environ.get("DRY_RUN", "1") != "0"
LIMIT = int(os.environ.get("LIMIT", "0")) or None

def _tok():
    for l in open(os.path.expanduser("~/Projects/secrets-manager/.env")):
        if l.startswith("SHOPIFY_ADMIN_TOKEN="): return l.split("=", 1)[1].strip().strip('"')
    raise SystemExit("token not found")
TOKEN = os.environ.get("AT") or _tok()
URL = "https://designer-laboratory-sandbox.myshopify.com/admin/api/2024-10/graphql.json"

def gql(q, v=None):
    b = json.dumps({"query": q, "variables": v or {}}).encode()
    req = urllib.request.Request(URL, b, {"X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json"})
    for a in range(8):
        try:
            d = json.load(urllib.request.urlopen(req, timeout=90))
            if "errors" in d and any("THROTTLED" in str(e) for e in d["errors"]):
                time.sleep(2 * (a + 1)); continue
            return d
        except Exception:
            time.sleep(2 * (a + 1))
    raise RuntimeError("gql failed")

FIND = """query($q:String!){products(first:1,query:$q){edges{node{id
  options{name values}
  variants(first:15){edges{node{id sku price selectedOptions{name value}}}}}}}}"""
VUPDATE = """mutation($pid:ID!,$v:[ProductVariantsBulkInput!]!){
  productVariantsBulkUpdate(productId:$pid,variants:$v){userErrors{field message}}}"""
VCREATE = """mutation($pid:ID!,$v:[ProductVariantsBulkInput!]!){
  productVariantsBulkCreate(productId:$pid,variants:$v){userErrors{field message}}}"""

def main():
    rows = list(csv.DictReader(open(os.path.join(HERE, "reonboard.csv"))))
    if LIMIT: rows = rows[:LIMIT]
    updated = created = skipped = 0; errs = []; results = []
    print(f"{'DRY-RUN' if DRY_RUN else 'LIVE'}: {len(rows)} products -> roll @ trade x1.810")
    for i, r in enumerate(rows):
        roll_sku, target, trade = r["roll_sku"], float(r["retail"]), float(r["trade_price"])
        d = gql(FIND, {"q": "sku:" + roll_sku})
        e = (d.get("data") or {}).get("products", {}).get("edges", [])
        if not e:
            errs.append({"sku": roll_sku, "err": "product not found"}); continue
        node = e[0]["node"]; pid = node["id"]
        vmap = {v["node"]["sku"]: v["node"] for v in node["variants"]["edges"]}
        roll = vmap.get(roll_sku)
        # roll variant option: use the product's ACTUAL first option name (some are
        # "Size", some are the default "Title") — hardcoding "Size" 422'd 5 products.
        opt = node["options"][0] if node["options"] else None
        opt_name = opt["name"] if opt else "Title"
        roll_val = next((v for v in (opt["values"] if opt else [])
                         if v.lower() not in ("sample", "default title")), "Roll")

        if roll:
            cur = float(roll["price"])
            if abs(cur - target) < 0.02:
                skipped += 1; continue
            action = "UPDATE"; op = (VUPDATE, [{"id": roll["id"], "price": f"{target:.2f}"}])
        else:
            action = "CREATE"; op = (VCREATE, [{
                "optionValues": [{"optionName": opt_name, "name": roll_val}],
                "price": f"{target:.2f}", "inventoryItem": {"tracked": False, "sku": roll_sku},
                "inventoryPolicy": "CONTINUE"}])
        if DRY_RUN:
            if i < 6: print(f"  {action} {roll_sku}: ${roll['price'] if roll else '—'} -> ${target:.2f} (cost ${trade})")
            updated += action == "UPDATE"; created += action == "CREATE"; continue
        mut, variants = op
        res = gql(mut, {"pid": pid, "v": variants})
        key = "productVariantsBulkUpdate" if action == "UPDATE" else "productVariantsBulkCreate"
        ue = ((res.get("data") or {}).get(key) or {}).get("userErrors") or []
        if ue: errs.append({"sku": roll_sku, "err": ue[:2]})
        else:
            updated += action == "UPDATE"; created += action == "CREATE"
            results.append({"sku": roll_sku, "action": action, "price": f"{target:.2f}"})
        if i % 25 == 0: print(f"  ...{i}/{len(rows)} upd={updated} new={created} err={len(errs)}")
        time.sleep(0.3)
    json.dump({"updated": updated, "created": created, "skipped": skipped, "errors": errs[:25]},
              open(os.path.join(HERE, "write-results.json"), "w"), indent=2)
    print(f"\nDONE {'(DRY-RUN)' if DRY_RUN else ''}: UPDATE={updated} CREATE={created} skip={skipped} err={len(errs)}")
    if errs: print("errs:", json.dumps(errs[:3]))

if __name__ == "__main__":
    main()