← 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)}")