SKU.io ↔ Rouge
Meeting brief · confidential

Otis / Rouge — Bundle architecture & demand forecasting

Tue Jul 21, 9:00–10:00 AM PT · Zoom · prepared for Kalvin · grounded in the SKU.io codebase + industry best-practice research
13 questions answered Code-verified Competitor-benchmarked Internal — unlisted link, don't circulate

How to play it

✅ Lead with (real strengths)

  • Master-SKU aggregation — two Shopify IDs → one clean demand record, by explicit mapping not fuzzy match.
  • API calculation trace — we hand back the whole forecast math, not just a number. Genuinely uncommon.
  • In-app explainability — CFO-defensible calc drawer per recommendation.
  • Per-market demand segmentation already works (by shipping destination).

⚠️ Be candid about (real gaps)

  • Seasonality — no auto-detection; manual multipliers that dilute sharp spikes. Weakest area.
  • Promo isolation — no campaign tagging; manual date-window only.
  • Returns — demand is gross; no per-SKU return netting yet.
  • Confidence — thin-data SKUs get a number silently, no flag.

🚀 Offer as co-designed roadmap

  • Auto weekly, YoY seasonal index (suggests the multiplier).
  • One-click "exclude this campaign" + auto spike-flag.
  • Return-rate netting — we already compute the rate, just wire it in.
  • Per-destination lead time (building blocks exist in MRP).
  • "Bundle-component-only" phantom flag + effective-dated BOM.

Overall framing for the room: most of what Rouge asked is either already solid or a known, near-term roadmap item where we own the underlying data — very little is a research problem. Their forecasting pain (spike dilution, promo pollution) is real and is our best design input. Rouge is not blocked for this year's BF (Vivien's plan is the fallback), so this is a "which engine to lean on" conversation, not a rescue.

At a glance

Q
One-line answer
Verdict
Part 1 · Ghost bundles & phantom SKUs
1
Two Shopify IDs, one SKU → aggregates into one demand record. We key demand on your internal SKU, not the Shopify product ID.
Yes ✓
2·3·5
Phantom $0 line → its units count (not $), but they land on the door-mount SKU, not the panel. Panel's safe; door-mount history is skewed.
Nuanced
4
Migration timingimport history FIRST, delete ghost bundles only after. We match by Shopify variant ID, not SKU.
Action
Part 2 · Forecasting methodology
1
Seasonality → trailing 30-day average + manual multipliers. No auto-detection; design dilutes spikes. Our weakest area.
Gap
2
Promo-aware → no campaign feature. Keep a promo out via a clean date window. Same as Inventory Planner / Cogsy.
Gap
3
Per-market seasonality → per-market demand works (filter by shipping country); seasonality is manual, config-per-market.
Partial
4·5
Explainability / confidence → full calc drawer in-app (CFO-ready). Export drops the multiplier; no confidence score; thin data → silent number.
Partial
6
Per-market lead time → not in the forecast (one per product/supplier). Workaround: a supplier per region. Roadmap: building blocks exist in MRP.
Partial
7
Return-rate netting → not netted (gross units). We do compute return rate per SKU — just not wired in. Peers don't net either.
Partial
Part 3 · API
8
APIYes — full per-line calculation trace + PO + vendor lead-time APIs. Uncommon; lean into it. No single "confidence" field yet.
Yes ✓
Your own question
Subscriptions & point-in-time SKU association → your instinct is right: editions drifted; composition isn't time-versioned. Fix = effective-dated BOM, not subscriptions.
Insight

Part 1 · Ghost bundles & phantom SKUs

1

Two Shopify product IDs sharing one SKU — clean aggregation?

Yes ✓
"When we import 2 years of history, will orders aggregate into one demand record, or does SKU.io get confused because two Shopify product IDs map to one SKU?"

"Yes — they aggregate into one demand record. We key all sales and demand history off your internal SKU, not the Shopify product ID. Each Shopify listing is just a mapping that points at the same internal product, so both the bundle listing and the panel listing resolve to one SKU and their two years of orders sum into a single demand signal. That's the same master-SKU model Inventory Planner, Linnworks, Cin7 and NetSuite use — and because our link is an explicit mapping set at import rather than a fuzzy SKU-string match, it won't silently split them the way heuristic matching can."

