← back to Flock Fix Viewer
apply-flock-combine.py
138 lines
#!/usr/bin/env python3
"""
TK-11122 — flock split-brain fix. Rebuild the ACTIVE (thin sample) survivor into
ONE combined roll+sample wallcovering SKU by copying the roll variant + metafields
forward from its ARCHIVED rich twin. Archived twin is LEFT ARCHIVED (Steve: "if it
was archived, leave it. We need 1 sku with roll and sample").
Usage: python3 apply-flock-combine.py 1046 [--apply]
(no --apply = dry run: prints exactly what it will change, writes nothing)
Reversible: survivor pre-state snapshot saved; ledgered.
"""
import os,sys,json,time,urllib.request,urllib.error
# colorway -> (archived rich roll product id, active survivor product id)
MAP={
"1045":(6785296007219,7862958030899),
"1046":(6785296662579,7862955311155),
"1047":(6785296990259,7862955868211),
# TK-11122 follow-on (2026-09-03): remaining combine-ready PR flock (archived priced roll twin + active sample survivor)
"1038":(6785294499891,7862957473843), # maggies-circles FLOCK-1038 roll@190.99
"1039":(6785295056947,7862957834291), # maggies-circles FLOCK-1039 roll@190.99
"69430":(1497223233648,7863155064883), # la-mayorca XCD-69430 roll@175.13 (SRC also carries sample variant)
}
LOC=5795643504 # 15442 Ventura Blvd (primary)
TMPL=6785295810611 # 1044 combined = channel template
cw=(sys.argv[1] if len(sys.argv)>1 else "").strip()
APPLY="--apply" in sys.argv
if cw not in MAP:
print("usage: apply-flock-combine.py <1045|1046|1047|1038|1039|69430> [--apply]"); sys.exit(1)
SRC,DST=MAP[cw]
env=open(os.path.expanduser("~/Projects/secrets-manager/.env")).read()
TOKEN=next((l.split("=",1)[1].strip().strip('"\'') for l in env.splitlines()
if l.startswith("SHOPIFY_FULL_ACCESS_TOKEN=")),None)
STORE="designer-laboratory-sandbox.myshopify.com"
API=f"https://{STORE}/admin/api/2024-10"
def call(method,path,body=None):
data=json.dumps(body).encode() if body is not None else None
req=urllib.request.Request(f"{API}/{path}",data=data,method=method,
headers={"X-Shopify-Access-Token":TOKEN,"Content-Type":"application/json"})
try: return json.load(urllib.request.urlopen(req))
except urllib.error.HTTPError as e:
print(" HTTP",e.code,e.read().decode()[:400]); raise
def gql(q,v=None):
req=urllib.request.Request(f"{API}/graphql.json",data=json.dumps({"query":q,"variables":v or {}}).encode(),
headers={"X-Shopify-Access-Token":TOKEN,"Content-Type":"application/json"})
return json.load(urllib.request.urlopen(req))
src=call("GET",f"products/{SRC}.json")['product']
dst=call("GET",f"products/{DST}.json")['product']
roll_v=[v for v in src['variants'] if 'sample' not in (v.get('sku') or '').lower()][0] # generic: non-sample roll variant of SRC
samp_v=[v for v in dst['variants'] if 'sample' in (v.get('sku') or '').lower()][0]
roll_opt=roll_v.get('option1') or 'Sold per single roll (20.5" x 5.5 yards)'
roll_price=roll_v.get('price') or "190.99"
roll_sku=roll_v.get('sku'); samp_sku=samp_v.get('sku') # generic SKUs (Sadie=FLOCK-####, la-mayorca=XCD-69430)
import re as _re
_title=src.get('title') or 'Flocked Velvet Wallcovering' # identity from SRC archived twin, NOT hardcoded Sadie
_apos='['+chr(39)+chr(8217)+chr(96)+']' # ' ’ ` -> dropped (Maggie's -> maggies)
_handle=f"{_re.sub(r'-+','-',_re.sub(r'[^a-z0-9]+','-',_re.sub(_apos,'',_title.lower()))).strip('-')}-{cw}"
tags=list(dict.fromkeys([t.strip() for t in src['tags'].split(',') if t.strip()]))
tags=[t for t in tags if t.lower() not in ('display-exclude','memo sample')]
if 'Phillipe Romano' not in tags: tags.append('Phillipe Romano')
print(f"=== flock {cw} SRC(archived rich)={SRC} DST(active survivor)={DST} APPLY={APPLY}")
print(f" will set DST title -> {_title!r}")
print(f" handle -> {_handle}")
print(f" variants -> [ROLL {roll_v['sku']} ${roll_price} continue pos1] + [SAMPLE {samp_v['sku']} $4.25 deny pos2]")
print(f" body_html <- archived roll ({len(src.get('body_html') or '')} chars); tags({len(tags)}) drop display-exclude/Memo Sample")
print(f" copy metafields <- archived roll (skip custom.has_sample); delete stale custom.sample_of; publish to 1044 channels")
if not APPLY:
print("\nDRY RUN — nothing written. Re-run with --apply to execute."); sys.exit(0)
os.makedirs(os.path.expanduser("~/Projects/flock-fix-viewer/sadie-snapshots"),exist_ok=True)
open(os.path.expanduser(f"~/Projects/flock-fix-viewer/sadie-snapshots/{cw}-survivor-pre.json"),"w").write(json.dumps(dst,indent=2))
# A. product update
payload={"product":{"id":DST,
"title":_title,
"handle":_handle,
"body_html":src.get('body_html') or dst.get('body_html'),
"tags":", ".join(tags),
"options":[{"name":"Size"}],
"variants":[
{"option1":roll_opt,"sku":roll_sku,"price":str(roll_price),
"inventory_policy":"continue","inventory_management":"shopify","weight":3,"weight_unit":"lb","position":1},
{"id":samp_v['id'],"option1":"Sample","sku":samp_sku,"price":"4.25",
"inventory_policy":"deny","inventory_management":"shopify","position":2},
]}}
print("\n[A] PUT product update…")
res=call("PUT",f"products/{DST}.json",payload)['product']
vs={v['sku']:v for v in res['variants']}
roll=vs[roll_sku]; samp=vs[samp_sku]
print(" variants:",[(v['sku'],v['option1'],v['price'],'pos'+str(v['position'])) for v in res['variants']])
# B. inventory
print("[B] inventory set…")
for v,qty in [(roll,1000),(samp,2026)]:
call("POST","inventory_levels/set.json",{"location_id":LOC,"inventory_item_id":v['inventory_item_id'],"available":qty})
print(f" {v['sku']} -> {qty}")
# C. metafields
print("[C] copy metafields…")
copied=0
for m in call("GET",f"products/{SRC}/metafields.json")['metafields']:
if (m['namespace'],m['key'])==('custom','has_sample'): continue
try:
call("POST",f"products/{DST}/metafields.json",{"metafield":{"namespace":m['namespace'],"key":m['key'],"type":m['type'],"value":m['value']}}); copied+=1
except Exception: pass
for m in call("GET",f"products/{DST}/metafields.json")['metafields']:
if (m['namespace'],m['key'])==('custom','sample_of'):
call("DELETE",f"products/{DST}/metafields/{m['id']}.json"); print(" deleted stale custom.sample_of")
print(f" copied {copied} metafields")
# D. publish
print("[D] publish to channels…")
tmpl=gql('{ product(id:"gid://shopify/Product/%d"){ resourcePublicationsV2(first:20){edges{node{publication{id name} isPublished}}}}}'%TMPL)
pubs=[e['node']['publication'] for e in tmpl['data']['product']['resourcePublicationsV2']['edges'] if e['node']['isPublished']]
r=gql('mutation($id:ID!,$in:[PublicationInput!]!){publishablePublish(id:$id,input:$in){userErrors{field message}}}',
{"id":f"gid://shopify/Product/{DST}","in":[{"publicationId":p['id']} for p in pubs]})
print(" ->",[p['name'] for p in pubs],"| errors:",r.get('data',{}).get('publishablePublish',{}).get('userErrors',[]) or "none")
# ledger
open(os.path.expanduser("~/.claude/yolo-queue/executed-reversible/ledger.jsonl"),"a").write(json.dumps({
"ts":time.strftime('%Y-%m-%dT%H:%M:%SZ',time.gmtime()),"agent":"vp-dw-commerce","ticket":"TK-11122",
"action":f"flock {cw}: rebuilt survivor {DST} -> combined roll+sample wallcovering; archived twin {SRC} left archived",
"blast_radius":1,"undo_cmd":f"restore ~/Projects/flock-fix-viewer/sadie-snapshots/{cw}-survivor-pre.json; delete roll variant {roll['id']}",
"verify":f"GET products/{DST}.json"})+"\n")
# verify
fin=call("GET",f"products/{DST}.json?fields=id,title,status,handle,variants")['product']
print(f"\n[VERIFY] {fin['id']} {fin['status']} '{fin['title']}' /{fin['handle']}")
for v in fin['variants']:
print(f" {v['sku']:20} {v['option1']:40} ${v['price']:>7} {v['inventory_policy']}")
print(f"DONE flock {cw}.")