[object Object]

← back to Designerwallcoverings

TK-10209: Osborne & Little variant-relabel tool + read-only defect enumeration

2a095222a35740ff60f72dec842a4b0d074e26cd · 2026-08-04 16:43:56 -0700 · Steve

Enumerates the 88 live ACTIVE Osborne & Little products that are single-variant,
priced at full-roll retail (>$5), but option-labeled Size:Sample (GMC
no_roll_variant_price exclusion + non-standard). relabel.mjs (dry-run default,
--apply/--pilot/--only/--limit, per-product live re-verify, last-run.json) relabels
Size:Sample->Size:Roll and adds a real Size:Sample $4.25 variant to match the
unanimous live 534-sibling norm.

Files touched

Diff

commit 2a095222a35740ff60f72dec842a4b0d074e26cd
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Aug 4 16:43:56 2026 -0700

    TK-10209: Osborne & Little variant-relabel tool + read-only defect enumeration
    
    Enumerates the 88 live ACTIVE Osborne & Little products that are single-variant,
    priced at full-roll retail (>$5), but option-labeled Size:Sample (GMC
    no_roll_variant_price exclusion + non-standard). relabel.mjs (dry-run default,
    --apply/--pilot/--only/--limit, per-product live re-verify, last-run.json) relabels
    Size:Sample->Size:Roll and adds a real Size:Sample $4.25 variant to match the
    unanimous live 534-sibling norm.
---
 scripts/osborne-relabel/enumerate.mjs |  68 ++++++++++++++
 scripts/osborne-relabel/relabel.mjs   | 162 ++++++++++++++++++++++++++++++++++
 2 files changed, 230 insertions(+)

