← 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)