← back to Elitis Price 2026
exec/ws3_drafts.py
217 lines
#!/usr/bin/env python3
"""WS3 — build DRAFT colorway products for Elitis new-on-list patterns.
Data reality: elitis_catalog.json carries pattern_name, color_name, mfr_sku,
width, product_type ONLY -- NO image, NO specs beyond width. So every product is
built DRAFT + tag Needs-Image (can never accidentally go ACTIVE without an image,
per the standing rule). Title = 'Pattern Color | Elitis' (Title Case, real
color from catalog -- never AI, never 'Unknown' -> fall back to mfr tail).
Variants per the WS1 recipe: Size option, Sample ($4.25, DENY, {DW}-Sample) +
sellable ({DW}-{Roll|Yard|Panel}, retail, CONTINUE, tracked, min/step=1).
Only builds colorways whose staging pattern EXACT-matches a catalog pattern_name
(reliable join); fuzzy/no-match patterns are routed to the follow-up scrape file.
Idempotent + resumable via ledger + live pre-check. $0."""
import csv, sys, os, json, datetime, time, re
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "lib"))
from shopify import gql
ROOT = os.path.join(os.path.dirname(__file__), "..")
LEDGER = os.path.join(os.path.dirname(__file__), "ledger", "ws3_drafts.csv")
CAT = os.path.join(ROOT, "elitis_catalog.json")
SJ = os.path.join(ROOT, "OUT_577_staging_join.csv")
UNIT_LABEL = {"Roll": "Sold Per Roll", "Yard": "Sold Per Yard", "Panel": "Sold Per Panel"}
M_CREATE = """mutation($input:ProductInput!){
productCreate(input:$input){ userErrors{ field message }
product{ id legacyResourceId title status handle
options{ id name values } } } }"""
M_VAR_BULK = """mutation($pid:ID!,$strategy:ProductVariantsBulkCreateStrategy,$variants:[ProductVariantsBulkInput!]!){
productVariantsBulkCreate(productId:$pid, strategy:$strategy, variants:$variants){
userErrors{ field message }
productVariants{ id title sku price inventoryPolicy selectedOptions{name value} } } }"""
M_MF_SET = """mutation($mf:[MetafieldsSetInput!]!){
metafieldsSet(metafields:$mf){ userErrors{ field message } } }"""
def title_case(s):
# Title Case but preserve accented letters; keep short words lower except first
return " ".join(w[:1].upper() + w[1:] for w in s.split())
def make_title(pattern, color, mfr):
color = (color or "").strip()
if not color or color.lower() == "unknown":
# fall back to mfr sku tail
tail = mfr.replace("ELIT-", "").replace("-", " ")
base = f"{title_case(pattern)} {tail}"
else:
base = f"{title_case(pattern)} {title_case(color)}"
return f"{base} | Elitis"
def dw_sku(mfr):
# ELIT-RM-250-04 -> DWEL-RM-250-04 (unique, traceable)
return "DWEL-" + mfr.replace("ELIT-", "")
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", "list_sku", "pattern", "colorway", "unit", "retail",
"product_id", "dw_sku", "status"])
w.writerow(row)
def seen_dw():
s = set()
if os.path.exists(LEDGER):
for r in csv.DictReader(open(LEDGER)):
if r.get("status", "").startswith("OK") and r.get("dw_sku"):
s.add(r["dw_sku"])
return s
def exists_on_shopify(dw):
d = gql("""query($q:String!){ productVariants(first:3, query:$q){ edges{ node{ sku } } } }""",
{"q": f"sku:{dw}-Sample"})
return len(d["productVariants"]["edges"]) > 0
def build_targets():
cat = json.load(open(CAT))
sj = list(csv.DictReader(open(SJ)))
cat_by_pat = {}
for e in cat:
cat_by_pat.setdefault(e["pattern_name"], []).append(e)
targets, unmatched = [], []
for r in sj:
pat = r["pattern"]
ents = cat_by_pat.get(pat)
if not ents:
unmatched.append(r)
continue
for e in ents:
if e.get("on_shopify"):
continue
targets.append({
"list_sku": r["list_sku"], "pattern": pat, "unit": r["unit"],
"retail": r["retail"], "mfr_sku": e["mfr_sku"],
"color": e["color_name"], "width": e["width"],
"product_type": e["product_type"],
})
return targets, unmatched
def create_one(t, verbose=False):
ts = datetime.datetime.now().isoformat(timespec="seconds")
unit = t["unit"]; mfr = t["mfr_sku"]
dw = dw_sku(mfr)
title = make_title(t["pattern"], t["color"], mfr)
try:
retail = round(float(t["retail"]), 2)
except Exception:
append_ledger([ts, t["list_sku"], t["pattern"], t["color"], unit, t["retail"], "", dw, "SKIP:bad-retail"])
return "SKIP:bad-retail"
label = UNIT_LABEL.get(unit)
if not label:
append_ledger([ts, t["list_sku"], t["pattern"], t["color"], unit, retail, "", dw, f"SKIP:bad-unit:{unit}"])
return f"SKIP:bad-unit:{unit}"
if exists_on_shopify(dw):
append_ledger([ts, t["list_sku"], t["pattern"], t["color"], unit, retail, "", dw, "SKIP:already-on-shopify"])
return "SKIP:already-on-shopify"
# DRAFT product, Size option, Needs-Image tag (no image data exists)
pt = "Wallcovering" if "Wallcovering" in t["product_type"] else "Wallcovering"
inp = {
"title": title,
"vendor": "Elitis",
"productType": pt,
"status": "DRAFT",
"tags": ["Elitis", "display_variant", "Needs-Image"],
"productOptions": [{"name": "Size", "values": [{"name": "Sample"}, {"name": label}]}],
}
d = gql(M_CREATE, {"input": inp})
res = d["productCreate"]
if res["userErrors"]:
append_ledger([ts, t["list_sku"], t["pattern"], t["color"], unit, retail, "", dw, "FAIL:create:" + json.dumps(res["userErrors"])[:140]])
return "FAIL:create"
p = res["product"]; gid = p["id"]; pid = str(p["legacyResourceId"])
# productCreate makes ONE default variant. Fetch it, UPDATE it into the Sample
# variant, then bulk-CREATE the sellable variant.
dv = gql("""query($id:ID!){ product(id:$id){ variants(first:10){ edges{ node{
id title selectedOptions{name value} } } } } }""", {"id": gid})
default_id = dv["product"]["variants"]["edges"][0]["node"]["id"]
M_VAR_UPD = """mutation($pid:ID!,$variants:[ProductVariantsBulkInput!]!){
productVariantsBulkUpdate(productId:$pid, variants:$variants){
userErrors{ field message } productVariants{ id title sku } } }"""
du = gql(M_VAR_UPD, {"pid": gid, "variants": [{
"id": default_id, "price": "4.25", "inventoryPolicy": "DENY",
"optionValues": [{"optionName": "Size", "name": "Sample"}],
"inventoryItem": {"sku": f"{dw}-Sample", "tracked": True}}]})
ue = du["productVariantsBulkUpdate"]["userErrors"]
if ue:
append_ledger([ts, t["list_sku"], t["pattern"], t["color"], unit, retail, pid, dw, "FAIL:sample-upd:" + json.dumps(ue)[:140]])
return "FAIL:sample-upd"
dc = gql(M_VAR_BULK, {"pid": gid, "strategy": "DEFAULT", "variants": [{
"price": f"{retail:.2f}", "inventoryPolicy": "CONTINUE",
"optionValues": [{"optionName": "Size", "name": label}],
"inventoryItem": {"sku": f"{dw}-{unit}", "tracked": True}}]})
ce = dc["productVariantsBulkCreate"]["userErrors"]
if ce:
append_ledger([ts, t["list_sku"], t["pattern"], t["color"], unit, retail, pid, dw, "FAIL:sellable-create:" + json.dumps(ce)[:140]])
return "FAIL:sellable-create"
# width metafield + qty min/step=1
mf = [
{"ownerId": gid, "namespace": "global", "key": "width", "type": "single_line_text_field", "value": t["width"]},
{"ownerId": gid, "namespace": "specs", "key": "width", "type": "single_line_text_field", "value": re.sub(r'[^0-9.]', '', t["width"]) or t["width"]},
{"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"},
]
gql(M_MF_SET, {"mf": mf})
append_ledger([ts, t["list_sku"], t["pattern"], t["color"], unit, retail, pid, dw, "OK"])
if verbose:
print(json.dumps({"gid": gid, "title": title, "dw": dw,
"sellable_sku": f"{dw}-{unit}", "width": t["width"]}, indent=2, ensure_ascii=False))
return "OK"
if __name__ == "__main__":
targets, unmatched = build_targets()
if "--report" in sys.argv:
print(f"WS3 buildable targets (exact-pattern-match, not on_shopify): {len(targets)}")
print(f"WS3 unmatched staging rows (fuzzy/no catalog pattern): {len(unmatched)}")
print(" unmatched patterns:", sorted(set(r['pattern'] for r in unmatched)))
sys.exit(0)
if "--canary" in sys.argv:
t = next(x for x in targets if x["pattern"] == "Empreinte" and x["color"] == "Le lac des cygnes")
print("=== WS3 CANARY build:", t, "===")
st = create_one(t, verbose=True)
print("RESULT:", st)
sys.exit(0)
seen = seen_dw()
n_ok = n_skip = n_fail = 0
for i, t in enumerate(targets):
if dw_sku(t["mfr_sku"]) in seen:
n_skip += 1; continue
st = create_one(t)
if st == "OK":
n_ok += 1
elif st.startswith("FAIL"):
n_fail += 1
else:
n_skip += 1
if (i + 1) % 25 == 0:
print(f" ...{i+1}/{len(targets)} ok={n_ok} skip={n_skip} fail={n_fail}")
time.sleep(0.2)
print(f"WS3 DONE: ok={n_ok} skip={n_skip} fail={n_fail} buildable={len(targets)} unmatched={len(unmatched)}")