[object Object]

← back to Designer Wallcoverings

Bucket B: Anna French sample-only roll recovery (feed-first, draft-gated)

6d0402811ab3c8155633d25b9d2c3483373a5cac · 2026-06-22 16:51:19 -0700 · Steve

Clone of bucketB-grahambrown on anna_french_catalog. Worklist DWAF- fabric SKUs
bridge to the DWTA-/AT#### wallcovering catalog via the handle mfr code (AF->AT).
60/357 recover a sellable per-roll price (catalog_retail), 297 -> Needs-Price,
0 discontinued. --apply wrote 60 PG retail_price rows (source of truth), tagged
297 Needs-Price, granted 0 rolls (shared 1k cap full) -> 60 staged for the nightly
bucketB-land-all.sh drain. 0 created/0 flips/0 dups/0 failed today.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files touched

Diff

commit 6d0402811ab3c8155633d25b9d2c3483373a5cac
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon Jun 22 16:51:19 2026 -0700

    Bucket B: Anna French sample-only roll recovery (feed-first, draft-gated)
    
    Clone of bucketB-grahambrown on anna_french_catalog. Worklist DWAF- fabric SKUs
    bridge to the DWTA-/AT#### wallcovering catalog via the handle mfr code (AF->AT).
    60/357 recover a sellable per-roll price (catalog_retail), 297 -> Needs-Price,
    0 discontinued. --apply wrote 60 PG retail_price rows (source of truth), tagged
    297 Needs-Price, granted 0 rolls (shared 1k cap full) -> 60 staged for the nightly
    bucketB-land-all.sh drain. 0 created/0 flips/0 dups/0 failed today.
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
 shopify/scripts/bucketB-annafrench-enrich.sh       |  74 +++++
 .../scripts/bucketB-annafrench-roll-variants.js    | 341 +++++++++++++++++++++
 2 files changed, 415 insertions(+)

