← back to Fentucci Naturals
scripts/build-drafts.py
129 lines
#!/usr/bin/env python3
"""Build data/shopify-drafts.jsonl from enriched dw_unified.tokiwa_catalog.
STRUCTURE DECISION: matches the existing live Fentucci Naturals family (148
DWNAT products) = ONE PRODUCT PER COLORWAY (504 drafts), option "Size" with
Sample + Per Yard variants. Quote-only posture per the RIGO precedent (Yard
$0.00 + quotes/Needs-Price tags, Sample $4.25 sku {DW_SKU}-Sample).
Title = "<Pattern> <Color> | Fentucci Naturals" (family convention; the word
"Wallpaper" is banned house-wide — "Wallcovering" only).
Every variant carries a real product WEIGHT (TK-11414 hard rule; TK-11471 wiring):
Sample 0.25 lb, Per Yard the per-product-type default (Wallcovering = 3.0 lb).
A $0.00 quote-only yard variant still SHIPS, so it still needs a weight.
Rows with settlement_status != 'clear' are EXCLUDED and listed on stderr.
FILES ONLY — no API calls, no shopify_products writes.
"""
import json, os, re, subprocess, sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
sys.path.insert(0, os.path.join(HERE, "lib"))
from weight_guard import resolve_weight_lb # noqa: E402 (TK-11471 weight gate)
OUT = os.path.join(ROOT, "data", "shopify-drafts.jsonl")
CONN = "host=/tmp dbname=dw_unified"
PRODUCT_TYPE = "Wallcovering" # drives the TK-11414 per-type default weight
def psql(sql):
return subprocess.run(["psql", CONN, "-Atc", sql], capture_output=True, text=True, check=True).stdout.strip()
rows = json.loads(psql(
"select coalesce(json_agg(row_to_json(t))::text,'[]') from (select * from tokiwa_catalog "
"where enriched_at is not null order by dw_sku) t"))
slug = lambda s: re.sub(r"-+", "-", re.sub(r"[^a-z0-9]+", "-", s.lower())).strip("-")
excluded, written = [], 0
with open(OUT, "w") as f:
for r in rows:
if r["settlement_status"] != "clear":
excluded.append((r["dw_sku"], r["pattern_name"], r["color_name"], r["settlement_status"]))
continue
title = f"{r['pattern_name']} {r['color_name']} | Fentucci Naturals"
body = (
f"<p>{r['description']}</p>"
f"<table>"
f"<tr><td><strong>Pattern</strong></td><td>{r['pattern_name']}</td></tr>"
f"<tr><td><strong>Color</strong></td><td>{r['color_name']}</td></tr>"
f"<tr><td><strong>Collection</strong></td><td>Fentucci Naturals</td></tr>"
f"<tr><td><strong>Material</strong></td><td>{r['material']}</td></tr>"
f"<tr><td><strong>Type</strong></td><td>Sidewall</td></tr>"
f"<tr><td><strong>Pricing</strong></td><td>By quote — order a memo sample to see the material in hand</td></tr>"
f"</table>")
# TK-11414/TK-11471 WEIGHT GATE (create side). Shopify REST takes weight +
# weight_unit on the variant object. A missing/zero weight collapses the order
# into the lowest weight tier / free-shipping band and mis-costs DW freight, so
# EVERY variant carries one — the $0.00 quote-only Per Yard variant included,
# because a quote-only item still ships.
#
# TK-11664 durability fix (item 6): quote-only rows NO LONGER MINT the $0.00
# "Per Yard" variant. The 462 live $0 Per Yard placeholders (TK-11635) exist
# because this file minted them unconditionally; a re-scrape adding new
# colorways would mint fresh ones. The line's intended live shape is
# SAMPLE-ONLY ($4.25 Sample = sole orderable item, CMO-Paris precedent, see
# go-live.py step 3a + data/sample-qty-carveout.json), so build it directly:
# go-live.py's 3a delete then becomes a no-op instead of the thing standing
# between a $0 variant and the live store. Rows whose tags do NOT carry
# `quotes` keep the old two-variant shape unchanged.
if "quotes" in (r["tags"] or []):
variants = [
{"sku": f"{r['dw_sku']}-Sample", "price": "4.25", "option1": "Sample",
"taxable": True, "requires_shipping": True, "inventory_management": None},
]
else:
variants = [
{"sku": f"{r['dw_sku']}-Sample", "price": "4.25", "option1": "Sample",
"taxable": True, "requires_shipping": True, "inventory_management": None},
# inventory_policy deny: hard stop on the $0.00 quote-only yard
# variant ever being purchasable if a tag edit strips `quotes`
{"sku": r["dw_sku"], "price": "0.00", "option1": "Per Yard",
"taxable": True, "requires_shipping": True, "inventory_management": None,
"inventory_policy": "deny"},
]
for v in variants:
v["weight"] = resolve_weight_lb(v, {"product_type": PRODUCT_TYPE})
v["weight_unit"] = "lb"
rec = {
"mfr_sku": r["mfr_sku"],
"sku": r["dw_sku"],
"local_image_path": os.path.join(ROOT, r["image_local"]),
"product": {
"title": title,
"handle": f"{slug(r['pattern_name'])}-{slug(r['color_name'])}-fentucci-naturals",
"vendor": "Fentucci Naturals",
"product_type": PRODUCT_TYPE,
"status": "draft",
"tags": ", ".join(r["tags"]),
"body_html": body,
"options": [{"name": "Size"}],
"variants": variants,
},
"metafields": [
{"namespace": "custom", "key": "manufacturer_sku", "type": "single_line_text_field", "value": r["mfr_sku"]},
{"namespace": "global", "key": "Brand", "type": "single_line_text_field", "value": "Fentucci Naturals"},
{"namespace": "custom", "key": "material", "type": "multi_line_text_field", "value": r["material"]},
{"namespace": "custom", "key": "color_family", "type": "single_line_text_field", "value": r["specs"]["color_family"]},
# explicit metafield gates, independent of tags: the rotation
# activator exempts `quotes`-TAGGED products from the price>0
# gate, so a bulk tag edit could otherwise flip $0.00 Per Yard
# variants live ($4.25-on-GMC risk). These survive tag edits.
{"namespace": "custom", "key": "price_mode", "type": "single_line_text_field", "value": "quote_only"},
{"namespace": "custom", "key": "needs_width", "type": "boolean", "value": "true"},
],
}
# 5-field + image gate: refuse to write an incomplete draft
assert r["description"] and len(r["tags"]) >= 2 and os.path.exists(rec["local_image_path"]), r["dw_sku"]
# weight gate: refuse to write a payload that would create a zero-weight variant
assert all(float(v["weight"]) > 0 and v["weight_unit"] == "lb"
for v in rec["product"]["variants"]), f"zero-weight variant: {r['dw_sku']}"
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
written += 1
print(f"wrote {written} draft payloads -> {OUT}")
if excluded:
print(f"EXCLUDED {len(excluded)} (settlement review):", file=sys.stderr)
for e in excluded:
print(" " + " | ".join(map(str, e)), file=sys.stderr)