← back to Sanderson Onboard

tk10873/README.md

138 lines

# TK-10873 — Zoffany Option-A Sample-Add + Price Draft (Stage S3 of EPIC TK-10874)

DRAFT / staging / local-artifact prep ONLY. **No Shopify production writes, no
`dw_unified` writes, no Kamatera writes.** All scripts here are READ-ONLY against
the local `dw_unified` mirror (host=`/tmp` socket) and write only local files in
this directory. The live-publish is a **separate, Steve-gated step** performed by
`scripts/create_sdg.mjs` (or a sellable-variant add) — NOT run here.

## Option A (the contract)

Each product = TWO variants (proven `create_sdg.mjs` pipeline):
- **sellable** — option1 `Sold Per {unit}`, price = the staged retail
- **sample** — option1 `Sample`, sku `{dw}-Sample`, price `4.25`

Retail rule = `trade / 0.65 / 0.85` (rounded to the cent). Verified on real rows:
trade 191→345.70, 208→376.47, 177→320.36. Rows with no trade got `price_retail`
from a direct retail harvest.

## Sample-add is a documented NO-OP

All 331 ACTIVE Zoffany products already carry a `$4.25` Sample variant, so the
"sample-add" half of Option A is a no-op. The one product missing a **sellable**
variant is `DWWC-502880` (mfr `313114`) — a SELLABLE-ADD candidate, not a sample-add.

## Join-key rationale

- **JOIN KEY = `mfr_sku`.** A `dw_sku` join is 0 matches (the live `shopify_products`
  mirror carries NULL `dw_sku` for ACTIVE Zoffany; the staging `dw_sku` is a
  different `DWZF-*` series). Joining on `mfr_sku` gives 330/331.
- `shopify_products` is per-product aggregated; its `price`/`retail_price`/`cost_price`
  are NULL (the mirror does not carry the live sellable price). We therefore source
  the proposed price from `zoffany_catalog.price_retail` (staging), NOT from the mirror.

## The 3 action buckets

| action | condition | proposed_sellable_price |
|---|---|---|
| `REPRICE` | has sellable variant AND `price_retail>0` in staging | = staged `price_retail` |
| `SELLABLE_ADD` | missing sellable variant AND a usable staged price | = staged `price_retail` |
| `HELD_NEEDS_PRICE` | joins but `price_retail` null/0, OR no staging join, OR the outlier w/o a usable price | none (held for a price harvest) |

Current draft: **328 REPRICE · 0 SELLABLE_ADD · 3 HELD_NEEDS_PRICE**.
- The 3 HELD: `313114`/DWWC-502880 (no staging join, also missing sellable),
  `ZFOW312941` and `ZFOW312943` (join but `price_retail` null — note: `our_price`
  309.50 exists but is NOT used as the retail source in this draft; a price harvest
  should populate `price_retail`).
- `SELLABLE_ADD` is 0 because the sole missing-sellable product (`313114`) has no
  staged price → per the contract it falls back to `HELD_NEEDS_PRICE`.

## Files

- `lib.mjs` — pure pricing/classification helpers (no DB, testable).
- `build_zoffany_optionA_draft.mjs` — READ-ONLY builder. Emits:
  - `zoffany_optionA_draft.json` (full plan)
  - `zoffany_optionA_draft.csv` (mfr_sku, dw_sku, action, live_has_sellable,
    live_has_sample, staged_trade, proposed_sellable_price, sample_price, note)
  - `zoffany_optionA_summary.json` (counts per action + totals)
  Draft rows are ordered by `mfr_sku`, so repeated runs against unchanged source
  data produce stable JSON/CSV artifacts instead of order-only diffs.
- `validate_draft.mjs` — the GATE: asserts every pricing invariant on the generated
  draft, plus unique/sorted manufacturer SKUs and existing Sample coverage; exits
  nonzero on any violation.
- `test_optionA.mjs` — zero-dependency unit tests for the pure functions.

## Regenerate + validate + test

