← 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
A .gitignoreA README.mdA import-drafts.py
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 — 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 →