← back to Dw Rotation Activator
lib/rotation-order.js
96 lines
'use strict';
/*
* rotation-order.js — THE canonical ordering for DW draft activation.
*
* ONE source of truth for the "textures-first, then vendor round-robin (~one per
* vendor per increment)" order across ALL DRAFT products (not just the ~5k
* field-fix worklist). Both consumers import ROTATION_ORDER_SQL from here so the
* unified effective order is identical:
* - dw-rotation-activator/rotate-activate.js (the executor that flips DRAFT→ACTIVE)
* - dw-activation-calendar/scripts/generate-schedule.js (the projection/UI)
*
* Proven design (read-only projection validated by the main session):
* mat_tier = 0 (textures/naturals) leads, then mat_tier = 1 (rest).
* Within each tier, breadth-first vendor round-robin:
* rr = (position of this SKU within its (tier,vendor) group) * 100000
* + (dense rank of the vendor within the tier)
* Ordering by (mat_tier, rr) therefore takes ONE sku from every vendor before a
* 2nd from any — so each activation increment spreads across vendors (never a
* single-vendor contiguous block).
*
* WIDENED texture detection (vs the original material-metafield-only heuristic,
* which under-counted because the material metafield is blank on many drafts):
* we build a match string from title + product_type + tags + BOTH material
* metafields, and match a broad naturals/texture lexicon. This lands more true
* grasscloth / silk / naturals / weaves / metallics in tier 0.
*
* NOISE FILTER: instructional/help-doc rows (e.g. a "How to Hang Grasscloth" help
* page that falsely matched the grasscloth keyword) are EXCLUDED entirely so they
* never activate. The filter is deliberately NARROW — it matches genuine help-doc
* title patterns only, NOT "memo"/"sample" (which are legit product-name tokens on
* Phillipe Romano etc.).
*
* Schumacher is excluded (internal-only, HARD RULE — never on the storefront).
*/
// Broad naturals/texture lexicon → tier 0 (textures-first).
const TEXTURE_REGEX =
"silk|grasscloth|sisal|raffia|abaca|jute|linen|hemp|cork|paperweave|paper weave|" +
"grass|natural fiber|natural fibre|seagrass|arrowroot|wool|mohair|leather|suede|" +
"bamboo|rattan|cane|weave|woven|textile|\\mfiber\\M|\\mfibre\\M|reed|coir|hessian|" +
"burlap|felt|velvet|flock|metallic|\\mmica\\M|gilded|gold leaf|silver leaf|foil";
// NARROW instructional/help-doc noise filter. Matches real help-doc titles only.
// Deliberately does NOT include "memo"/"sample book" (legit product-name tokens).
const NOISE_REGEX =
"how to|how-to|instructional|installation guide|hanging guide|care guide|" +
"user guide|tutorial";
/*
* The canonical ordered-DRAFT query. Emits every DRAFT (minus Schumacher + noise)
* in the exact textures-first + vendor-round-robin order, with the columns both
* consumers need. shopify_id is the durable per-item identity + image/title join.
*
* NOTE: `tags` is a text column (comma-joined), `metafields` is jsonb. coalesce
* guards every field so a null never breaks the match string.
*/
const ROTATION_ORDER_SQL = `
WITH pool AS (
SELECT
shopify_id,
vendor,
coalesce(sku, dw_sku) AS dw_sku,
dw_sku AS raw_dw_sku,
title,
product_type,
lower(
coalesce(metafields->'specs'->>'material','') || ' ' ||
coalesce(metafields->'custom'->>'material','') || ' ' ||
coalesce(title,'') || ' ' ||
coalesce(tags,'') || ' ' ||
coalesce(product_type,'')
) AS matstr
FROM shopify_products
WHERE status = 'DRAFT'
AND vendor NOT ILIKE '%schumacher%' -- internal-only, never storefront
AND lower(coalesce(title,'')) !~ '${NOISE_REGEX}' -- drop help-doc noise rows
),
tiered AS (
SELECT *,
CASE WHEN matstr ~ '${TEXTURE_REGEX}' THEN 0 ELSE 1 END AS mat_tier
FROM pool
),
ranked AS (
SELECT *,
(row_number() OVER (PARTITION BY mat_tier, vendor
ORDER BY coalesce(dw_sku, raw_dw_sku, shopify_id))) * 100000
+ dense_rank() OVER (PARTITION BY mat_tier ORDER BY vendor) AS rr
FROM tiered
)
SELECT shopify_id, vendor, dw_sku, title, product_type, mat_tier, rr
FROM ranked
ORDER BY mat_tier, rr
`;
module.exports = { ROTATION_ORDER_SQL, TEXTURE_REGEX, NOISE_REGEX };