feat(brands): Add calculated tags with configurable thresholds
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed

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 <noreply@anthropic.com>
This commit is contained in:
Kelly
2025-12-15 12:06:44 -07:00
parent 231d49e3e8
commit 2708fbe319

View File

@@ -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,