[object Object]

← back to Mdc Onboard

MDC→Phillipe Romano Type II draft onboard: verified importer (quote-only, private-label metafields, PG-first write-back)

f63756a5e5a8d873b6c6b7d5ffc9294d78f2a317 · 2026-07-26 08:49:27 -0700 · Steve Abrams

Files touched

Diff

commit f63756a5e5a8d873b6c6b7d5ffc9294d78f2a317
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Jul 26 08:49:27 2026 -0700

    MDC→Phillipe Romano Type II draft onboard: verified importer (quote-only, private-label metafields, PG-first write-back)
---
 .gitignore       |   6 ++
 README.md        |  60 ++++++++++++++++
 import-drafts.py | 208 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 274 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..8bbd5c8
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+out/*.jsonl
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..21420e1
--- /dev/null
+++ b/README.md
@@ -0,0 +1,60 @@
+# mdc-onboard — MDC → Phillipe Romano (Type II) DRAFT onboard
+
+Private-label onboard of the MDC WALLCOVERINGS Type II contract-vinyl line into the
+LIVE DW Shopify store (`designer-laboratory-sandbox.myshopify.com`) as **DRAFTS**,
+branded **Phillipe Romano** (world-capital collections). Source of truth =
+`dw_unified.mdc_catalog` (4,728 deduped-by-mfr_sku rows). Modeled on
+`vahallan-onboard` (quote-only) + `knoll-onboard` (private-label metafields) +
+the CMO Paris onboard→activate pattern.
+
+## HARD RULE — private label
+`MDC` / `MDC Interior Solutions` / `mdcwall.com` and the real book & designer names
+(Bolta, Vycon, Genon, Lentex, Nvolve, Candice Olson, Thom Filicia, Jonathan Mark,
+Taniya Nayak) are **NEVER customer-facing**. The only real datum pushed to Shopify is
+the opaque mfr SKU code, in hidden `custom.manufacturer_sku` + `dwc.manufacturer_sku`.
+Full real-identity reconciliation lives in `dw_unified.mdc_catalog`.
+
+## What `import-drafts.py` creates (per product)
+- **status = draft** (activation is a separate gated step — see below)
+- title = `mdc_catalog.dw_title` (title-cased `"… | Phillipe Romano"`; re-guarded: no `Wallpaper`/`Unknown`)
+- vendor `Phillipe Romano` · type `Wallcovering`
+- **Sample variant** `{DW_SKU}-Sample` @ **$4.25**, `inventory_management=null` (no inventory)
+- **quote-only** → tags `quotes` + `contact-for-price` (no sellable/priced variant)
+- metafields: `custom.manufacturer_sku`, `dwc.manufacturer_sku` (= mfr_sku), `global.Brand`,
+  `global.width` (when known), `global.unit_of_measure`, `custom.color`/`real_color_name`
+- ≥1 image (`image_url`); body_html = spec table built from the catalog spec columns
+
+**BUDGET_CAT=backlog** — standalone importer; does NOT touch the shared upload/variant
+budget (`budget.cjs`) and does NOT auto-activate. Drafts land as backlog for later
+drip-activation under the activation-lane cap.
+
+**PostgreSQL-first write-back** — `shopify_product_id` + `on_shopify=true` are written
+back to `mdc_catalog` the instant each product is created → fully resumable, never
+double-creates.
+
+## Run
+```sh
+python3 import-drafts.py                 # DRY-RUN (prints 3 payloads)
+python3 import-drafts.py --limit 3 --commit   # test batch
+python3 import-drafts.py --commit             # full remaining (~43 min, $0 local)
+node ../vahallan-onboard/gate-check.js <PID>  # prove a draft would pass activation
+```
+Cost: **$0 (local)** — unmetered Shopify Admin API + local PG parse. No browser/AI.
+
+## Verified (test batch, 2026-07-26)
+3 drafts created; PG write-back confirmed; created object has correct draft status,
+`{DW}-Sample`@4.25 null-inventory, 1 image, custom+dwc manufacturer_sku + global.width
+metafields; `gate-check.js` → **canonical PASS + fiveFieldExtra PASS → WOULD ACTIVATE: true**.
+
+## Known flag for the activation gate (48 rows)
+The `Dublin` collection (MDC book `vital-vycon`) has mfr pattern name **"Vital"**, so those
+48 titles read `"Dublin Vital … | Phillipe Romano"`. "Vital" is a generic English adjective
+(the distinctive mark "Vycon" never appears) so they were **not** blocked — but a human should
+confirm "Vital" is acceptable customer-facing **before these 48 go ACTIVE**. See
+`out/flagged-dublin-vital.jsonl`.
+
+## To reach LIVE (Steve-gated — NOT done here)
+Drafts only. Activation DRAFT→ACTIVE runs a CMO-Paris-style `mdc-activate.js`: real
+settlement gate + 5-field gate + publish to all channels **ex-Google** (quote-only lines
+stay off Google Shopping). The `mdc-wallcoverings` Shopify collection is populated at that
+activation step, not here.
diff --git a/import-drafts.py b/import-drafts.py
new file mode 100644
index 0000000..2972c29
--- /dev/null
+++ b/import-drafts.py
@@ -0,0 +1,208 @@
+#!/usr/bin/env python3
+"""
+MDC → Phillipe Romano (Type II contract vinyl) DRAFT onboard.
+
+Creates Shopify DRAFT products for dw_unified.mdc_catalog rows not yet on Shopify,
+following the proven vahallan-onboard (quote-only) + knoll-onboard (private-label)
+pattern. PRIVATE LABEL — "MDC" / mdcwall.com / the real book & designer names are
+NEVER customer-facing; the only real datum pushed to Shopify is the opaque mfr SKU
+code, stored in hidden custom+dwc manufacturer_sku metafields (per task spec).
+
+Per product (task contract):
+  * title       = dw_catalog.dw_title  (already title-cased "... | Phillipe Romano",
+                  guaranteed to contain no "Wallpaper"/"Unknown" — re-guarded here)
+  * status      = draft   (activation DRAFT->ACTIVE is a SEPARATE Steve-gated step
+                  through settlement + the 5-field gate + publish-ex-Google)
+  * Sample var  = {DW_SKU}-Sample @ $4.25, inventory_management=null (no inventory)
+  * quote-only  => tags 'quotes' + 'contact-for-price'  (no sellable/priced variant)
+  * metafields  = custom.manufacturer_sku, dwc.manufacturer_sku (both = mfr_sku),
+                  global.Brand=Phillipe Romano, global.width (when known),
+                  global.unit_of_measure, custom.color/real_color_name
+  * >=1 image   = image_url  (every row has one; re-guarded)
+
+BUDGET_CAT=backlog: this is a STANDALONE importer. It does NOT touch the shared
+upload/variant-create budget (budget.cjs) and does NOT auto-activate — the drafts
+land as backlog, to be drip-activated later under the activation-lane cap. Nothing
+here consumes the "upload" pool.
+
+PostgreSQL-first write-back: the moment a product is created, its shopify_product_id
++ on_shopify=true is written back to mdc_catalog (the local dw_unified mirror,
+host=/tmp) BEFORE moving on — so the run is fully resumable and never double-creates.
+
+Cost: $0 (local) — plain urllib POSTs to the Shopify Admin API (unmetered) + local
+PG. No browser, no Gemini, no Claude API.
+
+Usage:
+  python3 import-drafts.py                 # DRY-RUN: print first 3 payloads, create nothing
+  python3 import-drafts.py --limit 3 --commit   # create 3 real drafts (test batch)
+  python3 import-drafts.py --commit             # create ALL remaining (~45 min)
+"""
+import json, subprocess, sys, time, urllib.request, urllib.error, re, os
+
+SHOP  = 'designer-laboratory-sandbox.myshopify.com'   # LIVE DW store (legacy misnomer)
+API   = f'https://{SHOP}/admin/api/2024-10'
+TOKEN = next(l.split('=', 1)[1].strip()
+             for l in open('/Users/macstudio3/Projects/secrets-manager/.env')
+             if l.startswith('SHOPIFY_ADMIN_TOKEN='))
+OUT   = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'out')
+os.makedirs(OUT, exist_ok=True)
+
+COMMIT = '--commit' in sys.argv
+LIMIT  = int(sys.argv[sys.argv.index('--limit') + 1]) if '--limit' in sys.argv else 10**9
+
+# Distinctive private-label brand coinages + designer names that must NEVER be
+# customer-facing. Generic English words that happen to be book slugs (e.g. "vital",
+# "encore", "restoration") are deliberately NOT here — they are not distinctive marks.
+HARD_LEAK = re.compile(
+    r'\b(mdc|mdcwall|vycon|genon|bolta|lentex|nvolve|candice\s+olson|'
+    r'thom\s+filicia|jonathan\s+mark|taniya\s+nayak)\b', re.I)
+TITLE_BAN = re.compile(r'\b(wallpaper|unknown)\b', re.I)
+
+
+def psql(sql):
+    r = subprocess.run(['psql', '-h', '/tmp', '-d', 'dw_unified', '-v', 'ON_ERROR_STOP=1',
+                        '-tA', '-c', sql], capture_output=True, text=True)
+    if r.returncode:
+        sys.exit('psql error: ' + r.stderr[:400])
+    return r.stdout
+
+
+def api(method, path, body):
+    req = urllib.request.Request(API + path, method=method,
+          data=json.dumps(body).encode(),
+          headers={'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json'})
+    for _ in range(5):
+        try:
+            return json.loads(urllib.request.urlopen(req, timeout=45).read())
+        except urllib.error.HTTPError as e:
+            if e.code == 429:
+                time.sleep(float(e.headers.get('Retry-After', '3')) + 1); continue
+            raise
+    raise RuntimeError('429 retries exhausted')
+
+
+def spec_row(label, val):
+    return f'<tr><td><strong>{label}</strong></td><td>{val}</td></tr>' if val and str(val).strip() else ''
+
+
+def build_body(r):
+    coll = r['private_label_collection'] or ''
+    pat  = r['pattern_name'] or r['dw_sku']
+    intro = (f'<p>{pat} from the Phillipe Romano {coll} collection &mdash; a '
+             f'high-performance Type II commercial wallcovering. Priced per single roll; '
+             f'request a memo sample to see the material in hand, or contact us for a quote.</p>')
+    table = '<table>' + ''.join([
+        spec_row('Pattern', pat),
+        spec_row('Color', r['color_name']),
+        spec_row('Material', r['material']),
+        spec_row('Width', r['width']),
+        spec_row('Backing', r['backing']),
+        spec_row('Weight', r['weight']),
+        spec_row('Fire Rating', r['fire_rating']),
+        spec_row('Pattern Match', r['pattern_match']),
+        spec_row('Certifications', r['certifications']),
+        spec_row('Manufacturer SKU', r['mfr_sku']),
+    ]) + '</table>'
+    return intro + table
+
+
+def build_payload(r):
+    title = (r['dw_title'] or '').strip()
+    coll  = r['private_label_collection'] or ''
+    pat   = r['pattern_name'] or ''
+    color = r['color_name'] or ''
+    dw    = r['dw_sku']
+    tags = sorted({t for t in [
+        'Phillipe Romano', 'Wallcovering', 'Commercial', 'Type II', coll,
+        pat, 'quotes', 'contact-for-price', 'display_variant',
+    ] if t and t.strip()})
+    mfs = [
+        {'namespace': 'custom', 'key': 'manufacturer_sku', 'type': 'single_line_text_field', 'value': r['mfr_sku']},
+        {'namespace': 'dwc',    'key': 'manufacturer_sku', 'type': 'single_line_text_field', 'value': r['mfr_sku']},
+        {'namespace': 'global', 'key': 'Brand',            'type': 'single_line_text_field', 'value': 'Phillipe Romano'},
+        {'namespace': 'global', 'key': 'unit_of_measure',  'type': 'single_line_text_field', 'value': 'Priced Per Single Roll'},
+    ]
+    if color:
+        mfs.append({'namespace': 'custom', 'key': 'color',           'type': 'single_line_text_field', 'value': color})
+        mfs.append({'namespace': 'custom', 'key': 'real_color_name', 'type': 'single_line_text_field', 'value': color})
+    if r['width'] and r['width'].strip():
+        mfs.append({'namespace': 'global', 'key': 'width', 'type': 'single_line_text_field', 'value': r['width'].strip()})
+    return {'product': {
+        'title': title,
+        'body_html': build_body(r),
+        'vendor': 'Phillipe Romano',
+        'product_type': 'Wallcovering',
+        'status': 'draft',
+        'tags': ', '.join(tags),
+        'options': [{'name': 'Size', 'values': ['Sample']}],
+        'variants': [{'option1': 'Sample', 'price': '4.25', 'sku': f'{dw}-Sample',
+                      'inventory_management': None, 'requires_shipping': True,
+                      'taxable': True, 'grams': 0}],
+        'images': [{'src': r['image_url'], 'alt': title}],
+        'metafields': mfs,
+    }}
+
+
+COLS = ['id', 'mfr_sku', 'dw_sku', 'dw_title', 'pattern_name', 'color_name',
+        'private_label_collection', 'width', 'backing', 'weight', 'material',
+        'fire_rating', 'pattern_match', 'certifications', 'image_url']
+
+
+def fetch_rows():
+    sel = ('SELECT coalesce(json_agg(json_build_object(' +
+           ','.join(f"'{c}',{c}" for c in COLS) +
+           ") ORDER BY dw_sku),'[]') FROM mdc_catalog "
+           'WHERE NOT on_shopify AND shopify_product_id IS NULL')
+    return json.loads(psql(sel))
+
+
+def main():
+    rows = fetch_rows()
+    print(f'candidates (not yet on Shopify): {len(rows)}  |  mode={"COMMIT" if COMMIT else "DRY-RUN"}  |  limit={LIMIT if LIMIT < 10**9 else "all"}', flush=True)
+    created = errs = skipped = 0
+    leak_log = open(os.path.join(OUT, 'leak-skipped.jsonl'), 'a')
+    created_log = open(os.path.join(OUT, 'created.jsonl'), 'a')
+    todo = rows[:LIMIT]
+    for r in todo:
+        title = (r['dw_title'] or '').strip()
+        # ---- hard guards (task: never Wallpaper/Unknown; >=1 image; no coinage leak)
+        composite = f"{title} {r['pattern_name']} {r['color_name']} {r['material']}"
+        reason = None
+        if not title:                       reason = 'empty-title'
+        elif TITLE_BAN.search(title):       reason = 'title-has-wallpaper-or-unknown'
+        elif not (r['image_url'] or '').strip(): reason = 'no-image'
+        elif HARD_LEAK.search(composite):   reason = 'private-label-coinage-leak'
+        if reason:
+            skipped += 1
+            leak_log.write(json.dumps({'dw_sku': r['dw_sku'], 'reason': reason, 'title': title}) + '\n')
+            print(f'  SKIP {r["dw_sku"]}: {reason}', flush=True)
+            continue
+
+        payload = build_payload(r)
+        if not COMMIT:
+            if created < 3:
+                print(json.dumps(payload, indent=2)[:1400])
+            created += 1
+            continue
+        try:
+            resp = api('POST', '/products.json', payload)
+            pid = resp['product']['id']
+            # PostgreSQL-FIRST write-back (before anything else) — resumability contract
+            psql(f"UPDATE mdc_catalog SET shopify_product_id='{pid}', on_shopify=true "
+                 f"WHERE id={r['id']}")
+            created_log.write(json.dumps({'dw_sku': r['dw_sku'], 'pid': pid, 'title': title}) + '\n')
+            created += 1
+            if created % 25 == 0:
+                print(f'  {created}/{len(todo)} created (err {errs}, skip {skipped})', flush=True)
+            time.sleep(0.55)   # ~1.8/s, under Shopify REST 2/s
+        except Exception as e:
+            errs += 1
+            detail = e.read()[:200] if isinstance(e, urllib.error.HTTPError) else str(e)
+            print(f'  ERR {r["dw_sku"]}: {detail}', flush=True)
+            time.sleep(2)
+    leak_log.close(); created_log.close()
+    print(f'DONE mode={"COMMIT" if COMMIT else "DRY-RUN"} created={created} errors={errs} skipped={skipped}', flush=True)
+
+
+if __name__ == '__main__':
+    main()

(oldest)  ·  back to Mdc Onboard  ·  MDC onboard COMPLETE: 4728 Phillipe Romano Type II drafts, 0 e1a7c1e →