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