← back to Elitis Price 2026
exec/ws1_reprice.py
198 lines
#!/usr/bin/env python3
"""WS1 — Elitis reprice: rename option Title->Size, add ONE sellable variant per product.
Recipe (Steve-confirmed): Size option; keep Sample untouched; add sellable
{DW}-{Roll|Yard|Panel} at retail, CONTINUE, tracked, min/step=1 (NOT double-roll).
Neutralize the known-bad blanket qty metafields (min/units 2 -> 1) on touched products.
Idempotent + resumable: skip any product already carrying a non-sample variant.
$0 (Shopify Admin API is free)."""
import csv, sys, os, json, time, datetime
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "lib"))
from shopify import gql, find_product_by_title
LEDGER = os.path.join(os.path.dirname(__file__), "ledger", "ws1_priced.csv")
CSV = os.path.join(os.path.dirname(__file__), "..", "OUT_223_priced.csv")
UNIT_LABEL = {"Roll": "Sold Per Roll", "Yard": "Sold Per Yard", "Panel": "Sold Per Panel"}
UNIT_SKU = {"Roll": "Roll", "Yard": "Yard", "Panel": "Panel"}
Q_PROD = """query($id:ID!){ product(id:$id){
id legacyResourceId title status handle
options{ id name position values }
variants(first:30){ edges{ node{ id title sku price inventoryPolicy
inventoryItem{ id tracked } selectedOptions{name value} } } }
metafields(first:60){ edges{ node{ id namespace key value type } } }
} }"""
M_OPT_RENAME = """mutation($pid:ID!,$oid:ID!,$name:String!){
productOptionUpdate(productId:$pid, option:{id:$oid, name:$name}){
userErrors{ field message } product{ options{ id name values } } } }"""
M_VAR_CREATE = """mutation($pid:ID!,$variants:[ProductVariantsBulkInput!]!){
productVariantsBulkCreate(productId:$pid, variants:$variants){
userErrors{ field message }
productVariants{ id title sku price inventoryPolicy
inventoryItem{ tracked } selectedOptions{name value} } } }"""
M_MF_SET = """mutation($mf:[MetafieldsSetInput!]!){
metafieldsSet(metafields:$mf){ userErrors{ field message } metafields{ key value } } }"""
def prod_by_gid(gid):
return gql(Q_PROD, {"id": gid})["product"]
def dw_sku_from_sample(node):
for e in node["variants"]["edges"]:
n = e["node"]
sk = n["sku"] or ""
if sk.endswith("-Sample") or n["title"] == "Sample":
if sk.endswith("-Sample"):
return sk[:-len("-Sample")]
return None
def has_nonsample_variant(node):
for e in node["variants"]["edges"]:
n = e["node"]
sk = n["sku"] or ""
if n["title"] != "Sample" and not sk.endswith("-Sample"):
return True
return False
def ledger_seen():
seen = set()
if os.path.exists(LEDGER):
for r in csv.DictReader(open(LEDGER)):
if r.get("product_id") and r.get("status", "").startswith("OK"):
seen.add(r["product_id"])
return seen
def append_ledger(row):
exists = os.path.exists(LEDGER) and os.path.getsize(LEDGER) > 0
with open(LEDGER, "a", newline="") as f:
w = csv.writer(f)
if not exists:
w.writerow(["ts", "mfr_sku", "title", "product_id", "dw_sku", "unit",
"retail", "sellable_variant_id", "status"])
w.writerow(row)
def process(r, gid_override=None, verbose=False):
ts = datetime.datetime.now().isoformat(timespec="seconds")
title = r["title"]; unit = r["unit"]; mfr = r["mfr_sku"]
try:
retail = round(float(r["retail"]), 2)
except Exception:
append_ledger([ts, mfr, title, "", "", unit, r.get("retail"), "", "SKIP:bad-retail"])
return "SKIP:bad-retail"
if gid_override:
node = prod_by_gid(gid_override)
else:
found = find_product_by_title(title)
if not found:
append_ledger([ts, mfr, title, "", "", unit, retail, "", "SKIP:not-found"])
return "SKIP:not-found"
# re-fetch full node by gid so option ids + full metafields are present
node = prod_by_gid(found["id"])
if not node:
append_ledger([ts, mfr, title, "", "", unit, retail, "", "SKIP:not-found"])
return "SKIP:not-found"
gid = node["id"]; pid = str(node["legacyResourceId"])
if has_nonsample_variant(node):
append_ledger([ts, mfr, title, pid, "", unit, retail, "", "SKIP:already-has-sellable"])
return "SKIP:already-has-sellable"
dw = dw_sku_from_sample(node)
if not dw:
append_ledger([ts, mfr, title, pid, "", unit, retail, "", "SKIP:no-dw-sku"])
return "SKIP:no-dw-sku"
label = UNIT_LABEL.get(unit)
if not label:
append_ledger([ts, mfr, title, pid, dw, unit, retail, "", f"SKIP:bad-unit:{unit}"])
return f"SKIP:bad-unit:{unit}"
# 1) rename option Title -> Size (idempotent)
opt = node["options"][0]
if opt["name"] != "Size":
d = gql(M_OPT_RENAME, {"pid": gid, "oid": opt["id"], "name": "Size"})
ue = d["productOptionUpdate"]["userErrors"]
if ue:
append_ledger([ts, mfr, title, pid, dw, unit, retail, "", "FAIL:opt-rename:" + json.dumps(ue)[:120]])
return "FAIL:opt-rename"
# 2) add sellable variant
sellable_sku = f"{dw}-{UNIT_SKU[unit]}"
variants = [{
"price": f"{retail:.2f}",
"optionValues": [{"optionName": "Size", "name": label}],
"inventoryPolicy": "CONTINUE",
"inventoryItem": {"sku": sellable_sku, "tracked": True},
}]
d = gql(M_VAR_CREATE, {"pid": gid, "variants": variants})
res = d["productVariantsBulkCreate"]
ue = res["userErrors"]
if ue:
append_ledger([ts, mfr, title, pid, dw, unit, retail, "", "FAIL:var-create:" + json.dumps(ue)[:160]])
return "FAIL:var-create"
new_var = res["productVariants"][0]
vid = new_var["id"]
# 3) neutralize known-bad blanket qty metafields (min/units 2 -> 1) so PDP min=1
mf = [
{"ownerId": gid, "namespace": "global", "key": "v_prods_quantity_order_min",
"type": "single_line_text_field", "value": "1"},
{"ownerId": gid, "namespace": "global", "key": "v_prods_quantity_order_units",
"type": "single_line_text_field", "value": "1"},
]
dm = gql(M_MF_SET, {"mf": mf})
mue = dm["metafieldsSet"]["userErrors"]
mstat = "OK" if not mue else "OK(mf-warn)"
append_ledger([ts, mfr, title, pid, dw, unit, retail, vid, mstat])
if verbose:
print(json.dumps({"gid": gid, "dw": dw, "sellable_sku": sellable_sku,
"new_variant": new_var}, indent=2, ensure_ascii=False))
return mstat
if __name__ == "__main__":
args = sys.argv[1:]
canary_gid = None
only_title = None
if "--canary" in args:
canary_gid = "gid://shopify/Product/7789643792435"
only_title = "Lin Expérience Minimaliste | Elitis"
rows = list(csv.DictReader(open(CSV)))
seen = ledger_seen()
if canary_gid:
r = next(x for x in rows if x["mfr_sku"] == "ELIT-VP-953-22")
print("=== WS1 CANARY:", r["title"], "===")
st = process(r, gid_override=canary_gid, verbose=True)
print("RESULT:", st)
sys.exit(0)
# full run (skips canary already done via ledger)
BAD_MFR = {"ELIT-VP-1036-44"} # duplicate mfr_sku -> handled separately/flagged
n_ok = n_skip = n_fail = 0
for i, r in enumerate(rows):
# dedupe: skip if this exact title already landed in this run's seen set
node_pid_key = r["title"]
st = process(r)
if st.startswith("OK"):
n_ok += 1
elif st.startswith("FAIL"):
n_fail += 1
else:
n_skip += 1
if (i + 1) % 20 == 0:
print(f" ...{i+1}/{len(rows)} ok={n_ok} skip={n_skip} fail={n_fail}")
time.sleep(0.15)
print(f"DONE: ok={n_ok} skip={n_skip} fail={n_fail} total={len(rows)}")