Under the hood + best practice

How it works

Order line → Shopify variant_idProductListing → internal product_id. The forecast aggregates strictly by internal product_id (GROUP BY product_id); the channel listing plays no role in grouping. Two Shopify products = two ProductListing rows both pointing at the same internal Product.

The one precondition to verify

Both Shopify listings must actually be mapped to the same internal Product in this tenant. An unmapped listing's lines get product_id = null and simply drop out of the forecast (they don't split or double-count) — worth confirming before go-live.

vs industry — on par (a touch stronger)

Inventory Planner uses a "Master SKU" (auto-match by SKU/barcode/ASIN, else manual); Linnworks/Cin7/NetSuite all forecast off the internal item. Ours is a deterministic FK set at import, so it doesn't depend on the two listings sharing an identical SKU string — removing competitors' #1 failure mode (fuzzy-match misses).

2

Phantom $0 line — does its unit count become extra demand?

Nuanced — you're half right
"Ghost SKU lines are $0. Does the phantom line's unit count get treated as a separate demand signal (on top of the base panel), or only its revenue? Does the mix of old orders (with phantom) and new (without) need a config change?"

"You're right on the part that matters, and I'll be straight about the part where we have a gap. Our forecast runs purely on units, never dollars — so the $0 price doesn't do anything magic, and critically that phantom door-mount line does not add demand to your panel SKU, because we attribute it to the door-mount's own SKU, not the panel. Where it is a problem is the door-mount SKU itself: old orders carry that line and new ones don't, so that SKU's own history is inflated for the legacy period. Today we don't have a 'this part is only ever sold inside a bundle — don't forecast it standalone' flag, which an ERP would call a phantom component. The clean fix now is to stop reordering the door-mount independently and treat it as driven off the panel — and giving you that flag natively is fair to put on our roadmap."

Under the hood + best practice
Demand basis: units onlyPanel impact: none ✓Door-mount SKU: inflated by legacy eraConfig toggle: none

How it works

Forecast demand = SUM(quantity) → daily average. Revenue is a display-only column the reorder math never reads — so a $0 line contributes its full unit count. It's the inverse of "only revenue counts": the units count, the revenue evaporates. Units attribute to that line's own product_id (the door-mount), never the panel.

The real gap (own it)

Every sales-order-line SKU becomes independent demand. There's no phantom/"bundle-component-only" flag forecasting honors, and filters are order-level (can't strip one $0 line). So no setting reconciles the mixed old/new eras — the rows genuinely differ.

Watch-out to check

If the panel is modeled as a kit whose recipe already lists the door-mount AND a standalone $0 door-mount line also exists on old orders, the door-mount can be double-counted on those orders. Worth a quick data check.

Workaround now

Don't set reorder points on the door-mount (exclude from replenishment) so its polluted history never drives a PO; or trim its forecast window to the post-transition period.

vs industry

Units-not-dollars and component-level attribution = standard (SAP, NetSuite, Inventory Planner). The missing piece — an item-level phantom flag (Oracle/Infor "MRP sees through it") and history-cleansing for structural breaks — is where we're behind.

4

Migration timing — before or after the import?

Action: import first
"Best timing for removing the ghost bundle SKUs from Shopify and promoting the panels — before or after the historical import and go-live? What could break?"

"Here's the safe order of operations: promote your panels whenever you like — the SKU is unchanged, so they auto-link to the same internal product. But leave the old ghost-bundle listings live on Shopify until we've run and verified the full two-year import, because our order matching keys on the Shopify variant ID, so those old variants need to be present at import time for your history to back-link and bundle-expand correctly. Once that's confirmed, deleting them is completely safe — the line-to-product link is already locked in. The one thing that would permanently orphan your history is deleting the bundles first — because today we don't fall back to matching by SKU to recover it. That SKU-based re-match is on our roadmap."

