← back to Designer Wallcoverings
pending-approval/twil_price_update.py
156 lines
#!/usr/bin/env python3
"""
twil_price_update.py — apply TWIL vendor email pricing (Jerry Boyas STOCK INFO.xlsx)
to all Fentucci grasscloth products on Shopify + the TWIL PRices sheet.
Pricing chain (verified against sheet's own existing rows):
vendor "List price PER SR" == column C (cost per single roll / 8yd bolt)
Cost/yd (L) = cost / 4
DW Price (O, sell per yd) = (cost/4)*3 = cost * 0.75 <- the DW retail used at burst
DRY (default): read Shopify + sheet, print plan, no writes.
CONFIRM=1 : write variant prices, activate priced drafts, update sheet.
"""
import os, sys, json, time, urllib.request, urllib.parse
SHOP="designer-laboratory-sandbox.myshopify.com"; API="2024-10"
DRY = os.environ.get("CONFIRM","0") != "1"
TOKEN=os.environ.get("SHOPIFY_ADMIN_TOKEN","")
if not TOKEN:
for line in open(os.path.expanduser("~/Projects/secrets-manager/.env")):
if line.startswith("SHOPIFY_ADMIN_TOKEN="):
TOKEN=line.strip().split("=",1)[1]; break
assert TOKEN, "no SHOPIFY_ADMIN_TOKEN"
BASE=f"https://{SHOP}/admin/api/{API}"
H={"X-Shopify-Access-Token":TOKEN,"Content-Type":"application/json"}
def api(method, path, payload=None):
data=json.dumps(payload).encode() if payload is not None else None
req=urllib.request.Request(BASE+path, data=data, headers=H, method=method)
with urllib.request.urlopen(req, timeout=60) as r:
return r.status, json.loads(r.read().decode())
def graphql(query, variables=None):
st,res=api("POST","/graphql.json",{"query":query,"variables":variables or {}})
return res
# ---- Jerry price map ----
def norm(s): return (s or "").strip().upper().rstrip("T")
jerry_raw=json.load(open("/tmp/twil_prices.json"))
PRICE={} # norm(mfr) -> dw_price
COST={}
for x in jerry_raw:
c=x["list_per_sr"]
if c is None: continue
dw=round(c*0.75,2)
PRICE[norm(x["code"])]=dw
COST[norm(x["code"])]=c
# ---- pull all Fentucci products via GraphQL (paginated) ----
Q="""
query($cursor:String){
products(first:100, query:"vendor:Fentucci", after:$cursor){
pageInfo{hasNextPage endCursor}
edges{node{
id legacyResourceId title status
mfr: metafield(namespace:"custom", key:"manufacturer_sku"){value}
mfr2: metafield(namespace:"dwc", key:"manufacturer_sku"){value}
variants(first:10){edges{node{ id legacyResourceId title sku price }}}
}}
}
}"""
prods=[]; cursor=None
while True:
res=graphql(Q,{"cursor":cursor})
d=res.get("data",{}).get("products",{})
if not d:
print("GRAPHQL ERR:", json.dumps(res)[:500]); sys.exit(1)
for e in d["edges"]: prods.append(e["node"])
if d["pageInfo"]["hasNextPage"]: cursor=d["pageInfo"]["endCursor"]
else: break
print(f"Fentucci products on Shopify: {len(prods)}")
print(f"Jerry priced SKUs: {len(PRICE)}\n")
plan=[] # (variant_id, new_price, old_price, product_id, status, mfr, title, action)
unmatched=[]
for p in prods:
mfr = (p.get("mfr") or {}).get("value") or (p.get("mfr2") or {}).get("value") or ""
n=norm(mfr)
dw=PRICE.get(n)
# find Roll variant (option1 Roll => title 'Roll'); sample is the -Sample sku
roll=None
for ve in p["variants"]["edges"]:
v=ve["node"]
if (v.get("title") or "").lower()=="roll" or (v.get("sku","") and not v["sku"].endswith("-Sample") and "Sample" not in (v.get("title") or "")):
roll=v; break
if roll is None and p["variants"]["edges"]:
roll=p["variants"]["edges"][0]["node"]
if dw is None:
unmatched.append((mfr, p["title"], p["status"]))
continue
old=roll["price"]
pid=p["legacyResourceId"]
vid=roll["legacyResourceId"]
needs_price = (str(old) != str(dw))
needs_activate = (p["status"]!="ACTIVE")
action=[]
if needs_price: action.append(f"price {old}->{dw}")
if needs_activate: action.append("ACTIVATE")
if not action: action.append("ok")
plan.append({"vid":vid,"pid":pid,"new":dw,"old":old,"status":p["status"],
"mfr":mfr,"title":p["title"],"action":", ".join(action),
"needs_price":needs_price,"needs_activate":needs_activate})
# report
chg=[x for x in plan if x["needs_price"] or x["needs_activate"]]
print(f"=== {len(plan)} matched | {len(chg)} need change | {len(unmatched)} unmatched ===\n")
for x in sorted(plan,key=lambda z:z["action"]):
print(f" {x['mfr']:>10} {x['status']:7} ${str(x['old']):>7} -> ${x['new']:<7} [{x['action']}] {x['title'][:42]}")
if unmatched:
print(f"\n--- {len(unmatched)} Fentucci products NOT in Jerry's price list ---")
for m,t,s in unmatched: print(f" {m:>10} {s:7} {t[:50]}")
json.dump(plan, open("/tmp/twil_update_plan.json","w"), indent=2)
print(f"\nplan -> /tmp/twil_update_plan.json")
if DRY:
print("\n[DRY-RUN] no writes. Re-run with CONFIRM=1 to apply.")
sys.exit(0)
# ---- EXECUTE ----
print("\n=== APPLYING ===")
def all_pubs():
res=graphql("{ publications(first:50){edges{node{id name}}} }")
# GMC source-fix (Steve policy, 2026-07-08): exclude "Google & YouTube"
# (publication 29646651457). Shopify auto-syncs minVariantPrice ($4.25 sample)
# to Merchant Center -> price disapproval. Google is fed by the controlled TSV
# feed only. Rollback: delete the filter below.
return [e["node"]["id"] for e in res.get("data",{}).get("publications",{}).get("edges",[])
if e["node"]["id"]!="gid://shopify/Publication/29646651457"]
PUBS=all_pubs()
def publish(pid):
gid=f"gid://shopify/Product/{pid}"
q="""mutation pub($id:ID!,$input:[PublicationInput!]!){ publishablePublish(id:$id,input:$input){ userErrors{message} } }"""
res=graphql(q,{"id":gid,"input":[{"publicationId":x} for x in PUBS]})
return res.get("data",{}).get("publishablePublish",{}).get("userErrors",[])
PRICE_ONLY = os.environ.get("PRICE_ONLY","0")=="1"
npx=nac=0
for x in chg:
try:
if x["needs_price"]:
api("PUT", f"/variants/{x['vid']}.json", {"variant":{"id":x["vid"],"price":str(x["new"])}})
npx+=1
if x["needs_activate"] and not PRICE_ONLY:
api("PUT", f"/products/{x['pid']}.json", {"product":{"id":x["pid"],"status":"active"}})
errs=publish(x["pid"])
nac+=1
if errs: print(f" pub err {x['mfr']}: {errs}")
print(f" done {x['mfr']:>10} -> ${x['new']} {'ACTIVE' if (x['needs_activate'] and not PRICE_ONLY) else '(draft)'}")
time.sleep(0.4)
except Exception as e:
body=getattr(e,'read',lambda:b'')() if hasattr(e,'read') else b''
print(f" ERR {x['mfr']}: {e} {body[:200]}")
print(f"\nDONE — prices updated: {npx}, activated: {nac}")