diff --git a/scripts/osborne-relabel/enumerate.mjs b/scripts/osborne-relabel/enumerate.mjs
new file mode 100644
index 0000000..bb62ab3
--- /dev/null
+++ b/scripts/osborne-relabel/enumerate.mjs
@@ -0,0 +1,68 @@
+#!/usr/bin/env node
+// TK-10209 — READ-ONLY live enumeration of the Osborne-Inc "roll priced but option-labeled Sample" mislabel defect.
+// For every ACTIVE product of the family vendors, capture full variant state and classify.
+// Writes data/enumerate-<ts>.json + a per-vendor summary. NO WRITES to Shopify.
+import fs from 'fs'; import os from 'os';
+const env={}; for(const l of fs.readFileSync(os.homedir()+'/Projects/secrets-manager/.env','utf8').split('\n')){const m=l.match(/^([A-Z0-9_]+)=(.*)$/);if(m)env[m[1]]=m[2].replace(/^["']|["']$/g,'');}
+const API=`https://${env.SHOPIFY_STORE}/admin/api/2024-10/graphql.json`;
+const sleep=ms=>new Promise(r=>setTimeout(r,ms));
+async function gql(q,v){for(let t=0;t<8;t++){const r=await fetch(API,{method:'POST',headers:{'X-Shopify-Access-Token':env.SHOPIFY_ADMIN_TOKEN,'Content-Type':'application/json'},body:JSON.stringify({query:q,variables:v||{}})});if(r.status===429){await sleep(2000);continue;}const j=await r.json();if(j.errors&&JSON.stringify(j.errors).includes('THROTTLED')){await sleep(2500);continue;}if(j.errors)throw new Error(JSON.stringify(j.errors));const th=j.extensions?.cost?.throttleStatus;if(th&&th.currentlyAvailable<300)await sleep(1200);return j;}throw new Error('throttled out');}
+
+const VENDORS=(process.argv[2]||"Osborne & Little,Designers Guild,Nina Campbell,Threads,Baker Lifestyle").split(',');
+const isSampleName=s=>/(sample|memo|swatch)/i.test(s||'');
+const Q=`query($c:String,$q:String!){products(first:50,after:$c,query:$q){pageInfo{hasNextPage endCursor} nodes{
+  id handle title status vendor productType tags
+  featuredImage{url} mediaCount{count}
+  options{name values}
+  variants(first:30){nodes{ id title sku price selectedOptions{name value} inventoryItem{id tracked} }}
+  widthG:metafield(namespace:"global",key:"width"){value}
+  widthC:metafield(namespace:"custom",key:"width"){value}
+  widthD:metafield(namespace:"dwc",key:"width"){value}
+  uom:metafield(namespace:"custom",key:"unit_of_measure"){value}
+  uomG:metafield(namespace:"global",key:"unit_of_measure"){value}
+}}}`;
+
+const all=[]; const summary={};
+for(const V of VENDORS){
+  let cursor=null,hasNext=true,n=0;
+  const q=`status:active vendor:'${V.replace(/'/g,"\\'")}'`;
+  const s={vendor:V,active:0, defect_mislabel:0, healthy_2var:0, sample_only:0, other:0, defect_ids:[]};
+  while(hasNext){
+    const d=await gql(Q,{c:cursor,q});const pg=d.data.products;
+    for(const p of pg.nodes){
+      n++; s.active++;
+      const vars=p.variants.nodes;
+      const prices=vars.map(v=>parseFloat(v.price)).filter(Number.isFinite);
+      const maxP=prices.length?Math.max(...prices):null;
+      const hasRealSample=vars.some(v=>isSampleName(v.title)&&parseFloat(v.price)<=5);
+      const hasSellableRoll=vars.some(v=>!isSampleName(v.title)&&parseFloat(v.price)>5);
+      const width=(p.widthG?.value||p.widthC?.value||p.widthD?.value||'').trim();
+      const uom=(p.uom?.value||p.uomG?.value||'').trim();
+      const hasImg=!!(p.featuredImage?.url)||(p.mediaCount?.count||0)>0;
+      // DEFECT shape: single variant, priced >$5, its option/title looks like Sample -> feed reads no roll price
+      const single=vars.length===1;
+      const onlyVarSampleNamed=single&&isSampleName(vars[0].title);
+      const onlyVarPricedRoll=single&&parseFloat(vars[0].price)>5;
+      let cls;
+      if(single&&onlyVarSampleNamed&&onlyVarPricedRoll){cls='defect_mislabel';}
+      else if(hasRealSample&&hasSellableRoll){cls='healthy_2var';}
+      else if(maxP!=null&&maxP<=5){cls='sample_only';}
+      else cls='other';
+      s[cls]=(s[cls]||0)+1;
+      const rec={vendor:V,id:p.id.split('/').pop(),gid:p.id,handle:p.handle,title:p.title,cls,
+        nvars:vars.length,maxP,hasImg,width,uom,options:p.options,
+        variants:vars.map(v=>({id:v.id,title:v.title,sku:v.sku,price:v.price,selectedOptions:v.selectedOptions,invItem:v.inventoryItem?.id,tracked:v.inventoryItem?.tracked}))};
+      if(cls==='defect_mislabel'){s.defect_ids.push(rec.id); all.push(rec);}
+      else if(cls==='other') all.push(rec); // keep 'other' for inspection
+    }
+    hasNext=pg.pageInfo.hasNextPage;cursor=pg.pageInfo.endCursor;
+    if(n%200===0)process.stderr.write(`  ${V}: ${n} scanned\n`);
+  }
+  summary[V]=s;
+  process.stderr.write(`DONE ${V}: active=${s.active} defect=${s.defect_mislabel} healthy2=${s.healthy_2var} sampleOnly=${s.sample_only} other=${s.other}\n`);
+}
+const ts=new Date().toISOString().replace(/[:.]/g,'-');
+const out=`scripts/osborne-relabel/data/enumerate-${ts}.json`;
+fs.writeFileSync(out,JSON.stringify({ts,vendors:VENDORS,summary,records:all},null,2));
+fs.writeFileSync('scripts/osborne-relabel/data/enumerate-latest.json',JSON.stringify({ts,vendors:VENDORS,summary,records:all},null,2));
+console.log(JSON.stringify({summary:Object.fromEntries(Object.entries(summary).map(([k,v])=>[k,{active:v.active,defect_mislabel:v.defect_mislabel,healthy_2var:v.healthy_2var,sample_only:v.sample_only,other:v.other}])),out},null,2));
diff --git a/scripts/osborne-relabel/relabel.mjs b/scripts/osborne-relabel/relabel.mjs
new file mode 100644
index 0000000..cc08660
--- /dev/null
+++ b/scripts/osborne-relabel/relabel.mjs
@@ -0,0 +1,162 @@
+#!/usr/bin/env node
+/**
+ * TK-10209 — Osborne & Little "roll priced but option-labeled Sample" relabel + sample-add tool.
+ *
+ * DEFECT: 88 live ACTIVE Osborne & Little products are single-variant, priced at the real
+ * full-roll retail (>$5), but that one variant's option is `Size: Sample`. The GMC
+ * feed-eligibility engine (feed-eligibility.mjs isSample()) treats it as a sample, finds no
+ * roll price, and excludes them via `no_roll_variant_price`. They are also non-standard: the
+ * DW/Osborne norm is 2 variants — Size:Sample($4.25) + Size:Roll(retail) — verified 534/534.
+ *
+ * FIX per product (matches the unanimous live 534-sibling norm):
+ *   1. Relabel the existing roll-priced variant's option value Size:Sample -> Size:Roll
+ *      (productVariantsBulkUpdate; keeps price, SKU, inventory item, tracking as-is).
+ *   2. Add a real Sample variant: Size:Sample, SKU {rollSku}-Sample, price $4.25, tracked=true
+ *      (productVariantsBulkCreate; tracked matches the 534/534 live Osborne sample norm).
+ *
+ * Result: 2 variants (Sample $4.25 + Roll $retail), option values ["Sample","Roll"], product
+ * stays ACTIVE, becomes GMC feed-eligible (roll price now readable), consistent with siblings.
+ *
+ * REVERSIBLE: relabel Roll->Sample back on the original variant + delete the added Sample
+ * variant restores the exact prior state.
+ *
+ * READ-ONLY BY DEFAULT (dry-run). --apply writes. --pilot / --limit=N cap the set.
+ * --only=<id,id,..> restricts to specific product ids (canary-of-1). Per-product LIVE re-verify
+ * before AND after every write. Writes data/last-run.json.
+ *
+ * $0 — local Admin API only (unmetered). Shopify writes are customer-facing (gated per Steve).
+ */
+import fs from 'fs'; import os from 'os';
+const env = {}; for (const l of fs.readFileSync(os.homedir() + '/Projects/secrets-manager/.env', 'utf8').split('\n')) { const m = l.match(/^([A-Z0-9_]+)=(.*)$/); if (m) env[m[1]] = m[2].replace(/^["']|["']$/g, ''); }
+const API = `https://${env.SHOPIFY_STORE}/admin/api/2024-10/graphql.json`;
+const LOCATION = 'gid://shopify/Location/5795643504';
+const SAMPLE_PRICE = '4.25';
+const SAMPLE_TRACKED = true; // matches live Osborne norm (534/534 tracked)
+
+const APPLY = process.argv.includes('--apply');
+const PILOT = process.argv.includes('--pilot');
+const limArg = process.argv.find(a => a.startsWith('--limit='));
+const onlyArg = process.argv.find(a => a.startsWith('--only='));
+const ONLY = onlyArg ? onlyArg.split('=')[1].split(',').map(s => s.trim()) : null;
+const LIMIT = PILOT ? 1 : (limArg ? parseInt(limArg.split('=')[1], 10) : 99999);
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+async function gql(q, v) {
+  for (let t = 0; t < 8; t++) {
+    const r = await fetch(API, { method: 'POST', headers: { 'X-Shopify-Access-Token': env.SHOPIFY_ADMIN_TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ query: q, variables: v || {} }) });
+    if (r.status === 429) { await sleep(2000); continue; }
+    const j = await r.json();
+    if (j.errors && JSON.stringify(j.errors).includes('THROTTLED')) { await sleep(2500); continue; }
+    if (j.errors) throw new Error(JSON.stringify(j.errors));
+    const th = j.extensions?.cost?.throttleStatus; if (th && th.currentlyAvailable < 300) await sleep(1200);
+    return j.data;
+  }
+  throw new Error('throttled out');
+}
+
+const isSampleName = s => /(sample|memo|swatch)/i.test(s || '');
+
+// Pull one product's live state (single source of truth — NEVER trust the mirror scalars)
+const P_QUERY = `query($id:ID!){product(id:$id){ id title status vendor handle
+  featuredImage{url} mediaCount{count}
+  options{id name optionValues{id name}}
+  variants(first:20){nodes{id title sku price selectedOptions{name value} inventoryItem{id tracked}}}
+  widthG:metafield(namespace:"global",key:"width"){value}
+  widthC:metafield(namespace:"custom",key:"width"){value}
+  widthD:metafield(namespace:"dwc",key:"width"){value}
+}}`;
+
+async function fetchProduct(gid) { return (await gql(P_QUERY, { id: gid })).product; }
+
+// Classify a live product: is it the exact defect shape and safe to fix?
+function classify(p) {
+  const vars = p.variants?.nodes || [];
+  const width = (p.widthG?.value || p.widthC?.value || p.widthD?.value || '').trim();
+  const hasImg = !!(p.featuredImage?.url) || (p.mediaCount?.count || 0) > 0;
+  if (p.status !== 'ACTIVE') return { ok: false, why: `status=${p.status} (expected ACTIVE)` };
+  if (vars.length !== 1) return { ok: false, why: `has ${vars.length} variants (expected 1) — not the defect shape` };
+  const v = vars[0];
+  const price = parseFloat(v.price);
+  if (!(price > 5)) return { ok: false, why: `single variant priced $${v.price} (<=$5) — genuinely sample-only, not a mislabel` };
+  if (!isSampleName(v.title)) return { ok: false, why: `single variant option "${v.title}" is not Sample — not the defect` };
+  if (!hasImg) return { ok: false, why: 'no image (would drop from feed / active-eligibility)' };
+  if (!width) return { ok: false, why: 'no width metafield (would drop from feed / active-eligibility)' };
+  const opt = (p.options || []).find(o => o.name === 'Size') || (p.options || [])[0];
+  if (!opt) return { ok: false, why: 'no product option to relabel' };
+  return { ok: true, variant: v, price, width, hasImg, option: opt };
+}
+
+const M_UPDATE = `mutation($productId:ID!,$variants:[ProductVariantsBulkInput!]!){
+  productVariantsBulkUpdate(productId:$productId, variants:$variants){
+    product{id} productVariants{id title selectedOptions{name value}} userErrors{field message}
+  }}`;
+const M_CREATE = `mutation($productId:ID!,$variants:[ProductVariantsBulkInput!]!){
+  productVariantsBulkCreate(productId:$productId, variants:$variants){
+    product{id} productVariants{id title sku price selectedOptions{name value} inventoryItem{id tracked}} userErrors{field message}
+  }}`;
+
+async function fixOne(p, c) {
+  const gid = p.id;
+  const rollVar = c.variant;
+  const optName = c.option.name; // "Size"
+  // Step 1: relabel existing variant Size:Sample -> Size:Roll
+  const up = await gql(M_UPDATE, { productId: gid, variants: [{ id: rollVar.id, optionValues: [{ name: 'Roll', optionName: optName }] }] });
+  const ue = up.productVariantsBulkUpdate?.userErrors || []; if (ue.length) throw new Error('relabel: ' + JSON.stringify(ue));
+  // Step 2: add the Sample variant Size:Sample @ $4.25
+  const sampleSku = `${rollVar.sku}-Sample`;
+  const cr = await gql(M_CREATE, { productId: gid, variants: [{
+    optionValues: [{ name: 'Sample', optionName: optName }],
+    price: SAMPLE_PRICE,
+    inventoryItem: { sku: sampleSku, tracked: SAMPLE_TRACKED },
+  }] });
+  const ce = cr.productVariantsBulkCreate?.userErrors || []; if (ce.length) throw new Error('add-sample: ' + JSON.stringify(ce));
+  return { sampleSku, created: cr.productVariantsBulkCreate?.productVariants || [] };
+}
+
+// verify AFTER: must be 2 variants — Sample $4.25 + Roll $retail
+function verifyAfter(p) {
+  const vars = p.variants?.nodes || [];
+  if (vars.length !== 2) return { ok: false, why: `expected 2 variants, got ${vars.length}` };
+  const sample = vars.find(v => /sample/i.test(v.selectedOptions?.find(o => o.name === 'Size')?.value || v.title));
+  const roll = vars.find(v => /roll/i.test(v.selectedOptions?.find(o => o.name === 'Size')?.value || v.title));
+  if (!sample) return { ok: false, why: 'no Sample variant present' };
+  if (!roll) return { ok: false, why: 'no Roll variant present' };
+  if (parseFloat(sample.price) !== 4.25) return { ok: false, why: `sample price $${sample.price} != $4.25` };
+  if (!(parseFloat(roll.price) > 5)) return { ok: false, why: `roll price $${roll.price} <= $5` };
+  if (p.status !== 'ACTIVE') return { ok: false, why: `status=${p.status}` };
+  return { ok: true, sample: { sku: sample.sku, price: sample.price }, roll: { sku: roll.sku, price: roll.price } };
+}
+
+(async () => {
+  // Load the enumerated defect set (read-only, produced by enumerate.mjs)
+  const enumPath = 'scripts/osborne-relabel/data/enumerate-latest.json';
+  const en = JSON.parse(fs.readFileSync(enumPath, 'utf8'));
+  let targets = en.records.filter(r => r.cls === 'defect_mislabel');
+  if (ONLY) targets = targets.filter(r => ONLY.includes(String(r.id)));
+  targets = targets.slice(0, LIMIT);
+  console.log(`${APPLY ? 'APPLY' : 'DRY-RUN'} | enumerated defect=${en.records.filter(r => r.cls === 'defect_mislabel').length} | targeting=${targets.length}${ONLY ? ' (--only)' : ''}${PILOT ? ' (PILOT=1)' : ''}\n`);
+
+  const results = []; let ok = 0, skip = 0, fail = 0;
+  for (const t of targets) {
+    const gid = t.gid || `gid://shopify/Product/${t.id}`;
+    let live;
+    try { live = await fetchProduct(gid); } catch (e) { fail++; results.push({ id: t.id, status: 'FETCH_FAIL', err: e.message }); continue; }
+    const c = classify(live);
+    if (!c.ok) { skip++; results.push({ id: t.id, title: live?.title, status: 'SKIP', why: c.why }); console.log(`  ⤳ SKIP ${t.id} ${live?.title?.slice(0,36)} — ${c.why}`); continue; }
+    const plan = { id: t.id, title: live.title, rollSku: c.variant.sku, rollPrice: c.variant.price, addSampleSku: `${c.variant.sku}-Sample`, addSamplePrice: SAMPLE_PRICE, relabel: 'Size:Sample -> Size:Roll' };
+    if (!APPLY) { results.push({ ...plan, status: 'WOULD_FIX' }); console.log(`  · WOULD FIX ${t.id} | ${live.title.slice(0,34)} | roll ${c.variant.sku} $${c.variant.price} keep; add ${plan.addSampleSku} $4.25; relabel Sample→Roll`); continue; }
+    try {
+      const w = await fixOne(live, c);
+      await sleep(400);
+      const after = await fetchProduct(gid);
+      const va = verifyAfter(after);
+      if (!va.ok) { fail++; results.push({ ...plan, status: 'VERIFY_FAIL', why: va.why }); console.log(`  ✗ VERIFY_FAIL ${t.id} — ${va.why}`); }
+      else { ok++; results.push({ ...plan, status: 'FIXED', after: va }); console.log(`  ✓ FIXED ${t.id} | ${live.title.slice(0,32)} | Roll ${va.roll.sku} $${va.roll.price} + Sample ${va.sample.sku} $${va.sample.price} | ACTIVE`); }
+    } catch (e) { fail++; results.push({ ...plan, status: 'WRITE_FAIL', err: e.message }); console.log(`  ✗ WRITE_FAIL ${t.id} — ${e.message.slice(0,120)}`); }
+    await sleep(500);
+  }
+  console.log(`\n${APPLY ? 'APPLIED' : 'DRY-RUN'}: ok=${ok} skip=${skip} fail=${fail}`);
+  const runPath = 'scripts/osborne-relabel/data/last-run.json';
+  fs.writeFileSync(runPath, JSON.stringify({ ts: new Date().toISOString(), apply: APPLY, pilot: PILOT, only: ONLY, counts: { ok, skip, fail, targeted: targets.length }, results }, null, 2));
+  console.log(`wrote ${runPath}`);
+})();

← 9f1b4ec auto-save: 2026-08-04T14:04:01 (2 files) — scripts/sample-sp  ·  back to Designerwallcoverings  ·  TK-10209: canary-of-1 last-run (W7351-01 FIXED + verified fe 9db5f31 →