Under the hood + best practice
Sequence: ① promote panels (SKU-safe) → ② run & verify 2-yr import while ghost bundles still live on Shopify → ③ only then delete the ghost bundles. Add a Shopify catalog freeze during import.

Why

Order lines match to products by shopify_variant_id, with no SKU fallback. At import, each line snapshots its resolved product_id and survives later listing deletion. But if you delete a ghost-bundle listing before importing, its historical lines have no catalog variant to join to → permanently unmapped (no product, no COGS, no inventory movement, no bundle expansion). "SKU stays the same" can't rescue it, because SKU isn't the join key.

vs industry

Generic ERP advice ("restructure master data before import") assumes the target re-matches by SKU — Linnworks/Cin7 do. We don't (yet), which inverts the advice for this specific migration. Roadmap: a SKU-based re-link job + a delete-guard warning on non-FBA listings with order history.

Part 2 · Forecasting methodology

1

Seasonality — same-year event-vs-baseline, or trailing average?

Gap — manual only
"Does SKU.io use a same-year pre-event-vs-event comparison, or a trailing moving average? If trailing, how does it avoid diluting a sharp spike? We saw 30× single-day and 7–9× monthly multipliers."

"I'll be straight: this is the area where we're behind, and your instinct is exactly right. Today we use a trailing simple average (default 30 days) as the baseline — not a same-year event-vs-baseline comparison and not year-over-year — and we don't auto-detect seasonality at all. A trailing average dilutes a sharp spike: a 30× day inside a 30-day window barely moves the average. The only way to express your 7–9× panel behaviour today is a manual multiplier you type in, and our current design can even average a one-month spike down when the planning horizon spans quieter months. That's the exact gap we're closing next: auto-deriving a week-level, year-over-year seasonal index that suggests the multiplier and keeps it concentrated on the spike dates instead of smearing it. Your 30×/7–9× data is the perfect design input."

Under the hood + best practice
Baseline: flat trailing SMA (30d default)Auto-seasonality: noneSpike handling: diluted ×2

Two dilution mechanisms to be aware of

  • The trailing average spreads any spike across the whole lookback window.
  • Monthly "Seasonal" modifiers are day-weighted-averaged across the horizon, and the "Event" modifier explicitly smears event-day demand across the window — actively flattening spikes.

The "Seasonal" modifier is a manual, uncapped % (a user could type +2900% for 30×) — the system applies it, it just won't derive it.

Best practice

Multiplicative seasonal index via ratio-to-moving-average, computed at the resolution the spike lives at (weekly / day-of-week, not monthly) and year-over-year across ≥2 years — with recurring seasonality separated from one-off promo/event lift (baseline + event overlay). Netstock, Inventory Planner (Sage), GAINS, Holt-Winters all auto-derive it. We hand-enter it.

2

Promo-aware modeling — automatic or manual tagging?

Gap — no promo feature
"Is promo-aware modeling — tagging campaigns and excluding promo data — automatic or manual? Has any promo-tagging been set up for us?"

"Honest answer: today promo handling is manual, not automatic — there's no campaign-tag and no auto spike-detection, and nothing has been set up for Rouge because the feature doesn't exist yet. To keep a promo like July 4th out of a clean baseline, you point the forecast at a clean date window that avoids it — or filter those orders out. That's the same manual approach Inventory Planner and Cogsy take; only the enterprise suites like ToolsGroup and Blue Ridge auto-separate baseline demand from promo lift. That exact gap — automatic spike-flagging plus a one-click 'exclude this campaign' control so a July-4th window can't silently pollute your baseline — is what we want to build next, and your case is the perfect design input."

Under the hood + best practice

How it works

The baseline is one contiguous date window; no promo/campaign concept, no outlier/anomaly detection anywhere in forecasting. A promo spike inside the window inflates the mean proportionally — exactly your July-4th problem.

The obscure escape hatch (demo-able today)

The advanced sales filter has a generic order tag filter and an order_date filter with is_not / OR-groups — so you can exclude tagged orders or carve out promo dates by hand. It's manual and uses generic order primitives, not a forecast-native campaign concept.

