← back to Kravet Sheet Sync 2026 04 20

price_update_pass.py

147 lines

#!/usr/bin/env python3
"""
Price-update pass for Kravet products whose sheet MAP has changed.

Source: kravet_diff_update_2026_04_20 where change_flag='retail_changed'
Updates: retail price (= sheet MAP) and cost (= MAP/2 + WHLS × Tariff%)
         on the main DWKK-XXX variant (the "Sold Per..." variant, NOT Sample).

Pipeline per product:
  1) query product variants, find main variant (sku = DWKK-XXX exactly)
  2) productVariantsBulkUpdate with new price + new cost
  3) write kravet_catalog.price_retail/cost_price

Concurrency: 8 threads.
Resume-safe: tracks updated products in JSONL; re-run skips already-done.

Usage:
  python3 price_update_pass.py --limit 100      # pilot
  python3 price_update_pass.py                  # all 4,570
  python3 price_update_pass.py --dry-run        # no Shopify writes
"""
import argparse, json, os, subprocess, sys, time, urllib.request
from concurrent.futures import ThreadPoolExecutor, as_completed
import io, csv, threading

TOKEN=os.environ.get('SHOPIFY_ADMIN_TOKEN','')
URL='https://designer-laboratory-sandbox.myshopify.com/admin/api/2024-10/graphql.json'
SSH=['ssh','root@45.61.58.125']
OUT_JSONL='price_update_results.jsonl'

_sem = threading.Semaphore(8)
_lock = threading.Lock()


def pg_rows(sql):
    r = subprocess.run(SSH + [
        f'PGPASSWORD=DW2024! psql -h 127.0.0.1 -U dw_admin -d dw_unified --csv -t -c "{sql}"'],
        capture_output=True, text=True, check=True)
    return [row for row in csv.reader(io.StringIO(r.stdout)) if row]


def pg_exec(sql):
    subprocess.run(SSH + [
        f'PGPASSWORD=DW2024! psql -h 127.0.0.1 -U dw_admin -d dw_unified -c "{sql}"'],
        capture_output=True, text=True, check=True)


def gql(q, v=None):
    body=json.dumps({"query":q,"variables":v or {}}).encode()
    req=urllib.request.Request(URL,data=body,method="POST",
        headers={"Content-Type":"application/json","X-Shopify-Access-Token":TOKEN})
    d=json.loads(urllib.request.urlopen(req,timeout=30).read())
    if "errors" in d: raise RuntimeError(d["errors"])
    return d["data"]


GET_VARS="""query($id:ID!){ product(id:$id){ variants(first:5){ nodes{ id sku price inventoryItem{ id unitCost{ amount } } } } } }"""
UPD="""mutation($pid:ID!,$vs:[ProductVariantsBulkInput!]!){ productVariantsBulkUpdate(productId:$pid,variants:$vs){ userErrors{field message} } }"""


def process_one(row, dry):
    # Row from joined diff ↔ variant_map: (dw_sku, variant_gid, product_gid,
    #                                       current_price, sheet_map, sheet_cost)
    dw_sku, variant_gid, product_gid, current_price, sheet_map, sheet_cost = row
    try:
        try:
            new_price = float(sheet_map)
            new_cost  = float(sheet_cost) if sheet_cost else None
            cur_price = float(current_price) if current_price else None
        except (ValueError, TypeError):
            return (dw_sku, "bad_numbers", None)
        if cur_price is not None and abs(cur_price - new_price) < 0.005:
            return (dw_sku, "nochange", {"price": cur_price})

        variant_input = {"id": variant_gid, "price": f"{new_price:.2f}"}
        if new_cost is not None:
            variant_input["inventoryItem"] = {"cost": f"{new_cost:.2f}"}
        if dry:
            return (dw_sku, "dry", {"from": cur_price, "to": new_price, "cost": new_cost})

        with _sem:
            r = gql(UPD, {"pid": product_gid, "vs": [variant_input]})
        errs = r["productVariantsBulkUpdate"]["userErrors"]
        if errs:
            return (dw_sku, f"shopify_err:{str(errs)[:120]}", None)

        esc = dw_sku.replace("'", "''")
        pg_exec(f"UPDATE kravet_catalog SET price_retail={new_price}, "
                f"cost_price={new_cost if new_cost is not None else 'NULL'}, "
                f"updated_at=now() WHERE dw_sku='{esc}'")
        return (dw_sku, "ok", {"from": cur_price, "to": new_price, "cost": new_cost})
    except Exception as e:
        return (dw_sku, f"exc:{type(e).__name__}:{str(e)[:100]}", None)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--limit", type=int, default=0)
    ap.add_argument("--dry-run", action="store_true")
    ap.add_argument("--threads", type=int, default=8)
    args = ap.parse_args()

    # Resume: skip SKUs already marked done in JSONL
    done = set()
    if os.path.exists(OUT_JSONL):
        with open(OUT_JSONL) as f:
            for line in f:
                try: d = json.loads(line); done.add(d["dw_sku"])
                except Exception: pass

    # Join diff ↔ authoritative variant map.
    # Recompute "change" against Shopify's actual current price (map.price),
    # not catalog.price_retail which had drift.
    sql = """SELECT u.dw_sku, m.variant_gid, m.product_gid, m.price::text,
                    u.sheet_map::text, u.sheet_cost::text
               FROM kravet_diff_update_2026_04_20 u
               JOIN kravet_dwkk_variant_map m ON m.dw_sku = u.dw_sku
              WHERE u.change_flag='retail_changed'"""
    if args.limit: sql += f" LIMIT {args.limit}"
    rows = pg_rows(sql)
    rows = [r for r in rows if r[0] not in done]
    print(f"[scope] {len(rows)} rows to update (resumed: {len(done)})")

    out = open(OUT_JSONL, "a", buffering=1)
    ok = err = nochange = dry = 0
    t0 = time.time()
    with ThreadPoolExecutor(max_workers=args.threads) as ex:
        futs = [ex.submit(process_one, r, args.dry_run) for r in rows]
        for i, f in enumerate(as_completed(futs), 1):
            dw_sku, status, data = f.result()
            out.write(json.dumps({"dw_sku": dw_sku, "status": status, "data": data}) + "\n")
            if status == "ok":         ok += 1
            elif status == "nochange": nochange += 1
            elif status == "dry":      dry += 1
            else:                      err += 1
            if i % 100 == 0 or i == len(rows):
                rate = i / max(1, time.time() - t0)
                eta = (len(rows) - i) / max(1, rate)
                print(f"  [{i:,}/{len(rows):,}] ok={ok} nc={nochange} err={err} dry={dry}  "
                      f"{rate:.1f}/s  eta {eta/60:.0f}m", flush=True)
    out.close()
    print(f"\n[done] ok={ok} nochange={nochange} err={err} dry={dry}  {time.time()-t0:.0f}s")


if __name__ == "__main__":
    main()