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