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)
|
// Margin assumption - default 50% (industry standard)
|
||||||
const marginPct = Math.min(Math.max(parseFloat(req.query.margin_pct as string) || 50, 0), 100);
|
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();
|
const startDate = new Date();
|
||||||
startDate.setDate(startDate.getDate() - days);
|
startDate.setDate(startDate.getDate() - days);
|
||||||
|
|
||||||
@@ -877,11 +885,71 @@ export function createBrandsRouter(pool: Pool): Router {
|
|||||||
|
|
||||||
const totals = totalsResult.rows[0] || {};
|
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({
|
res.json({
|
||||||
brand: brandName,
|
brand: brandName,
|
||||||
period_days: days,
|
period_days: days,
|
||||||
state: stateCode || 'all',
|
state: stateCode || 'all',
|
||||||
margin_pct_assumed: marginPct,
|
margin_pct_assumed: marginPct,
|
||||||
|
tag_thresholds: tagConfig,
|
||||||
summary: {
|
summary: {
|
||||||
total_stores: parseInt(totals.total_stores) || 0,
|
total_stores: parseInt(totals.total_stores) || 0,
|
||||||
total_oos: parseInt(totals.total_oos) || 0,
|
total_oos: parseInt(totals.total_oos) || 0,
|
||||||
@@ -889,12 +957,15 @@ export function createBrandsRouter(pool: Pool): Router {
|
|||||||
stores: result.rows.map(row => {
|
stores: result.rows.map(row => {
|
||||||
const totalSalesEst = row.total_sales_est ? parseFloat(row.total_sales_est) : null;
|
const totalSalesEst = row.total_sales_est ? parseFloat(row.total_sales_est) : null;
|
||||||
const marginEst = totalSalesEst ? totalSalesEst * (marginPct / 100) : null;
|
const marginEst = totalSalesEst ? totalSalesEst * (marginPct / 100) : null;
|
||||||
|
const tags = calculateTags(row);
|
||||||
return {
|
return {
|
||||||
store_id: row.store_id,
|
store_id: row.store_id,
|
||||||
store_name: row.store_name,
|
store_name: row.store_name,
|
||||||
state_code: row.state_code,
|
state_code: row.state_code,
|
||||||
city: row.city,
|
city: row.city,
|
||||||
address: row.address,
|
address: row.address,
|
||||||
|
// Tags (calculated)
|
||||||
|
tags,
|
||||||
// SKU counts
|
// SKU counts
|
||||||
active_skus: parseInt(row.active_skus) || 0,
|
active_skus: parseInt(row.active_skus) || 0,
|
||||||
oos_skus: parseInt(row.oos_skus) || 0,
|
oos_skus: parseInt(row.oos_skus) || 0,
|
||||||
|
|||||||
Reference in New Issue
Block a user