diff --git a/shopify/scripts/bucketB-annafrench-enrich.sh b/shopify/scripts/bucketB-annafrench-enrich.sh
new file mode 100755
index 00000000..c0582365
--- /dev/null
+++ b/shopify/scripts/bucketB-annafrench-enrich.sh
@@ -0,0 +1,74 @@
+#!/usr/bin/env bash
+# bucketB-annafrench-enrich.sh  (2026-06-22)  — owner: vp-dw-commerce
+#
+# Produce data/bucketB-annafrench-enriched.tsv by joining the 357-row Anna French
+# worklist (shopify_id base_sku handle sample_sku) to anna_french_catalog (dw_unified).
+#
+# JOIN KEY — the worklist base_sku is a DWAF-##### fabric-cohort SKU; the catalog uses a
+#   SEPARATE DWTA-##### dw_sku, so an exact dw_sku join recovers ZERO. The reliable bridge
+#   is the manufacturer code carried in the product handle (…-af24562-… = AF24562). Anna
+#   French's wallcovering catalog stores that same pattern/colorway under the AT#### code
+#   (AF24553 fabric  <->  AT24553 wallcovering — verified same pattern name + width). So we
+#   extract a[ft]#### from the handle, AF->AT substitute, and join on catalog.mfr_sku.
+#   75/357 recover a catalog match this way (60 with a real price). The other ~282 carry no
+#   handle code or are genuine fabric-only SKUs absent from the wallcovering catalog -> none
+#   (Needs-Price); re-scrape annafrench.com later without touching this run. Feed-first PG
+#   join — NEVER invents a price.
+#
+# PRICE PRIORITY (Anna French is a Thibaut-family line but NOT Kravet-umbrella):
+#   prefer price_retail (per single roll) verbatim; else DW retail = price_trade/0.65/0.85.
+#   A matched-but-blank price -> none (Needs-Price); we NEVER force-price.
+#
+# DISCONTINUED: catalog discontinued=true -> NO roll, emit cat_disc='t'; the JS tags those
+#   Discontinued-Review (potential future Bucket C, not in the approved 33). (None in the
+#   current matched cohort, but the column is carried for correctness.)
+#
+# Enriched TSV cols (tab-separated, matches the grahambrown enriched schema):
+#   shopify_id  base_sku  sample_sku  mfr_sku  price_retail  price_trade  price_source  cat_width  cat_width_in  cat_image  cat_disc
+set -euo pipefail
+WL="$HOME/Projects/designerwallcoverings/today-viewer/data/bucketB-annafrench-worklist.tsv"
+OUT="$HOME/Projects/designerwallcoverings/today-viewer/data/bucketB-annafrench-enriched.tsv"
+REMOTE_WL="/tmp/bucketB-annafrench-worklist.tsv"
+REMOTE_OUT="/tmp/bucketB-annafrench-enriched.tsv"
+
+# ship the worklist (header stripped) to Kamatera for the \copy
+tail -n +2 "$WL" > /tmp/_af_wl_noheader.tsv
+scp -q /tmp/_af_wl_noheader.tsv "my-server:$REMOTE_WL"
+
+ssh my-server "sudo -u postgres psql -d dw_unified -v ON_ERROR_STOP=1" << 'SQL'
+CREATE TEMP TABLE wl(shopify_id text, base_sku text, handle text, sample_sku text);
+\copy wl FROM '/tmp/bucketB-annafrench-worklist.tsv' WITH (FORMAT csv, DELIMITER E'\t')
+
+-- mfr bridge code from the handle, AF->AT substituted to the catalog's wallcovering code
+ALTER TABLE wl ADD COLUMN mfr_at text;
+UPDATE wl SET mfr_at = regexp_replace(upper((regexp_match(handle, '(a[ft][0-9]+)'))[1]), '^AF', 'AT');
+
+-- one resolved row per worklist product (dedup duplicate catalog rows by MAX)
+CREATE TEMP TABLE enr AS
+SELECT DISTINCT ON (wl.shopify_id)
+  wl.shopify_id, wl.base_sku, wl.sample_sku,
+  MAX(a.mfr_sku)                                          AS mfr_sku,
+  MAX(NULLIF(a.price_retail,0))                           AS price_retail,
+  MAX(NULLIF(a.price_trade,0))                            AS price_trade,
+  MAX(a.width)                                            AS cat_width,
+  bool_or(a.image_url IS NOT NULL AND a.image_url <> '')  AS has_img,
+  bool_or(a.discontinued IS TRUE)                         AS disc
+FROM wl
+LEFT JOIN anna_french_catalog a ON wl.mfr_at IS NOT NULL AND upper(a.mfr_sku) = wl.mfr_at
+GROUP BY wl.shopify_id, wl.base_sku, wl.sample_sku
+ORDER BY wl.shopify_id, wl.base_sku;
+
+-- emit enriched TSV. discontinued -> no price (cat_disc='t' carries it). else retail -> catalog_retail;
+-- else trade -> catalog_trade_derived; else none (Needs-Price).
+\copy (SELECT shopify_id, base_sku, sample_sku, COALESCE(mfr_sku,''), CASE WHEN NOT disc AND price_retail > 0 THEN price_retail::text ELSE '' END, CASE WHEN NOT disc AND COALESCE(price_retail,0)=0 AND price_trade > 0 THEN price_trade::text ELSE '' END, CASE WHEN disc THEN 'discontinued' WHEN price_retail > 0 THEN 'catalog_retail' WHEN COALESCE(price_retail,0)=0 AND price_trade > 0 THEN 'catalog_trade_derived' ELSE 'none' END, COALESCE(cat_width,''), '', CASE WHEN has_img THEN 't' ELSE '' END, CASE WHEN disc THEN 't' ELSE '' END FROM enr ORDER BY base_sku) TO '/tmp/bucketB-annafrench-enriched.tsv' WITH (FORMAT text, DELIMITER E'\t', NULL '')
+
+SELECT 'enriched rows' lbl, count(*)::text FROM enr
+UNION ALL SELECT 'catalog_retail', count(*)::text FROM enr WHERE NOT disc AND price_retail > 0
+UNION ALL SELECT 'trade_derived', count(*)::text FROM enr WHERE NOT disc AND COALESCE(price_retail,0)=0 AND price_trade > 0
+UNION ALL SELECT 'discontinued', count(*)::text FROM enr WHERE disc
+UNION ALL SELECT 'none (Needs-Price)', count(*)::text FROM enr WHERE NOT disc AND COALESCE(price_retail,0)=0 AND COALESCE(price_trade,0)=0;
+SQL
+
+scp -q "my-server:$REMOTE_OUT" "$OUT"
+echo "enriched -> $OUT"
+wc -l "$OUT"
diff --git a/shopify/scripts/bucketB-annafrench-roll-variants.js b/shopify/scripts/bucketB-annafrench-roll-variants.js
new file mode 100644
index 00000000..ac3e04e4
--- /dev/null
+++ b/shopify/scripts/bucketB-annafrench-roll-variants.js
@@ -0,0 +1,341 @@
+#!/usr/bin/env node
+/**
+ * bucketB-annafrench-roll-variants.js  (2026-06-22)  — owner: vp-dw-commerce
+ *
+ * Bucket B — Anna French sample-only recovery (Steve APPROVED, feed-first, draft-gated,
+ * reversible). Clone of bucketB-grahambrown-roll-variants.js pointed at anna_french_catalog.
+ * For the 357 Anna French sample-only products (Sample variant present, no sellable ROLL),
+ * add the missing ROLL variant `{DW_SKU}` alongside the existing `{DW_SKU}-Sample` ($4.25).
+ *
+ * PRICE SOURCE — recover from PG first (feed-first), scrape only gaps:
+ *   anna_french_catalog (dw_unified) carries price_retail, price_trade, mfr_sku, dw_sku,
+ *   discontinued. The worklist base_sku is a DWAF-##### fabric-cohort SKU; the catalog keys
+ *   a SEPARATE DWTA-##### dw_sku, so an exact dw_sku join recovers ZERO. The reliable bridge
+ *   is the manufacturer code in the product handle (…-af24562-… = AF24562); Anna French's
+ *   wallcovering catalog stores the same pattern/colorway under the AT#### code (AF24553
+ *   fabric <-> AT24553 wallcovering, verified same pattern + width). The upstream enricher
+ *   (bucketB-annafrench-enrich.sh) extracts that code, AF->AT substitutes, joins on
+ *   catalog.mfr_sku, and writes data/bucketB-annafrench-enriched.tsv. This script never
+ *   invents a price.
+ *
+ *   60/357 recover a real sellable per-roll price (catalog_retail). The other 297 carry no
+ *   handle code or are genuine fabric-only SKUs absent from the wallcovering catalog -> GATED
+ *   to Needs-Price (stay sample-only). Catalog-missing rows can be re-scraped later off
+ *   annafrench.com without touching this run.
+ *
+ * PRICING RULE (Anna French is a Thibaut-family line but NOT Kravet-umbrella):
+ *   prefer the direct DW retail (price_retail, per single roll) -> verbatim
+ *   (carried in the enriched TSV as price_retail).
+ *   else if only a trade basis -> DW retail = price_trade/0.65/0.85 (enriched price_trade).
+ *   (No row in the current matched cohort is trade-only, but the branch is kept for
+ *   correctness.) NEVER force-price.
+ *
+ * DISCONTINUED: a catalog discontinued=true row gets NO roll and is tagged Discontinued-Review
+ *   (a potential future Bucket C, NOT in the approved 33). None in the current matched cohort,
+ *   but the branch is enforced.
+ *
+ * ORDER OF OPS (PostgreSQL BEFORE Shopify):
+ *   1. read recovered roll price from data/bucketB-annafrench-enriched.tsv (PG-sourced)
+ *   2. WRITE the roll price into dw_unified.shopify_products.retail_price for the matching
+ *      shopify_id (GID) -- source of truth first
+ *   3. create the ROLL variant on the Shopify product (sku on inventoryItem), reorder
+ *      roll-first, set metafields (mfr_sku, unit_of_measure, price_updated_at)
+ *   4. for unmatched (price_source=none): tag Needs-Price (+ Needs-Width/Needs-Image where
+ *      the catalog gave us no width/image); for discontinued: tag Discontinued-Review only —
+ *      NO variant, status untouched
+ *
+ * SHARED VARIANT BUDGET (budget.cjs, 1k/day, shared with every other Bucket B vendor + Thibaut):
+ *   take('roll', N) before creating, refund the unused grant after — fail-closed refund so a
+ *   429/exit/partial never silently drains the daily slice. If the combined day would exceed
+ *   the cap (grant < want), the remainder is STAGED (deferred to the next window) and noted in
+ *   the report — never forced past the ceiling. Expect 0 created + all staged on a full-cap day.
+ *
+ * HARD RULES enforced:
+ *   - NEVER DUPLICATE — operate on the EXISTING product by shopify_id; idempotent skip if a
+ *     non-Sample variant already exists. Never create a 2nd product.
+ *   - PRICE GATE — no recovered price => row stays sample-only, tag Needs-Price.
+ *   - PG dw_unified BEFORE Shopify.
+ *   - NEVER flip status. These are ACTIVE; we never set ACTIVE (no img+width work here) and
+ *     never demote. A DRAFT product would stay DRAFT.
+ *   - never touch title / customer-facing vendor field / display_variant tag.
+ *   - never the word "Wallpaper"; SKU never "Unknown" (sample sku is authoritative).
+ *
+ * Resumable: idempotent per product (skips already-has-roll; re-tagging is additive).
+ *
+ *   node bucketB-annafrench-roll-variants.js                          # DRY-RUN
+ *   node bucketB-annafrench-roll-variants.js --apply                  # LIVE (budget-safe)
+ *   DW_ROLL_CAP=100 node bucketB-annafrench-roll-variants.js --apply   # allow up to 100 roll grants today
+ *   node bucketB-annafrench-roll-variants.js --apply --report data/bucketB/af-run-report.json
+ *
+ * NOTE: the shared budget defaults roll cap to 0 (upload/backlog priority), so a plain
+ *   --apply will grant 0 and STAGE all priced rolls until a roll slice is freed. This script
+ *   auto-joins the nightly bucketB-land-all.sh drain (which sets DW_ROLL_CAP) — no per-vendor
+ *   launchd job. To land manually, set DW_ROLL_CAP (still clamped to the global 1k ceiling).
+ */
+const https = require('https');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { execFileSync } = require('child_process');
+
+const args = process.argv.slice(2);
+const APPLY = args.includes('--apply');
+const numArg = (flag, dflt) => { const i = args.indexOf(flag); return i >= 0 ? parseInt(args[i + 1], 10) : dflt; };
+const strArg = (flag, dflt) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : dflt; };
+const MAX_CREATE = numArg('--max-create', 900);
+const BATCH_SIZE = numArg('--batch-size', 100);
+const BATCH_GAP_MS = numArg('--batch-gap', 90000);
+const REPORT = strArg('--report', null);
+
+const TSV = path.join(os.homedir(), 'Projects/designerwallcoverings/today-viewer/data/bucketB-annafrench-enriched.tsv');
+const TODAY = new Date().toISOString().slice(0, 10);
+
+// shared daily Shopify variant budget (roll category — same as the other Bucket B vendors)
+const budget = require(path.join(os.homedir(), 'Projects/designerwallcoverings/scripts/variant-budget/budget.cjs'));
+const BUDGET_WHO = 'roll';
+
+const env = fs.readFileSync(path.join(os.homedir(), 'Projects/secrets-manager/.env'), 'utf8');
+const TOKEN = (env.match(/^SHOPIFY_ADMIN_TOKEN=(.*)$/m) || [])[1].replace(/['"]/g, '').trim();
+const STORE = 'designer-laboratory-sandbox.myshopify.com', API = '2024-10';
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+function round2(n) { return Math.round((n + Number.EPSILON) * 100) / 100; }
+
+// ---- dw_unified write (PG-first) ------------------------------------------
+// worklist shopify_id is the NUMERIC product id; shopify_products keys on the GID form.
+function pgWriteRollPrice(rows) {
+  if (!rows.length) return '0';
+  const values = rows.map(r =>
+    `('gid://shopify/Product/${r.shopify_id}', ${r.price})`).join(',\n');
+  const sql = `
+BEGIN;
+CREATE TEMP TABLE _bBaf_price(gid text, roll_price numeric);
+INSERT INTO _bBaf_price(gid, roll_price) VALUES
+${values};
+UPDATE shopify_products sp
+   SET retail_price = b.roll_price
+  FROM _bBaf_price b
+ WHERE sp.shopify_id = b.gid;
+SELECT count(*) AS updated FROM _bBaf_price b JOIN shopify_products sp ON sp.shopify_id=b.gid;
+COMMIT;`;
+  const out = execFileSync('ssh', ['my-server',
+    `sudo -u postgres psql -d dw_unified -v ON_ERROR_STOP=1 -t -A`],
+    { input: sql, encoding: 'utf8', maxBuffer: 1 << 24 });
+  return out.trim();
+}
+
+// ---- Shopify GraphQL -------------------------------------------------------
+function gql(query, variables = {}) {
+  return new Promise((resolve, reject) => {
+    const body = JSON.stringify({ query, variables });
+    const req = https.request({ host: STORE, path: `/admin/api/${API}/graphql.json`, method: 'POST',
+      headers: { 'Content-Type': 'application/json', 'X-Shopify-Access-Token': TOKEN, 'Content-Length': Buffer.byteLength(body) } },
+      r => { let d = ''; r.on('data', c => d += c); r.on('end', () => { try { resolve(JSON.parse(d)); } catch (e) { reject(d); } }); });
+    req.on('error', reject); req.write(body); req.end();
+  });
+}
+async function gqlR(q, v, tries = 4) {
+  for (let i = 0; i < tries; i++) {
+    const r = await gql(q, v);
+    if (r && !r.errors) return r;
+    const throttled = (r.errors || []).some(e => /throttl/i.test(JSON.stringify(e)));
+    if (!throttled) return r;
+    await sleep(1500 * (i + 1));
+  }
+  return gql(q, v);
+}
+
+const Q_PRODUCT = `query($id:ID!){product(id:$id){id title status tags
+  options{name values}
+  variants(first:30){nodes{id title sku selectedOptions{name value} inventoryItem{id}}}}}`;
+const M_VAR_CREATE = `mutation($pid:ID!,$variants:[ProductVariantsBulkInput!]!){
+  productVariantsBulkCreate(productId:$pid,variants:$variants){
+    productVariants{id sku} userErrors{field message}}}`;
+const M_REORDER = `mutation($pid:ID!,$positions:[ProductVariantPositionInput!]!){
+  productVariantsBulkReorder(productId:$pid,positions:$positions){userErrors{field message}}}`;
+const M_MF = `mutation($mf:[MetafieldsSetInput!]!){metafieldsSet(metafields:$mf){userErrors{field message}}}`;
+const M_TAGS = `mutation($id:ID!,$tags:[String!]!){tagsAdd(id:$id,tags:$tags){userErrors{field message}}}`;
+
+// ---- worklist (enriched) ---------------------------------------------------
+// cols: shopify_id base_sku sample_sku mfr_sku price_retail price_trade price_source cat_width cat_width_in cat_image cat_disc
+function loadWork() {
+  const lines = fs.readFileSync(TSV, 'utf8').replace(/\n$/, '').split('\n');
+  return lines.map(l => {
+    const c = l.split('\t');
+    return {
+      shopify_id: (c[0] || '').trim(), base_sku: (c[1] || '').trim(),
+      sample_sku: (c[2] || '').trim(), mfr_sku: (c[3] || '').trim(),
+      price_retail: c[4] ? parseFloat(c[4]) : null,
+      price_trade: c[5] ? parseFloat(c[5]) : null,
+      price_source: (c[6] || '').trim(),
+      cat_width: (c[7] || '').trim(), cat_width_in: (c[8] || '').trim(),
+      cat_image: (c[9] || '').trim(), cat_disc: (c[10] || '').trim() === 't',
+    };
+  }).filter(r => r.shopify_id && /^\d+$/.test(r.shopify_id));
+}
+
+// Anna French price rule (Thibaut-family but NOT Kravet): prefer retail verbatim; else trade/0.65/0.85.
+function rollPriceFor(r) {
+  if (r.cat_disc) return null; // discontinued never gets a roll
+  if (r.price_retail != null && r.price_retail > 0) return { price: round2(r.price_retail), src: 'catalog_retail' };
+  if (r.price_trade != null && r.price_trade > 0) return { price: round2(r.price_trade / 0.65 / 0.85), src: 'catalog_trade_derived' };
+  return null;
+}
+
+(async () => {
+  const rows = loadWork();
+  const priced = []; const unpriced = []; const discontinued = [];
+  for (const r of rows) {
+    if (r.cat_disc) { discontinued.push(r); continue; }
+    const p = rollPriceFor(r);
+    if (p) { r.roll_price = p.price; r.price_basis = p.src; priced.push(r); }
+    else unpriced.push(r);
+  }
+  console.log(`bucketB-annafrench — ${rows.length} products (${priced.length} priced, ${unpriced.length} no-price, ${discontinued.length} discontinued) — ${APPLY ? '🔴 LIVE' : 'DRY-RUN'}`);
+  console.log(`max-create=${MAX_CREATE} batch-size=${BATCH_SIZE} batch-gap=${BATCH_GAP_MS}ms`);
+
+  // shared budget snapshot
+  let budgetGrant = 0, budgetWant = 0;
+  if (priced.length) {
+    budgetWant = Math.min(priced.length, MAX_CREATE);
+    if (APPLY) {
+      budgetGrant = budget.take(BUDGET_WHO, budgetWant);
+      console.log(`budget.take('${BUDGET_WHO}', ${budgetWant}) -> granted ${budgetGrant}`);
+      if (budgetGrant < budgetWant) {
+        console.log(`⚠ shared 1k/day cap leaves only ${budgetGrant} roll slots; ${budgetWant - budgetGrant} priced rows STAGED for the next window`);
+      }
+    } else {
+      budgetGrant = Math.min(budget.remaining(BUDGET_WHO), budgetWant);
+      console.log(`(dry-run) budget.remaining('${BUDGET_WHO}')=${budget.remaining(BUDGET_WHO)} -> would create up to ${budgetGrant} this window`);
+    }
+  }
+  console.log('');
+
+  const report = { started: new Date().toISOString(), apply: APPLY, vendor: 'Anna French', scrapedFresh: 0,
+    created: [], skippedAlreadyRoll: [], taggedNeedsPrice: [], taggedDiscontinued: [], staged: [], failed: [],
+    byPriceSource: {}, budget: { who: BUDGET_WHO, want: budgetWant, granted: budgetGrant, refunded: 0 }, geminiCostUsd: 0 };
+
+  // rows we may actually create this run (bounded by budget grant)
+  const toCreate = priced.slice(0, budgetGrant);
+  const stagedPriced = priced.slice(budgetGrant);
+  for (const s of stagedPriced) report.staged.push({ shopify_id: s.shopify_id, base_sku: s.base_sku, roll_price: s.roll_price, reason: 'budget-cap' });
+
+  // ---- PG-FIRST: write recovered roll prices to dw_unified before any Shopify write ----
+  // Write for ALL priced rows (source of truth), even staged ones — the price is known; only
+  // the Shopify variant is deferred. (Idempotent UPDATE; re-running is harmless.)
+  if (APPLY && priced.length) {
+    try {
+      const res = pgWriteRollPrice(priced.map(w => ({ shopify_id: w.shopify_id, price: round2(w.roll_price) })));
+      console.log(`PG (dw_unified) retail_price written — psql updated count: ${res}\n`);
+      report.pgUpdated = res;
+    } catch (e) {
+      console.error('PG write FAILED — refunding budget + aborting before Shopify:', e.message || e);
+      if (budgetGrant > 0) budget.refund(BUDGET_WHO, budgetGrant);
+      process.exit(2);
+    }
+  } else if (priced.length) {
+    console.log(`(dry-run) would write ${priced.length} retail_price rows to dw_unified first\n`);
+  }
+
+  // ---- Shopify: add ROLL variant to each (budget-permitted) priced product ----
+  let createdThisRun = 0, inBatch = 0;
+  for (const w of toCreate) {
+    const pid = `gid://shopify/Product/${w.shopify_id}`;
+    const pr = await gqlR(Q_PRODUCT, { id: pid });
+    const p = pr.data && pr.data.product;
+    if (!p) { console.log(`❌ ${w.shopify_id} ${w.base_sku} not found`); report.failed.push({ ...w, reason: 'not-found' }); continue; }
+
+    const vs = p.variants.nodes;
+    const sample = vs.find(v => /sample/i.test(v.title) || /-sample$/i.test(v.sku || ''));
+    const roll = vs.find(v => v !== sample);
+    if (roll) { console.log(`⏭  ${p.title.slice(0,46)} already has roll (${roll.sku})`); report.skippedAlreadyRoll.push({ ...w, existingRollSku: roll.sku }); continue; }
+
+    // SKU: prefer existing sample sku minus -Sample (authoritative), else base_sku. Never Unknown.
+    let skuBase = (sample && sample.sku) ? sample.sku.replace(/-sample$/i, '') : w.base_sku;
+    if (!skuBase) { console.log(`❌ ${w.shopify_id} — no derivable SKU — SKIP`); report.failed.push({ ...w, reason: 'no-sku' }); continue; }
+    const price = round2(w.roll_price);
+
+    console.log(`${APPLY ? '➕' : '·'} ${p.title.slice(0,44).padEnd(44)} [${p.status}] roll $${price} (${w.price_basis}) sku ${skuBase}`);
+    report.byPriceSource[w.price_basis] = (report.byPriceSource[w.price_basis] || 0) + 1;
+    if (!APPLY) { report.created.push({ ...w, skuBase, price, dryRun: true }); continue; }
+
+    const cr = await gqlR(M_VAR_CREATE, { pid, variants: [{
+      optionValues: [{ optionName: (p.options[0] && p.options[0].name) || 'Title', name: 'Single Roll' }],
+      price: String(price), taxable: true, inventoryPolicy: 'CONTINUE',
+      inventoryItem: { sku: skuBase, tracked: false },
+    }] });
+    const ue = cr.data?.productVariantsBulkCreate?.userErrors || [];
+    if (ue.length) { console.log(`   ❌ create: ${JSON.stringify(ue)}`); report.failed.push({ ...w, skuBase, reason: 'create-error', errors: ue }); continue; }
+    const newId = cr.data.productVariantsBulkCreate.productVariants[0].id;
+
+    if (sample) {
+      const ro = await gqlR(M_REORDER, { pid, positions: [{ id: newId, position: 1 }, { id: sample.id, position: 2 }] });
+      const re = ro.data?.productVariantsBulkReorder?.userErrors || [];
+      if (re.length) console.log(`   ⚠ reorder: ${JSON.stringify(re)}`);
+    }
+
+    const mf = await gqlR(M_MF, { mf: [
+      { ownerId: pid, namespace: 'custom', key: 'manufacturer_sku', type: 'single_line_text_field', value: w.mfr_sku || skuBase },
+      { ownerId: pid, namespace: 'dwc', key: 'manufacturer_sku', type: 'single_line_text_field', value: w.mfr_sku || skuBase },
+      { ownerId: pid, namespace: 'custom', key: 'price_updated_at', type: 'date', value: TODAY },
+      { ownerId: pid, namespace: 'global', key: 'unit_of_measure', type: 'single_line_text_field', value: 'Priced Per Single Roll' },
+    ] });
+    const me = mf.data?.metafieldsSet?.userErrors || [];
+    if (me.length) console.log(`   ⚠ metafields: ${JSON.stringify(me)}`);
+
+    report.created.push({ ...w, skuBase, price, variantId: newId });
+    createdThisRun++; inBatch++;
+    if (inBatch >= BATCH_SIZE) { console.log(`   …batch of ${inBatch} done — sleeping ${BATCH_GAP_MS/1000}s (≥90s bulk gap)`); await sleep(BATCH_GAP_MS); inBatch = 0; }
+    else await sleep(200);
+  }
+
+  // ---- refund unused budget grant (fail-closed) — granted but not consumed by a real create ----
+  if (APPLY && budgetGrant > 0) {
+    const unused = budgetGrant - createdThisRun;
+    if (unused > 0) { const back = budget.refund(BUDGET_WHO, unused); report.budget.refunded = back; console.log(`\nbudget.refund('${BUDGET_WHO}', ${unused}) -> returned ${back} (created ${createdThisRun}/${budgetGrant})`); }
+  }
+
+  // ---- unpriced: tag Needs-Price (+ Needs-Width/Needs-Image where catalog lacked them) ----
+  console.log(`\n--- tagging ${unpriced.length} unpriced rows (Needs-Price) ---`);
+  for (const w of unpriced) {
+    const tags = ['Needs-Price'];
+    if (!w.cat_width) tags.push('Needs-Width');
+    if (!w.cat_image) tags.push('Needs-Image');
+    if (!APPLY) { report.taggedNeedsPrice.push({ ...w, tags, dryRun: true }); continue; }
+    const pid = `gid://shopify/Product/${w.shopify_id}`;
+    const tr = await gqlR(M_TAGS, { id: pid, tags });
+    const te = tr.data?.tagsAdd?.userErrors || [];
+    if (te.length) { console.log(`   ⚠ tag ${w.base_sku}: ${JSON.stringify(te)}`); report.failed.push({ ...w, reason: 'tag-error', errors: te }); continue; }
+    report.taggedNeedsPrice.push({ ...w, tags });
+    await sleep(80);
+  }
+
+  // ---- discontinued: tag Discontinued-Review (NO roll, potential future Bucket C) ----
+  console.log(`\n--- tagging ${discontinued.length} discontinued rows (Discontinued-Review) ---`);
+  for (const w of discontinued) {
+    const tags = ['Discontinued-Review'];
+    if (!APPLY) { report.taggedDiscontinued.push({ ...w, tags, dryRun: true }); continue; }
+    const pid = `gid://shopify/Product/${w.shopify_id}`;
+    const tr = await gqlR(M_TAGS, { id: pid, tags });
+    const te = tr.data?.tagsAdd?.userErrors || [];
+    if (te.length) { console.log(`   ⚠ tag ${w.base_sku}: ${JSON.stringify(te)}`); report.failed.push({ ...w, reason: 'tag-error', errors: te }); continue; }
+    report.taggedDiscontinued.push({ ...w, tags });
+    await sleep(80);
+  }
+
+  report.finished = new Date().toISOString();
+  report.totals = {
+    rollVariantsCreated: report.created.filter(c => !c.dryRun).length,
+    rollVariantsWouldCreate: report.created.filter(c => c.dryRun).length,
+    stayedSampleOnly_NeedsPrice: report.taggedNeedsPrice.length,
+    discontinuedReview: report.taggedDiscontinued.length,
+    stagedForNextWindow: report.staged.length,
+    skippedAlreadyHadRoll: report.skippedAlreadyRoll.length,
+    scrapedFresh: report.scrapedFresh,
+    failed: report.failed.length,
+  };
+  console.log(`\n=== SUMMARY ===`);
+  console.log(JSON.stringify(report.totals, null, 2));
+  console.log('byPriceSource:', JSON.stringify(report.byPriceSource));
+  console.log('budget:', JSON.stringify(report.budget));
+  if (REPORT) { fs.mkdirSync(path.dirname(REPORT), { recursive: true }); fs.writeFileSync(REPORT, JSON.stringify(report, null, 2)); console.log(`report -> ${REPORT}`); }
+})();

← 9cd6d0d0 Tier B finalized: vendor-level write set (16,038 products) +  ·  back to Designer Wallcoverings  ·  Bucket B Thibaut: roll-variant recovery script + run report b67ee2ad →