← back to Hollywood Price 2026

price.py

154 lines

#!/usr/bin/env python3
"""Price Hollywood/Momentum sample-only products (Steve-approved 2026-07-12).
Per plan.csv row: rename option Title->Size, keep $4.25 Sample, ADD one sellable
variant "Sold Per {Box|Yard|Panel|Square Yard}" priced at feed `hw` (Hollywood
retail = list x 1.448), inventoryPolicy CONTINUE, tracked, qty min/step=1; strip
Needs-Price tag. Idempotent (ledger + live pre-check). $0 (Admin API free).

Usage:  python3 price.py --canary N      # price first N, verbose, then STOP
        python3 price.py --batch          # price the rest (skips ledger-done)
"""
import sys
import os
import csv
import json
import time
import datetime
sys.path.insert(0, "lib")
from shopify import gql

CSV = "plan_correct.csv"
LEDGER = os.path.join("ledger", "priced_correct.csv")

UNIT = {  # feed uom -> (option value label, sku suffix)
    "YD": ("Sold Per Yard", "Yard"),
    "PANEL": ("Sold Per Panel", "Panel"),
    "BOX": ("Sold Per Box", "Box"),
    "SY": ("Sold Per Square Yard", "SqYard"),
}

Q_PROD = """query($id:ID!){ product(id:$id){ id legacyResourceId title status handle tags
  options{ id name position values }
  variants(first:30){ edges{ node{ id title sku price selectedOptions{name value} } } } } }"""
M_OPT_RENAME = """mutation($pid:ID!,$oid:ID!,$name:String!){
  productOptionUpdate(productId:$pid, option:{id:$oid, name:$name}){ userErrors{ field message } } }"""
M_VAR_CREATE = """mutation($pid:ID!,$variants:[ProductVariantsBulkInput!]!){
  productVariantsBulkCreate(productId:$pid, 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 } } }"""
M_TAGSRM = """mutation($id:ID!,$tags:[String!]!){ tagsRemove(id:$id, tags:$tags){ userErrors{ message } } }"""


def log(row):
    new = not (os.path.exists(LEDGER) and os.path.getsize(LEDGER) > 0)
    with open(LEDGER, "a", newline="") as f:
        w = csv.writer(f)
        if new:
            w.writerow(["ts", "product_id", "dw_sku", "uom", "hw", "sellable_sku", "variant_id", "status"])
        w.writerow(row)


def seen():
    s = set()
    if os.path.exists(LEDGER):
        for r in csv.DictReader(open(LEDGER)):
            if r.get("status", "").startswith("OK") and r.get("product_id"):
                s.add(r["product_id"])
    return s


def process(r, verbose=False):
    ts = datetime.datetime.now().isoformat(timespec="seconds")
    pid = str(r["product_id"]); dw = r.get("mom_pattern", ""); uom = r["unit"]
    try:
        hw = round(float(r["hw"]), 2)
    except Exception:
        log([ts, pid, dw, uom, r.get("hw"), "", "", "SKIP:bad-hw"]); return "SKIP:bad-hw"
    lbl = UNIT.get(uom)
    if not lbl:
        log([ts, pid, dw, uom, hw, "", "", f"SKIP:bad-uom:{uom}"]); return f"SKIP:bad-uom:{uom}"
    label, suffix = lbl
    node = gql(Q_PROD, {"id": r["gid"]})["product"]
    if not node:
        log([ts, pid, dw, uom, hw, "", "", "SKIP:not-found"]); return "SKIP:not-found"
    gid = node["id"]
    # Already priced? The Needs-Price tag is often STALE (product already has a
    # correct Sold-Per-Yard variant). If the existing price matches hw, just strip
    # the stale tag; if it DIFFERS, flag it (never silently overwrite a real price).
    existing = None
    for e in node["variants"]["edges"]:
        n2 = e["node"]; sk = (n2["sku"] or "")
        if n2["title"] != "Sample" and not sk.lower().endswith("-sample"):
            existing = n2; break
    if existing:
        try:
            same = abs(float(existing["price"]) - hw) < 0.5
        except Exception:
            same = False
        if same:
            gql(M_TAGSRM, {"id": gid, "tags": ["Needs-Price"]})
            log([ts, pid, dw, uom, hw, existing["sku"], existing["id"], "OK:tag-stripped"]); return "OK:tag-stripped"
        log([ts, pid, dw, uom, hw, existing["sku"], existing["id"], f"FLAG:price-mismatch:{existing['price']}vs{hw}"]); return "FLAG:price-mismatch"
    sellable_sku = f"{r['sku_base']}-{suffix}"

    # 1) rename option Title -> Size
    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:
            log([ts, pid, dw, uom, hw, sellable_sku, "", "FAIL:opt-rename:" + json.dumps(ue)[:120]]); return "FAIL:opt-rename"

    # 2) add sellable variant at hw
    d = gql(M_VAR_CREATE, {"pid": gid, "variants": [{
        "price": f"{hw:.2f}",
        "optionValues": [{"optionName": "Size", "name": label}],
        "inventoryPolicy": "CONTINUE",
        "inventoryItem": {"sku": sellable_sku, "tracked": True},
    }]})
    res = d["productVariantsBulkCreate"]
    if res["userErrors"]:
        log([ts, pid, dw, uom, hw, sellable_sku, "", "FAIL:var-create:" + json.dumps(res["userErrors"])[:160]]); return "FAIL:var-create"
    vid = res["productVariants"][0]["id"]

    # 3) qty min/step = 1
    gql(M_MF_SET, {"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"},
    ]})
    # 4) strip Needs-Price
    gql(M_TAGSRM, {"id": gid, "tags": ["Needs-Price"]})

    log([ts, pid, dw, uom, hw, sellable_sku, vid, "OK"])
    if verbose:
        print(json.dumps({"pid": pid, "title": node["title"], "dw_sku": dw,
                          "sellable_sku": sellable_sku, "price": f"{hw:.2f}", "unit": label,
                          "variant_id": vid}, indent=2, ensure_ascii=False))
    return "OK"


if __name__ == "__main__":
    rows = list(csv.DictReader(open(CSV)))
    done = seen()
    if "--canary" in sys.argv:
        n = int(sys.argv[sys.argv.index("--canary") + 1])
        todo = [r for r in rows if str(r["product_id"]) not in done][:n]
        print(f"=== CANARY: pricing {len(todo)} ===")
        for r in todo:
            print(f"\n--- {r['title'][:50]} ({r['unit']} @ ${r['hw']}) ---")
            print("RESULT:", process(r, verbose=True))
        sys.exit(0)
    todo = [r for r in rows if str(r["product_id"]) not in done]
    print(f"=== BATCH: {len(todo)} to price ({len(done)} already done) ===")
    ok = sk = fl = 0
    for i, r in enumerate(todo):
        st = process(r)
        if st == "OK": ok += 1
        elif st.startswith("FAIL"): fl += 1
        else: sk += 1
        if (i + 1) % 25 == 0:
            print(f"  ...{i+1}/{len(todo)} ok={ok} skip={sk} fail={fl}")
        time.sleep(0.15)
    print(f"BATCH DONE: ok={ok} skip={sk} fail={fl}")