From 2708fbe3191c410716f97af7fbf03850d9380fd8 Mon Sep 17 00:00:00 2001 From: Kelly Date: Mon, 15 Dec 2025 12:06:44 -0700 Subject: [PATCH] feat(brands): Add calculated tags with configurable thresholds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tags assigned per store: - must_win: High-revenue store with room to grow SKUs - at_risk: High OOS% (losing shelf presence) - top_performer: High sales + good inventory management - growth: Above-average velocity - low_inventory: Low days on hand Configurable via query params: - ?must_win_max_skus=5 - ?at_risk_oos_pct=30 - ?top_performer_max_oos=15 - ?low_inventory_days=7 Response includes tag_thresholds showing applied values. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- backend/src/routes/brands.ts | 71 ++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/backend/src/routes/brands.ts b/backend/src/routes/brands.ts index 56b8d7fa..2d6f9845 100644 --- a/backend/src/routes/brands.ts +++ b/backend/src/routes/brands.ts @@ -713,6 +713,14 @@ export function createBrandsRouter(pool: Pool): Router { // Margin assumption - default 50% (industry standard) const marginPct = Math.min(Math.max(parseFloat(req.query.margin_pct as string) || 50, 0), 100); + // Configurable tag thresholds (Cannabrands can set their own basis) + const tagConfig = { + must_win_max_skus: parseInt(req.query.must_win_max_skus as string) || 5, + at_risk_oos_pct: parseInt(req.query.at_risk_oos_pct as string) || 30, + top_performer_max_oos: parseInt(req.query.top_performer_max_oos as string) || 15, + low_inventory_days: parseInt(req.query.low_inventory_days as string) || 7, + }; + const startDate = new Date(); startDate.setDate(startDate.getDate() - days); @@ -877,11 +885,71 @@ export function createBrandsRouter(pool: Pool): Router { const totals = totalsResult.rows[0] || {}; + // Calculate percentiles for relative tagging + const salesValues = result.rows + .map(r => r.total_sales_est ? parseFloat(r.total_sales_est) : 0) + .sort((a, b) => a - b); + const velocityValues = result.rows + .map(r => r.avg_daily_units ? parseFloat(r.avg_daily_units) : 0) + .sort((a, b) => a - b); + + const getPercentile = (arr: number[], p: number) => { + if (arr.length === 0) return 0; + const idx = Math.floor(arr.length * p); + return arr[Math.min(idx, arr.length - 1)]; + }; + + const salesP75 = getPercentile(salesValues, 0.75); + const salesP50 = getPercentile(salesValues, 0.50); + const velocityP75 = getPercentile(velocityValues, 0.75); + + // Tag calculation function (uses configurable thresholds) + const calculateTags = (row: any): string[] => { + const tags: string[] = []; + const sales = row.total_sales_est ? parseFloat(row.total_sales_est) : 0; + const velocity = row.avg_daily_units ? parseFloat(row.avg_daily_units) : 0; + const oosPct = parseInt(row.oos_pct) || 0; + const skuCount = parseInt(row.active_skus) || 0; + const daysOnHand = row.avg_days_on_hand ? parseFloat(row.avg_days_on_hand) : null; + + // Must Win: High-revenue store where brand has room to grow + // Configurable: ?must_win_max_skus=5 (default) + if (sales >= salesP75 && skuCount < tagConfig.must_win_max_skus) { + tags.push('must_win'); + } + + // At Risk: High OOS% means brand is losing shelf presence + // Configurable: ?at_risk_oos_pct=30 (default) + if (oosPct >= tagConfig.at_risk_oos_pct) { + tags.push('at_risk'); + } + + // Top Performer: High sales + good inventory management + // Configurable: ?top_performer_max_oos=15 (default) + if (sales >= salesP75 && oosPct < tagConfig.top_performer_max_oos) { + tags.push('top_performer'); + } + + // Growth: Above-average velocity - momentum store + if (velocity >= velocityP75 && sales >= salesP50) { + tags.push('growth'); + } + + // Low Inventory: Days on hand below threshold + // Configurable: ?low_inventory_days=7 (default) + if (daysOnHand !== null && daysOnHand < tagConfig.low_inventory_days && daysOnHand > 0) { + tags.push('low_inventory'); + } + + return tags; + }; + res.json({ brand: brandName, period_days: days, state: stateCode || 'all', margin_pct_assumed: marginPct, + tag_thresholds: tagConfig, summary: { total_stores: parseInt(totals.total_stores) || 0, total_oos: parseInt(totals.total_oos) || 0, @@ -889,12 +957,15 @@ export function createBrandsRouter(pool: Pool): Router { stores: result.rows.map(row => { const totalSalesEst = row.total_sales_est ? parseFloat(row.total_sales_est) : null; const marginEst = totalSalesEst ? totalSalesEst * (marginPct / 100) : null; + const tags = calculateTags(row); return { store_id: row.store_id, store_name: row.store_name, state_code: row.state_code, city: row.city, address: row.address, + // Tags (calculated) + tags, // SKU counts active_skus: parseInt(row.active_skus) || 0, oos_skus: parseInt(row.oos_skus) || 0,