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