Best practice

Ladder: (a) auto outlier detection/smoothing with review → (b) manual campaign tagging → (c) full baseline-vs-promo-lift decomposition. ToolsGroup/Blue Ridge do (c) automatically; Netstock does (a); Inventory Planner/Cogsy/NetSuite are manual like us. Near-term win for us: named promo-window exclusion + auto spike-flag (would've caught your July-4th window automatically).

3

Per-market seasonality within one Shopify store?

Partial — demand yes, seasonality manual
"Does the model support per-market seasonality within a single Shopify store (CA/US/AU/UK/EU)? Or is 'channel' platform-level (Shopify vs Amazon)?"

"Yes — you can forecast and set seasonality per market inside your single Shopify store today, because our forecasting segments on the shipping destination, not just the sales platform. Each market gets its own demand config and its own seasonal adjustment. Being straight with you: right now that's a config-per-market setup (CA, US, AU, UK, and EU as a country list), and the seasonal curve is one you set rather than one we auto-learn. 'Channel' in our sense is platform-level (Shopify vs Amazon vs wholesale); market is expressed as a shipping-country filter. Auto-detected per-market seasonality and a single dashboard that breaks demand out by market are on our roadmap — that's where RELEX and Blue Yonder sit."

Under the hood + best practice

How it works

Forecasting has no fixed "channel" dimension — it segments demand via a saved sales-order filter tree. That includes geo filters (shipping_country_code, shipping_province) alongside platform filters (integration = Shopify). So per-market = one ForecastConfiguration per market. Seasonal modifiers scope only by product, not geo — so per-market seasonality means attaching modifiers to each per-market config.

Nuance worth noting

True per-destination-market seasonality within one storefront is uncommon even among competitors — most (Netstock, Inventory Planner) segment by warehouse/location, not shipping country. Our filter model is genuinely flexible here; the gaps are auto-detection and a per-market breakdown view. Short-term: ship a "market" preset that scaffolds the per-country configs so Rouge isn't hand-building filter trees (EU = an is_one_of country list).

4·5

Can they see/export the calc? & thin-data confidence?

Partial — strong in-app
"Can we see/export the calculation behind a reorder rec — baseline, multiplier, confidence — or only the final quantity? And how are thin/no-history SKUs handled — flagged, or silently numbered?"

"For any single recommendation, Rouge can open the calculation and see the whole chain — the exact baseline window, the daily sales rate before and after any multiplier, lead time, MOQ, min-stock, and the literal formula, each with a badge showing where the number came from. That's genuinely line-item defensible to your CFO in the app today. Two honest caveats: our current spreadsheet export shows the final rate and baseline day-count but drops the multiplier and source tags — we're adding those so the download is a full audit trail — and we don't yet put a numeric confidence score on a line. A brand-new SKU with little history still gets a sensible number from your min-stock and backorder rules, but it won't be flagged 'low confidence' yet. That flag is on our roadmap; today that judgment stays with your planner, and all the transparency to make it is on screen."

Under the hood + best practice
In-app trace: full ✓CSV export: drops multiplierConfidence score: noneThin data: silent number

Two concrete gaps

  • Export omits the demand multiplier + source tags (the UI has them). Cheap fix — finance audits from the spreadsheet.
  • No confidence / data-sufficiency flag anywhere on a reorder rec; zero-history SKUs silently fall back to min-stock/backorder logic. (The only "confidence" in the product is a demand-trend arrow on a different report.)

vs industry

SMB tools (Inventory Planner, Cogsy) match our UI transparency and also lack a numeric confidence score. Enterprise/AI (Netstock, Lokad) add probabilistic confidence intervals + explicit new-product (cold-start) handling — link-to-similar-SKU is the natural next step for us. Buyers literally screen for "how do you handle new products with no history, and is it a black box?"

6

Lead time per market?

Partial — workaround today
"Can lead-time assumptions be set per market? Ours differ — CA/US/AU ~2 months, UK/EU ~2.5 months including sea freight."

"Today our demand-planning forecast resolves one lead time per product/supplier — it doesn't change by destination market (the warehouse only changes on-hand numbers). So the clean way to reflect your 2-month vs 2.5-month markets right now is a per-region supplier setup keyed to each warehouse. Per-destination lead time is on our roadmap as a first-class field — and importantly we already model per-lane transit times in our manufacturing/transfer engine, so it's a matter of surfacing that in the forecasting workflow, not inventing it from scratch."

Under the hood + best practice

How it works

Forecast lead time resolves supplier-product → supplier → global default. The supplier_products table has no warehouse/market column. There's no "Market" entity — a market maps to a Warehouse (which has country_code).

The building blocks already exist (in MRP)

The separate MRP engine already supports per-(product, warehouse) lead time (ItemPlanningPolicy) and per source→destination lane transfer lead times (DistributionNetwork.transfer_lead_time_days) — exactly your sea-freight-to-UK case. They're just not wired into the forecasting screens yet. Fix = let destination_warehouse_id feed lead-time resolution.

vs industry

Enterprise APS (GAINS, Netstock, Blue Yonder) model supplier + lane transit lead time per supplier-item-destination. In the ecommerce mid-market a clean "per-market lead time" field is uncommon — Inventory Planner/Cogsy also handle it via per-vendor settings or separate supplier records, like our workaround.

7

Is demand netted against expected return rate?

Partial — data exists, not wired
"Does forecasted demand get netted against expected return rate by SKU, or do we account for that separately?"

"Today our forecast is built on gross units sold, so for a high-return SKU you'd currently account for returns separately — there's an exclude-returns filter, but it's a coarse whole-order toggle, not a per-SKU net. Where we're actually strong is that we already compute return rate by SKU in our product scorecard and analytics — the gap is purely that it isn't wired into forecasting yet. Honestly, most ecommerce tools in our tier — Inventory Planner, Cogsy, Prediko — don't do true per-SKU return-rate netting either; only the enterprise retail suites do. So accounting for returns separately today is normal for the category, but because we already own the return-rate data, netting it into demand is a near-term, high-confidence roadmap item, not a research project."

Under the hood + best practice

How it works

Demand = gross SUM(quantity), excluding only draft/cancelled orders. No return term in the reorder formula. The forecast doesn't even subtract the per-line returned_quantity the rest of the app uses for net-sold — so a high-return SKU is systematically over-forecast. The one lever (exclude_returns) is opt-in and drops whole refunded orders by payment status.

The asset we already have

ProductScorecardManager::returnRate() = returned ÷ sold per SKU; return_rate_pct in product analytics. Not consumed by forecasting. Wiring = a quick win (subtract returned_quantity) + an opt-in "expected return rate" demand modifier netting velocity by (1 − rate).

Best practice

Forecast on gross, model returns as a separate per-SKU stream, then net replenishment (or treat returns as inbound supply). DTC returns run 20–40% vs 8–10% retail, so gross-only over-buys high-return SKUs. Enterprise (Blue Yonder/RELEX/o9) do ML return-probability; SMB tools mostly don't.

Part 3 · API

8

API — PO, vendor lead time, and the calculation itself?

Yes ✓ — differentiator
"Does the API expose PO and vendor lead-time data, AND the forecast calculation itself (not just the output), so we can pull it into our internal ops tool?"

"Most planning tools will give your ops tool the forecast number and your PO and lead-time data over an API — we do that too — but very few will hand you the calculation itself. Our forecast API returns the full per-line trace: the baseline daily demand, the exact multiplier we applied and why, the lead-time and coverage math, the live inventory position, and the literal formula — each with a source tag — so Rouge can reconstruct our recommendation and blend it with your own context. The one thing we don't yet emit is a single statistical confidence interval; today you'd infer confidence from our velocity-trend and warning signals, and a first-class confidence field is on our roadmap."

Under the hood + best practice

Three surfaces (all auth:sanctum)

  • Forecast calc tracePOST /api/inventory-forecasting/build returns per line: base_daily_averagedemand_modifier_descriptiondaily_average → lead-time/target coverage → inventory snapshot → final_quantity, plus the literal formula and a *_source on every input.
  • Vendor lead time — supplier + supplier-product resources; measured actuals (avg_actual_lead_time, reliability, variance) via the inventory-planning report.
  • Purchase orders — full REST (Route::resource('purchase-orders') + lines/receipts/movements), plus forecast-driven PO creation.

Caveats to flag

No single "confidence" field (infer from velocity-trend + base-vs-adjusted + warnings). Forecasts are computed on-demand via POST (no GET-by-id stored breakdown yet). Measured lead-time actuals live on a companion report call, not in the build payload. External token needs purchase-orders/suppliers/reports scopes.

vs industry

Inventory Planner/Netstock expose forecast + PO + lead-time data but keep the "why" in a UI drill-down. Exposing a machine-readable per-line calculation trace is uncommon — a genuine differentiator. Big ERPs (D365 BC, NetSuite) lead only on returning a confidence interval.

Your own question · point-in-time SKU association

Are subscription offerings the right home for time-versioned SKU/bundle association?

Your instinct is right
"Subscription offerings feel like they're being used to change a SKU↔product/bundle association at a point in time — but I'm not sure that's the right methodology; it turned into something it wasn't intended to be. Is there a better framework?"

Short version: you're right on both counts. Subscription editions genuinely are effective-dated point-in-time versioning — but only of a single scalar product pointer (offering → which SKU ships in a date window), and they've drifted. What began (Dec 2025) as a clean effective_date/cutoff_date resolver became (Mar 2026) a priority-ranked, quantity-rationed, order-splitting allocation engine — and the "no overlapping date ranges" validation was gutted to a no-op because priority now does the work. Separately, bundle/kit composition isn't effective-dated at all: the recipe table has no temporal columns. These are two different mechanisms, not one framework — and neither is a general-purpose time-versioned association system.

The clean framework — and what to change

What each mechanism actually is

  • Subscription editions = effective-dated resolution of an offering → one product_id. Real as-of-date semantics (findEditionForDate). This core idea is sound and industry-standard — it's exactly what Recharge ("monthly SKU swap", "order-sequence swap") and Skio do for changing box contents. Resolve at order time, then freeze onto the sales-order line. Keep this.
  • Bundle/kit recipe (product_components) = live snapshot, no version, no dates. Bundles fake history by snapshotting the component qty onto the order line at write time (immutable — good). Kits retain nothing: the forecast re-explodes every historical kit sale against the current recipe, so editing a kit recipe silently rewrites history. You can't ask "what was product X's recipe on date Y."

Where it over-extended (the "turned into something it wasn't")

The edition row now fuses three orthogonal concerns: (a) temporal versioning, (b) priority tie-break, (c) inventory quota/allocation/splitting. Only (a) is temporal. Jamming (b)+(c) in is what forced disabling the non-overlap invariant.

The better framework — effective-dated BOM (NetSuite is the reference)

Time-versioned composition belongs in an effective-dated / revision-controlled Bill of Materials, not in subscriptions. Each recipe revision carries valid_from/valid_to; the recipe resolves as-of the order/build date; and the system enforces non-overlap (NetSuite auto-computes obsolete dates so revisions never gap or overlap — the exact invariant you had to disable). Subscription platforms deliberately keep "what ships next month" as a workflow concern, separate from composition versioning.

Recommended split

  • Keep a thin effective-dated resolver for offering → single product (Recharge/Skio-aligned).
  • Extract priority + allocation + splitting out of editions into a separate rationing concern; then re-enable a real non-overlap guarantee on the temporal layer.
  • Introduce a BomRevision (valid_from/valid_to, non-overlap enforced) owning the recipe rows, resolved as-of date — so bundles and kits finally share one as-of-date model, and kit history stops silently rewriting.

Meeting-safe line if Otis asks: "You're right that it drifted — the core effective-dated idea is sound and is what Recharge and Skio do, but we bolted quota and splitting onto it. The clean pattern for versioning a recipe over time is an effective-dated Bill of Materials — NetSuite's revision-controlled BOM — and separating those two is the fix."