```sh
cd ~/Projects/sanderson-onboard
node tk10873/build_zoffany_optionA_draft.mjs   # regenerate the draft artifacts
node tk10873/test_optionA.mjs                  # unit tests (exit 0 = pass)
node tk10873/validate_draft.mjs                # the gate (exit 0 = pass)
```

## Dry-run runbook + sanity (READ-ONLY, no writes)

Two DRY-RUN-ONLY tools sit between the draft and the (separate, Steve-gated) live run:

- `reprice_lib.mjs` — pure helpers (mutation-payload builder + band/sanity classifier
  + stats). No DB, no http/fetch, testable.
- `reprice_runbook.mjs` — reads the draft, filters to the **328 REPRICE**, and emits
  one plan record per product to `reprice_runbook_dryrun.json`:
  `{ dw_sku, mfr_sku, proposed_sellable_price, sample_price:4.25, would_run, restore_map_schema }`.
  `would_run` is the `productVariantsBulkUpdate` mutation **shape** (a plain JS object)
  with a `VARIANT_ID` placeholder — a dry run does **not** read live variant ids.
  `restore_map_schema` is the pre-write snapshot `{product_id, variant_id, old_price,
  new_price}` the future live tool MUST capture for a reversible rollback.
  **There is NO http/fetch/graphql-send path in this file.** Passing `--apply` prints
  `live run is gated — not implemented here` and exits 0 — the live run is a **separate
  Steve-gated tool**, not implemented here.
- `reprice_sanity.mjs` — an **independent** data-quality gate that re-derives from the
  draft JSON (does not trust the runbook). Per REPRICE row it asserts the proposed
  price is a positive number, `> sample (4.25)`, and (where `staged_trade` present)
  `>= staged_trade` (never below cost). Prices outside `$20–$2000` are WARN (listed for
  human eyes), not FAIL. Reports the distribution (min/median/max/count) + trade-backed
  vs retail-harvest-backed counts. Writes `reprice_sanity.json` + `reprice_sanity.md`.
  **Exits nonzero ONLY on a hard invariant break** (nonpositive / ≤ sample / below
  cost); WARN-only band outliers still exit 0.

Run them:

```sh
cd ~/Projects/sanderson-onboard
node tk10873/reprice_runbook.mjs        # emits reprice_runbook_dryrun.json (328 records)
node tk10873/reprice_sanity.mjs         # the gate — exit 0 = clean (WARN-only also exits 0)
node tk10873/test_reprice.mjs           # unit tests for the new pure fns (exit 0 = pass)
```

Live execution of the reprice is **Steve-gated and not implemented in these files** —
see the next section.

## Live-publish (Steve-GATED — NOT run here)

The reprice + sellable-add is a customer-facing Shopify write and stays gated. The
command that WOULD run later, per the proven pipeline:

```sh
# DRY-RUN first (default), then --apply once Steve approves:
node scripts/create_sdg.mjs --manifest=tk10873/<option-a-manifest>.json           # dry-run
node scripts/create_sdg.mjs --manifest=tk10873/<option-a-manifest>.json --apply --limit=N
```

- REPRICE rows update the existing sellable variant's price to `proposed_sellable_price`.
- The single SELLABLE_ADD candidate (once priced) gets a `Sold Per {unit}` sellable
  variant added alongside its existing sample.
- Daily variant cap 1k/day; ≥90s gap between bulk batches.
- HELD rows do NOT publish — they wait for a price harvest.

## Open CLARIFY for Steve (contrarian panel, SHIP IT + 1 fork)

`ZFOW312941` and `ZFOW312943` join staging with `price_retail` NULL but carry
`our_price` = 309.50, which is *exactly* `trade(171)/0.65/0.85`. The draft HELDs them
(conservative/reversible default) rather than silently repricing off a second column.
**Decision Steve owns:** is `our_price` an authoritative retail source we should
promote these 2 to REPRICE on, or do they wait for a `price_retail` harvest? The
draft does NOT assume — it holds and flags (see the `CLARIFY` note on those rows).

This draft NEVER touches Shopify or `dw_unified`; it only prepares the plan.