feat(brands): Add calculated tags with configurable thresholds
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user