Discount simulator fixes/adjustments
This commit is contained in:
@@ -4,57 +4,54 @@ const { getDbConnection } = require('../db/connection');
|
|||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Bucket boundaries by summary_subtotal (post-item-sale, pre-order-promo).
|
||||||
|
// The final entry is open-ended: all orders >= the last bound land there.
|
||||||
const RANGE_BOUNDS = [
|
const RANGE_BOUNDS = [
|
||||||
10, 20, 30, 40, 50, 60, 70, 80, 90,
|
10, 20, 30, 40, 50, 60, 70, 80, 90,
|
||||||
100, 110, 120, 130, 140, 150, 160, 170, 180, 190, 200,
|
100, 110, 120, 130, 140, 150, 160, 170, 180, 190, 200,
|
||||||
300, 400, 500, 1000, 1500, 2000
|
300, 400, 500, 1000, 1500
|
||||||
];
|
];
|
||||||
|
|
||||||
const FINAL_BUCKET_KEY = 'PLUS';
|
const FINAL_BUCKET_KEY = '99999';
|
||||||
|
|
||||||
function buildRangeDefinitions() {
|
function buildRangeDefinitions() {
|
||||||
const ranges = [];
|
const ranges = [];
|
||||||
let previous = 0;
|
let previous = 0;
|
||||||
for (const bound of RANGE_BOUNDS) {
|
for (const bound of RANGE_BOUNDS) {
|
||||||
const label = `$${previous.toLocaleString()} - $${bound.toLocaleString()}`;
|
|
||||||
const key = bound.toString().padStart(5, '0');
|
const key = bound.toString().padStart(5, '0');
|
||||||
ranges.push({
|
ranges.push({
|
||||||
min: previous,
|
min: previous,
|
||||||
max: bound,
|
max: bound,
|
||||||
label,
|
label: `$${previous.toLocaleString()} - $${bound.toLocaleString()}`,
|
||||||
key,
|
key,
|
||||||
sort: bound
|
|
||||||
});
|
});
|
||||||
previous = bound;
|
previous = bound;
|
||||||
}
|
}
|
||||||
// Remove the 2000+ category - all orders >2000 will go into the 2000 bucket
|
const lastBound = RANGE_BOUNDS[RANGE_BOUNDS.length - 1];
|
||||||
|
ranges.push({
|
||||||
|
min: lastBound,
|
||||||
|
max: null,
|
||||||
|
label: `$${lastBound.toLocaleString()}+`,
|
||||||
|
key: FINAL_BUCKET_KEY,
|
||||||
|
});
|
||||||
return ranges;
|
return ranges;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RANGE_DEFINITIONS = buildRangeDefinitions();
|
const RANGE_DEFINITIONS = buildRangeDefinitions();
|
||||||
|
|
||||||
const BUCKET_CASE = (() => {
|
function bucketKeyFor(subtotal) {
|
||||||
const parts = [];
|
for (const range of RANGE_DEFINITIONS) {
|
||||||
for (let i = 0; i < RANGE_BOUNDS.length; i++) {
|
if (range.max == null) return range.key;
|
||||||
const bound = RANGE_BOUNDS[i];
|
if (subtotal <= range.max) return range.key;
|
||||||
const key = bound.toString().padStart(5, '0');
|
|
||||||
if (i === RANGE_BOUNDS.length - 1) {
|
|
||||||
// For the last bucket (2000), include all orders >= 1500 (previous bound)
|
|
||||||
parts.push(`ELSE '${key}'`);
|
|
||||||
} else {
|
|
||||||
parts.push(`WHEN o.summary_subtotal <= ${bound} THEN '${key}'`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return `CASE\n ${parts.join('\n ')}\n END`;
|
return FINAL_BUCKET_KEY;
|
||||||
})();
|
}
|
||||||
|
|
||||||
const DEFAULT_POINT_DOLLAR_VALUE = 0.005; // 1000 points = $5, so 200 points = $1
|
const DEFAULT_POINT_DOLLAR_VALUE = 0.005;
|
||||||
|
|
||||||
const DEFAULTS = {
|
const DEFAULTS = {
|
||||||
merchantFeePercent: 2.9,
|
merchantFeePercent: 2.9,
|
||||||
fixedCostPerOrder: 1.5,
|
fixedCostPerOrder: 1.25,
|
||||||
pointsPerDollar: 0,
|
|
||||||
pointsRedemptionRate: 0, // Will be calculated from actual data
|
|
||||||
pointDollarValue: DEFAULT_POINT_DOLLAR_VALUE,
|
pointDollarValue: DEFAULT_POINT_DOLLAR_VALUE,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -73,13 +70,6 @@ function formatDateForSql(dt) {
|
|||||||
return dt.toFormat('yyyy-LL-dd HH:mm:ss');
|
return dt.toFormat('yyyy-LL-dd HH:mm:ss');
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMidpoint(range) {
|
|
||||||
if (range.max == null) {
|
|
||||||
return range.min + 200; // Rough estimate for 2000+
|
|
||||||
}
|
|
||||||
return (range.min + range.max) / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
router.get('/promos', async (req, res) => {
|
router.get('/promos', async (req, res) => {
|
||||||
let connection;
|
let connection;
|
||||||
try {
|
try {
|
||||||
@@ -101,7 +91,7 @@ router.get('/promos', async (req, res) => {
|
|||||||
const rangeEndSql = formatDateForSql(rangeEnd);
|
const rangeEndSql = formatDateForSql(rangeEnd);
|
||||||
|
|
||||||
const sql = `
|
const sql = `
|
||||||
SELECT
|
SELECT
|
||||||
p.promo_id AS id,
|
p.promo_id AS id,
|
||||||
p.promo_code AS code,
|
p.promo_code AS code,
|
||||||
p.promo_description_online AS description_online,
|
p.promo_description_online AS description_online,
|
||||||
@@ -111,8 +101,8 @@ router.get('/promos', async (req, res) => {
|
|||||||
COALESCE(u.usage_count, 0) AS usage_count
|
COALESCE(u.usage_count, 0) AS usage_count
|
||||||
FROM promos p
|
FROM promos p
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
SELECT
|
SELECT
|
||||||
discount_code,
|
discount_code,
|
||||||
COUNT(DISTINCT order_id) AS usage_count
|
COUNT(DISTINCT order_id) AS usage_count
|
||||||
FROM order_discounts
|
FROM order_discounts
|
||||||
WHERE discount_type = 10 AND discount_active = 1
|
WHERE discount_type = 10 AND discount_active = 1
|
||||||
@@ -156,6 +146,188 @@ router.get('/promos', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function emptyBucketAccumulator(range) {
|
||||||
|
return {
|
||||||
|
key: range.key,
|
||||||
|
label: range.label,
|
||||||
|
min: range.min,
|
||||||
|
max: range.max,
|
||||||
|
orderCount: 0,
|
||||||
|
sumOrderValue: 0,
|
||||||
|
sumProductDiscountAmount: 0,
|
||||||
|
sumPromoProductDiscount: 0,
|
||||||
|
sumCustomerItemCost: 0,
|
||||||
|
sumShippingChargeBase: 0,
|
||||||
|
sumShippingAfterAuto: 0,
|
||||||
|
sumShipPromoDiscount: 0,
|
||||||
|
sumShippingSurcharge: 0,
|
||||||
|
sumOrderSurcharge: 0,
|
||||||
|
sumCustomerShipCost: 0,
|
||||||
|
sumActualShippingCost: 0,
|
||||||
|
sumTotalRevenue: 0,
|
||||||
|
sumProductCogs: 0,
|
||||||
|
sumMerchantFees: 0,
|
||||||
|
sumPointsCost: 0,
|
||||||
|
sumFixedCosts: 0,
|
||||||
|
sumTotalCosts: 0,
|
||||||
|
sumProfit: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function simulateOrder(order, config, derived) {
|
||||||
|
const orderValue = Number(order.summary_subtotal) || 0;
|
||||||
|
const retail = Number(order.summary_subtotal_retail) || orderValue;
|
||||||
|
const productDiscountAmount = Number(order.summary_discount_subtotal) || 0;
|
||||||
|
const pointsRedeemedDollars = Number(order.points_redeemed) || 0;
|
||||||
|
// summary_discount_subtotal is a kitchen-sink rollup that includes points
|
||||||
|
// redemptions (type 20). pointsCost already accrues for points awarded, so
|
||||||
|
// the points portion of historical discount must be excluded here to avoid
|
||||||
|
// double-counting it on orders that redeemed points.
|
||||||
|
const historicalProductDiscountExPoints = Math.max(0, productDiscountAmount - pointsRedeemedDollars);
|
||||||
|
const shippingChargeBase =
|
||||||
|
(Number(order.summary_shipping) || 0) + (Number(order.summary_shipping_rush) || 0);
|
||||||
|
const actualShippingCost = Number(order.ship_method_cost) || 0;
|
||||||
|
const cogs = Number(order.total_cogs) || 0;
|
||||||
|
|
||||||
|
let promoProductDiscount = 0;
|
||||||
|
if (config.productPromo.type === 'percentage_subtotal' && orderValue >= config.productPromo.minSubtotal) {
|
||||||
|
promoProductDiscount = orderValue * (config.productPromo.value / 100);
|
||||||
|
} else if (config.productPromo.type === 'percentage_regular' && orderValue >= config.productPromo.minSubtotal) {
|
||||||
|
const targetRate = config.productPromo.value / 100;
|
||||||
|
const targetCustomerPrice = retail * (1 - targetRate);
|
||||||
|
promoProductDiscount = Math.max(0, orderValue - targetCustomerPrice);
|
||||||
|
} else if (config.productPromo.type === 'fixed_amount' && orderValue >= config.productPromo.minSubtotal) {
|
||||||
|
promoProductDiscount = config.productPromo.value;
|
||||||
|
} else if (config.productPromo.type === 'none' && config.applyHistoricalProductPromo) {
|
||||||
|
promoProductDiscount = historicalProductDiscountExPoints;
|
||||||
|
}
|
||||||
|
promoProductDiscount = Math.max(0, Math.min(promoProductDiscount, orderValue));
|
||||||
|
|
||||||
|
let shippingAfterAuto = shippingChargeBase;
|
||||||
|
for (const tier of config.shippingTiers) {
|
||||||
|
if (orderValue >= tier.threshold) {
|
||||||
|
if (tier.mode === 'percentage') {
|
||||||
|
shippingAfterAuto = shippingChargeBase * Math.max(0, 1 - tier.value / 100);
|
||||||
|
} else if (tier.mode === 'flat') {
|
||||||
|
shippingAfterAuto = tier.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let shipPromoDiscount = 0;
|
||||||
|
if (config.shippingPromo.type !== 'none' && orderValue >= config.shippingPromo.minSubtotal) {
|
||||||
|
if (config.shippingPromo.type === 'percentage') {
|
||||||
|
shipPromoDiscount = shippingAfterAuto * (config.shippingPromo.value / 100);
|
||||||
|
} else if (config.shippingPromo.type === 'fixed') {
|
||||||
|
shipPromoDiscount = config.shippingPromo.value;
|
||||||
|
}
|
||||||
|
if (config.shippingPromo.maxDiscount > 0) {
|
||||||
|
shipPromoDiscount = Math.min(shipPromoDiscount, config.shippingPromo.maxDiscount);
|
||||||
|
}
|
||||||
|
shipPromoDiscount = Math.min(shipPromoDiscount, shippingAfterAuto);
|
||||||
|
}
|
||||||
|
|
||||||
|
let shippingSurcharge = 0;
|
||||||
|
let orderSurcharge = 0;
|
||||||
|
for (const surcharge of config.surcharges) {
|
||||||
|
const meetsMin = orderValue >= surcharge.threshold;
|
||||||
|
const meetsMax = surcharge.maxThreshold == null || orderValue < surcharge.maxThreshold;
|
||||||
|
if (meetsMin && meetsMax) {
|
||||||
|
if (surcharge.target === 'shipping') shippingSurcharge += surcharge.amount;
|
||||||
|
else if (surcharge.target === 'order') orderSurcharge += surcharge.amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const customerShipCost = Math.max(0, shippingAfterAuto - shipPromoDiscount + shippingSurcharge);
|
||||||
|
const customerItemCost = Math.max(0, orderValue - promoProductDiscount + orderSurcharge);
|
||||||
|
const totalRevenue = customerItemCost + customerShipCost;
|
||||||
|
|
||||||
|
const productCogs = config.cogsCalculationMode === 'average'
|
||||||
|
? orderValue * derived.overallCogsPercentage
|
||||||
|
: cogs;
|
||||||
|
|
||||||
|
const merchantFees = totalRevenue * (config.merchantFeePercent / 100);
|
||||||
|
const pointsCost = orderValue * derived.pointsPerDollar * derived.redemptionRate * derived.pointDollarValue;
|
||||||
|
const fixedCosts = config.fixedCostPerOrder;
|
||||||
|
const totalCosts = productCogs + actualShippingCost + merchantFees + pointsCost + fixedCosts;
|
||||||
|
const profit = totalRevenue - totalCosts;
|
||||||
|
|
||||||
|
return {
|
||||||
|
orderValue,
|
||||||
|
productDiscountAmount,
|
||||||
|
promoProductDiscount,
|
||||||
|
customerItemCost,
|
||||||
|
shippingChargeBase,
|
||||||
|
shippingAfterAuto,
|
||||||
|
shipPromoDiscount,
|
||||||
|
shippingSurcharge,
|
||||||
|
orderSurcharge,
|
||||||
|
customerShipCost,
|
||||||
|
actualShippingCost,
|
||||||
|
totalRevenue,
|
||||||
|
productCogs,
|
||||||
|
merchantFees,
|
||||||
|
pointsCost,
|
||||||
|
fixedCosts,
|
||||||
|
totalCosts,
|
||||||
|
profit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function accumulate(bucket, sim) {
|
||||||
|
bucket.orderCount += 1;
|
||||||
|
bucket.sumOrderValue += sim.orderValue;
|
||||||
|
bucket.sumProductDiscountAmount += sim.productDiscountAmount;
|
||||||
|
bucket.sumPromoProductDiscount += sim.promoProductDiscount;
|
||||||
|
bucket.sumCustomerItemCost += sim.customerItemCost;
|
||||||
|
bucket.sumShippingChargeBase += sim.shippingChargeBase;
|
||||||
|
bucket.sumShippingAfterAuto += sim.shippingAfterAuto;
|
||||||
|
bucket.sumShipPromoDiscount += sim.shipPromoDiscount;
|
||||||
|
bucket.sumShippingSurcharge += sim.shippingSurcharge;
|
||||||
|
bucket.sumOrderSurcharge += sim.orderSurcharge;
|
||||||
|
bucket.sumCustomerShipCost += sim.customerShipCost;
|
||||||
|
bucket.sumActualShippingCost += sim.actualShippingCost;
|
||||||
|
bucket.sumTotalRevenue += sim.totalRevenue;
|
||||||
|
bucket.sumProductCogs += sim.productCogs;
|
||||||
|
bucket.sumMerchantFees += sim.merchantFees;
|
||||||
|
bucket.sumPointsCost += sim.pointsCost;
|
||||||
|
bucket.sumFixedCosts += sim.fixedCosts;
|
||||||
|
bucket.sumTotalCosts += sim.totalCosts;
|
||||||
|
bucket.sumProfit += sim.profit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function finalizeBucket(b, totalOrders) {
|
||||||
|
const n = b.orderCount;
|
||||||
|
const avg = (sum) => (n > 0 ? sum / n : 0);
|
||||||
|
return {
|
||||||
|
key: b.key,
|
||||||
|
label: b.label,
|
||||||
|
min: b.min,
|
||||||
|
max: b.max,
|
||||||
|
orderCount: n,
|
||||||
|
weight: totalOrders > 0 ? n / totalOrders : 0,
|
||||||
|
orderValue: avg(b.sumOrderValue),
|
||||||
|
productDiscountAmount: avg(b.sumProductDiscountAmount),
|
||||||
|
promoProductDiscount: avg(b.sumPromoProductDiscount),
|
||||||
|
customerItemCost: avg(b.sumCustomerItemCost),
|
||||||
|
shippingChargeBase: avg(b.sumShippingChargeBase),
|
||||||
|
shippingAfterAuto: avg(b.sumShippingAfterAuto),
|
||||||
|
shipPromoDiscount: avg(b.sumShipPromoDiscount),
|
||||||
|
shippingSurcharge: avg(b.sumShippingSurcharge),
|
||||||
|
orderSurcharge: avg(b.sumOrderSurcharge),
|
||||||
|
customerShipCost: avg(b.sumCustomerShipCost),
|
||||||
|
actualShippingCost: avg(b.sumActualShippingCost),
|
||||||
|
totalRevenue: avg(b.sumTotalRevenue),
|
||||||
|
productCogs: avg(b.sumProductCogs),
|
||||||
|
merchantFees: avg(b.sumMerchantFees),
|
||||||
|
pointsCost: avg(b.sumPointsCost),
|
||||||
|
fixedCosts: avg(b.sumFixedCosts),
|
||||||
|
totalCosts: avg(b.sumTotalCosts),
|
||||||
|
profit: avg(b.sumProfit),
|
||||||
|
profitPercent: b.sumTotalRevenue > 0 ? b.sumProfit / b.sumTotalRevenue : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
router.post('/simulate', async (req, res) => {
|
router.post('/simulate', async (req, res) => {
|
||||||
const {
|
const {
|
||||||
dateRange = {},
|
dateRange = {},
|
||||||
@@ -167,6 +339,7 @@ router.post('/simulate', async (req, res) => {
|
|||||||
merchantFeePercent,
|
merchantFeePercent,
|
||||||
fixedCostPerOrder,
|
fixedCostPerOrder,
|
||||||
cogsCalculationMode = 'actual',
|
cogsCalculationMode = 'actual',
|
||||||
|
applyHistoricalProductPromo = false,
|
||||||
pointsConfig = {}
|
pointsConfig = {}
|
||||||
} = req.body || {};
|
} = req.body || {};
|
||||||
|
|
||||||
@@ -176,20 +349,15 @@ router.post('/simulate', async (req, res) => {
|
|||||||
const endDt = parseDate(dateRange.end, endDefault).endOf('day');
|
const endDt = parseDate(dateRange.end, endDefault).endOf('day');
|
||||||
|
|
||||||
const shipCountry = filters.shipCountry || 'US';
|
const shipCountry = filters.shipCountry || 'US';
|
||||||
const rawPromoFilters = [
|
const promoIds = Array.from(
|
||||||
...(Array.isArray(filters.promoIds) ? filters.promoIds : []),
|
|
||||||
...(Array.isArray(filters.promoCodes) ? filters.promoCodes : []),
|
|
||||||
];
|
|
||||||
const promoCodes = Array.from(
|
|
||||||
new Set(
|
new Set(
|
||||||
rawPromoFilters
|
[
|
||||||
|
...(Array.isArray(filters.promoIds) ? filters.promoIds : []),
|
||||||
|
...(Array.isArray(filters.promoCodes) ? filters.promoCodes : []),
|
||||||
|
]
|
||||||
.map((value) => {
|
.map((value) => {
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') return value.trim();
|
||||||
return value.trim();
|
if (typeof value === 'number') return String(value);
|
||||||
}
|
|
||||||
if (typeof value === 'number') {
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
return '';
|
return '';
|
||||||
})
|
})
|
||||||
.filter((value) => value.length > 0)
|
.filter((value) => value.length > 0)
|
||||||
@@ -199,6 +367,8 @@ router.post('/simulate', async (req, res) => {
|
|||||||
const config = {
|
const config = {
|
||||||
merchantFeePercent: typeof merchantFeePercent === 'number' ? merchantFeePercent : DEFAULTS.merchantFeePercent,
|
merchantFeePercent: typeof merchantFeePercent === 'number' ? merchantFeePercent : DEFAULTS.merchantFeePercent,
|
||||||
fixedCostPerOrder: typeof fixedCostPerOrder === 'number' ? fixedCostPerOrder : DEFAULTS.fixedCostPerOrder,
|
fixedCostPerOrder: typeof fixedCostPerOrder === 'number' ? fixedCostPerOrder : DEFAULTS.fixedCostPerOrder,
|
||||||
|
cogsCalculationMode,
|
||||||
|
applyHistoricalProductPromo: applyHistoricalProductPromo === true,
|
||||||
productPromo: {
|
productPromo: {
|
||||||
type: productPromo.type || 'none',
|
type: productPromo.type || 'none',
|
||||||
value: Number(productPromo.value || 0),
|
value: Number(productPromo.value || 0),
|
||||||
@@ -248,300 +418,131 @@ router.post('/simulate', async (req, res) => {
|
|||||||
connection = dbConn.connection;
|
connection = dbConn.connection;
|
||||||
release = dbConn.release;
|
release = dbConn.release;
|
||||||
|
|
||||||
const filteredOrdersParams = [
|
const params = [shipCountry, formatDateForSql(startDt), formatDateForSql(endDt)];
|
||||||
shipCountry,
|
let promoExistsClause = '';
|
||||||
formatDateForSql(startDt),
|
if (promoIds.length > 0) {
|
||||||
formatDateForSql(endDt)
|
const placeholders = promoIds.map(() => '?').join(',');
|
||||||
];
|
promoExistsClause = `
|
||||||
const promoJoin = promoCodes.length > 0
|
AND EXISTS (
|
||||||
? 'JOIN order_discounts od ON od.order_id = o.order_id AND od.discount_active = 1 AND od.discount_type = 10'
|
SELECT 1 FROM order_discounts od
|
||||||
: '';
|
WHERE od.order_id = o.order_id
|
||||||
|
AND od.discount_active = 1
|
||||||
let promoFilterClause = '';
|
AND od.discount_type = 10
|
||||||
if (promoCodes.length > 0) {
|
AND od.discount_code IN (${placeholders})
|
||||||
const placeholders = promoCodes.map(() => '?').join(',');
|
)
|
||||||
promoFilterClause = `AND od.discount_code IN (${placeholders})`;
|
`;
|
||||||
filteredOrdersParams.push(...promoCodes);
|
params.push(...promoIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredOrdersQuery = `
|
const ordersQuery = `
|
||||||
SELECT
|
SELECT
|
||||||
o.order_id,
|
o.order_id,
|
||||||
o.order_cid,
|
|
||||||
o.summary_subtotal,
|
o.summary_subtotal,
|
||||||
o.summary_discount_subtotal,
|
COALESCE(o.summary_subtotal_retail, o.summary_subtotal) AS summary_subtotal_retail,
|
||||||
o.summary_shipping,
|
COALESCE(o.summary_discount_subtotal, 0) AS summary_discount_subtotal,
|
||||||
o.ship_method_rate,
|
COALESCE(o.summary_shipping, 0) AS summary_shipping,
|
||||||
o.ship_method_cost,
|
COALESCE(o.summary_shipping_rush, 0) AS summary_shipping_rush,
|
||||||
o.summary_points,
|
COALESCE(o.ship_method_cost, 0) AS ship_method_cost,
|
||||||
${BUCKET_CASE} AS bucket_key
|
COALESCE(o.summary_points, 0) AS summary_points,
|
||||||
|
COALESCE(c.total_cogs, 0) AS total_cogs,
|
||||||
|
COALESCE(p.points_redeemed, 0) AS points_redeemed
|
||||||
FROM _order o
|
FROM _order o
|
||||||
${promoJoin}
|
|
||||||
WHERE o.summary_shipping > 0
|
|
||||||
AND o.summary_total > 0
|
|
||||||
AND o.order_status NOT IN (15)
|
|
||||||
AND o.ship_method_selected <> 'holdit'
|
|
||||||
AND o.ship_country = ?
|
|
||||||
AND o.date_placed BETWEEN ? AND ?
|
|
||||||
${promoFilterClause}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const bucketParams = [
|
|
||||||
...filteredOrdersParams,
|
|
||||||
formatDateForSql(startDt),
|
|
||||||
formatDateForSql(endDt)
|
|
||||||
];
|
|
||||||
|
|
||||||
const bucketQuery = `
|
|
||||||
SELECT
|
|
||||||
f.bucket_key,
|
|
||||||
COUNT(*) AS order_count,
|
|
||||||
SUM(f.summary_subtotal) AS subtotal_sum,
|
|
||||||
SUM(f.summary_discount_subtotal) AS product_discount_sum,
|
|
||||||
SUM(f.summary_subtotal + f.summary_discount_subtotal) AS regular_subtotal_sum,
|
|
||||||
SUM(f.ship_method_rate) AS ship_rate_sum,
|
|
||||||
SUM(f.ship_method_cost) AS ship_cost_sum,
|
|
||||||
SUM(f.summary_points) AS points_awarded_sum,
|
|
||||||
SUM(COALESCE(p.points_redeemed, 0)) AS points_redeemed_sum,
|
|
||||||
SUM(COALESCE(c.total_cogs, 0)) AS cogs_sum,
|
|
||||||
AVG(f.summary_subtotal) AS avg_subtotal,
|
|
||||||
AVG(f.summary_discount_subtotal) AS avg_product_discount,
|
|
||||||
AVG(f.ship_method_rate) AS avg_ship_rate,
|
|
||||||
AVG(f.ship_method_cost) AS avg_ship_cost,
|
|
||||||
AVG(COALESCE(c.total_cogs, 0)) AS avg_cogs
|
|
||||||
FROM (
|
|
||||||
${filteredOrdersQuery}
|
|
||||||
) AS f
|
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
SELECT order_id, SUM(cogs_amount) AS total_cogs
|
SELECT order_id, SUM(cogs_amount) AS total_cogs
|
||||||
FROM report_sales_data
|
FROM report_sales_data
|
||||||
WHERE action IN (1,2,3)
|
WHERE action IN (1,2,3)
|
||||||
AND date_change BETWEEN ? AND ?
|
|
||||||
GROUP BY order_id
|
GROUP BY order_id
|
||||||
) AS c ON c.order_id = f.order_id
|
) c ON c.order_id = o.order_id
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
SELECT order_id, SUM(discount_amount) AS points_redeemed
|
SELECT order_id, SUM(discount_amount_subtotal) AS points_redeemed
|
||||||
FROM order_discounts
|
FROM order_discounts
|
||||||
WHERE discount_type = 20 AND discount_active = 1
|
WHERE discount_type = 20 AND discount_active = 1
|
||||||
GROUP BY order_id
|
GROUP BY order_id
|
||||||
) AS p ON p.order_id = f.order_id
|
) p ON p.order_id = o.order_id
|
||||||
GROUP BY f.bucket_key
|
WHERE o.summary_total > 0
|
||||||
|
AND o.order_status >= 20
|
||||||
|
AND o.ship_method_selected <> 'holdit'
|
||||||
|
AND o.ship_country = ?
|
||||||
|
AND o.date_placed BETWEEN ? AND ?
|
||||||
|
${promoExistsClause}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const [rows] = await connection.execute(bucketQuery, bucketParams);
|
const [orders] = await connection.execute(ordersQuery, params);
|
||||||
|
|
||||||
const totals = {
|
|
||||||
orders: 0,
|
|
||||||
subtotal: 0,
|
|
||||||
productDiscount: 0,
|
|
||||||
regularSubtotal: 0,
|
|
||||||
shipRate: 0,
|
|
||||||
shipCost: 0,
|
|
||||||
cogs: 0,
|
|
||||||
pointsAwarded: 0,
|
|
||||||
pointsRedeemed: 0
|
|
||||||
};
|
|
||||||
|
|
||||||
const rowMap = new Map();
|
|
||||||
for (const row of rows) {
|
|
||||||
const key = row.bucket_key || FINAL_BUCKET_KEY;
|
|
||||||
const parsed = {
|
|
||||||
orderCount: Number(row.order_count || 0),
|
|
||||||
subtotalSum: Number(row.subtotal_sum || 0),
|
|
||||||
productDiscountSum: Number(row.product_discount_sum || 0),
|
|
||||||
regularSubtotalSum: Number(row.regular_subtotal_sum || 0),
|
|
||||||
shipRateSum: Number(row.ship_rate_sum || 0),
|
|
||||||
shipCostSum: Number(row.ship_cost_sum || 0),
|
|
||||||
pointsAwardedSum: Number(row.points_awarded_sum || 0),
|
|
||||||
pointsRedeemedSum: Number(row.points_redeemed_sum || 0),
|
|
||||||
cogsSum: Number(row.cogs_sum || 0),
|
|
||||||
avgSubtotal: Number(row.avg_subtotal || 0),
|
|
||||||
avgProductDiscount: Number(row.avg_product_discount || 0),
|
|
||||||
avgShipRate: Number(row.avg_ship_rate || 0),
|
|
||||||
avgShipCost: Number(row.avg_ship_cost || 0),
|
|
||||||
avgCogs: Number(row.avg_cogs || 0)
|
|
||||||
};
|
|
||||||
rowMap.set(key, parsed);
|
|
||||||
|
|
||||||
totals.orders += parsed.orderCount;
|
|
||||||
totals.subtotal += parsed.subtotalSum;
|
|
||||||
totals.productDiscount += parsed.productDiscountSum;
|
|
||||||
totals.regularSubtotal += parsed.regularSubtotalSum;
|
|
||||||
totals.shipRate += parsed.shipRateSum;
|
|
||||||
totals.shipCost += parsed.shipCostSum;
|
|
||||||
totals.cogs += parsed.cogsSum;
|
|
||||||
totals.pointsAwarded += parsed.pointsAwardedSum;
|
|
||||||
totals.pointsRedeemed += parsed.pointsRedeemedSum;
|
|
||||||
}
|
|
||||||
|
|
||||||
const productDiscountRate = totals.regularSubtotal > 0
|
|
||||||
? totals.productDiscount / totals.regularSubtotal
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
const pointsPerDollar = config.points.pointsPerDollar != null
|
|
||||||
? config.points.pointsPerDollar
|
|
||||||
: totals.subtotal > 0
|
|
||||||
? totals.pointsAwarded / totals.subtotal
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
const pointDollarValue = config.points.pointDollarValue || DEFAULT_POINT_DOLLAR_VALUE;
|
|
||||||
|
|
||||||
// Calculate redemption rate using dollars redeemed from the matched order set
|
|
||||||
let calculatedRedemptionRate = 0;
|
|
||||||
if (config.points.redemptionRate != null) {
|
|
||||||
calculatedRedemptionRate = config.points.redemptionRate;
|
|
||||||
} else if (totals.pointsAwarded > 0 && pointDollarValue > 0) {
|
|
||||||
const totalRedeemedPoints = totals.pointsRedeemed / pointDollarValue;
|
|
||||||
if (totalRedeemedPoints > 0) {
|
|
||||||
calculatedRedemptionRate = Math.min(1, totalRedeemedPoints / totals.pointsAwarded);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const redemptionRate = calculatedRedemptionRate;
|
|
||||||
|
|
||||||
// Calculate overall average COGS percentage for 'average' mode
|
|
||||||
let overallCogsPercentage = 0;
|
|
||||||
if (cogsCalculationMode === 'average' && totals.subtotal > 0) {
|
|
||||||
overallCogsPercentage = totals.cogs / totals.subtotal;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bucketResults = [];
|
|
||||||
let weightedProfitAmount = 0;
|
|
||||||
let weightedProfitPercent = 0;
|
|
||||||
|
|
||||||
for (const range of RANGE_DEFINITIONS) {
|
|
||||||
const data = rowMap.get(range.key) || {
|
|
||||||
orderCount: 0,
|
|
||||||
avgSubtotal: 0,
|
|
||||||
avgShipRate: 0,
|
|
||||||
avgShipCost: 0,
|
|
||||||
avgCogs: 0
|
|
||||||
};
|
|
||||||
|
|
||||||
const orderValue = data.avgSubtotal > 0 ? data.avgSubtotal : getMidpoint(range);
|
|
||||||
const shippingChargeBase = data.avgShipCost > 0 ? data.avgShipCost : 0;
|
|
||||||
const actualShippingCost = data.avgShipCost > 0 ? data.avgShipCost : 0;
|
|
||||||
|
|
||||||
// Calculate COGS based on the selected mode
|
|
||||||
let productCogs;
|
|
||||||
if (cogsCalculationMode === 'average') {
|
|
||||||
// Use overall average COGS percentage applied to this bucket's order value
|
|
||||||
productCogs = orderValue * overallCogsPercentage;
|
|
||||||
} else {
|
|
||||||
// Use actual COGS data from this bucket (existing behavior)
|
|
||||||
productCogs = data.avgCogs > 0 ? data.avgCogs : 0;
|
|
||||||
}
|
|
||||||
const productDiscountAmount = orderValue * productDiscountRate;
|
|
||||||
const effectiveRegularPrice = productDiscountRate < 0.99
|
|
||||||
? orderValue / (1 - productDiscountRate)
|
|
||||||
: orderValue;
|
|
||||||
|
|
||||||
let promoProductDiscount = 0;
|
|
||||||
if (config.productPromo.type === 'percentage_subtotal' && orderValue >= config.productPromo.minSubtotal) {
|
|
||||||
promoProductDiscount = Math.min(orderValue, (config.productPromo.value / 100) * orderValue);
|
|
||||||
} else if (config.productPromo.type === 'percentage_regular' && orderValue >= config.productPromo.minSubtotal) {
|
|
||||||
const targetRate = config.productPromo.value / 100;
|
|
||||||
const additionalRate = Math.max(0, targetRate - productDiscountRate);
|
|
||||||
promoProductDiscount = Math.min(orderValue, additionalRate * effectiveRegularPrice);
|
|
||||||
} else if (config.productPromo.type === 'fixed_amount' && orderValue >= config.productPromo.minSubtotal) {
|
|
||||||
promoProductDiscount = Math.min(orderValue, config.productPromo.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
let shippingAfterAuto = shippingChargeBase;
|
|
||||||
for (const tier of config.shippingTiers) {
|
|
||||||
if (orderValue >= tier.threshold) {
|
|
||||||
if (tier.mode === 'percentage') {
|
|
||||||
shippingAfterAuto = shippingChargeBase * Math.max(0, 1 - tier.value / 100);
|
|
||||||
} else if (tier.mode === 'flat') {
|
|
||||||
shippingAfterAuto = tier.value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let shipPromoDiscount = 0;
|
|
||||||
if (config.shippingPromo.type !== 'none' && orderValue >= config.shippingPromo.minSubtotal) {
|
|
||||||
if (config.shippingPromo.type === 'percentage') {
|
|
||||||
shipPromoDiscount = shippingAfterAuto * (config.shippingPromo.value / 100);
|
|
||||||
} else if (config.shippingPromo.type === 'fixed') {
|
|
||||||
shipPromoDiscount = config.shippingPromo.value;
|
|
||||||
}
|
|
||||||
if (config.shippingPromo.maxDiscount > 0) {
|
|
||||||
shipPromoDiscount = Math.min(shipPromoDiscount, config.shippingPromo.maxDiscount);
|
|
||||||
}
|
|
||||||
shipPromoDiscount = Math.min(shipPromoDiscount, shippingAfterAuto);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate surcharges
|
|
||||||
let shippingSurcharge = 0;
|
|
||||||
let orderSurcharge = 0;
|
|
||||||
for (const surcharge of config.surcharges) {
|
|
||||||
const meetsMin = orderValue >= surcharge.threshold;
|
|
||||||
const meetsMax = surcharge.maxThreshold == null || orderValue < surcharge.maxThreshold;
|
|
||||||
if (meetsMin && meetsMax) {
|
|
||||||
if (surcharge.target === 'shipping') {
|
|
||||||
shippingSurcharge += surcharge.amount;
|
|
||||||
} else if (surcharge.target === 'order') {
|
|
||||||
orderSurcharge += surcharge.amount;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const customerShipCost = Math.max(0, shippingAfterAuto - shipPromoDiscount + shippingSurcharge);
|
|
||||||
const customerItemCost = Math.max(0, orderValue - promoProductDiscount + orderSurcharge);
|
|
||||||
const totalRevenue = customerItemCost + customerShipCost;
|
|
||||||
|
|
||||||
const merchantFees = totalRevenue * (config.merchantFeePercent / 100);
|
|
||||||
const pointsCost = customerItemCost * pointsPerDollar * redemptionRate * pointDollarValue;
|
|
||||||
const fixedCosts = config.fixedCostPerOrder;
|
|
||||||
const totalCosts = productCogs + actualShippingCost + merchantFees + pointsCost + fixedCosts;
|
|
||||||
const profit = totalRevenue - totalCosts;
|
|
||||||
const profitPercent = totalRevenue > 0 ? (profit / totalRevenue) : 0;
|
|
||||||
const weight = totals.orders > 0 ? (data.orderCount || 0) / totals.orders : 0;
|
|
||||||
|
|
||||||
weightedProfitAmount += profit * weight;
|
|
||||||
weightedProfitPercent += profitPercent * weight;
|
|
||||||
|
|
||||||
bucketResults.push({
|
|
||||||
key: range.key,
|
|
||||||
label: range.label,
|
|
||||||
min: range.min,
|
|
||||||
max: range.max,
|
|
||||||
orderCount: data.orderCount || 0,
|
|
||||||
weight,
|
|
||||||
orderValue,
|
|
||||||
productDiscountAmount,
|
|
||||||
promoProductDiscount,
|
|
||||||
customerItemCost,
|
|
||||||
shippingChargeBase,
|
|
||||||
shippingAfterAuto,
|
|
||||||
shipPromoDiscount,
|
|
||||||
shippingSurcharge,
|
|
||||||
orderSurcharge,
|
|
||||||
customerShipCost,
|
|
||||||
actualShippingCost,
|
|
||||||
totalRevenue,
|
|
||||||
productCogs,
|
|
||||||
merchantFees,
|
|
||||||
pointsCost,
|
|
||||||
fixedCosts,
|
|
||||||
totalCosts,
|
|
||||||
profit,
|
|
||||||
profitPercent
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (release) {
|
if (release) {
|
||||||
release();
|
release();
|
||||||
|
release = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let totalSubtotal = 0;
|
||||||
|
let totalProductDiscount = 0;
|
||||||
|
let totalCogs = 0;
|
||||||
|
let totalPointsAwarded = 0;
|
||||||
|
let totalPointsRedeemedDollars = 0;
|
||||||
|
for (const o of orders) {
|
||||||
|
totalSubtotal += Number(o.summary_subtotal) || 0;
|
||||||
|
totalProductDiscount += Number(o.summary_discount_subtotal) || 0;
|
||||||
|
totalCogs += Number(o.total_cogs) || 0;
|
||||||
|
totalPointsAwarded += Number(o.summary_points) || 0;
|
||||||
|
totalPointsRedeemedDollars += Number(o.points_redeemed) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const productDiscountRate = totalSubtotal > 0 ? totalProductDiscount / totalSubtotal : 0;
|
||||||
|
const overallCogsPercentage = totalSubtotal > 0 ? totalCogs / totalSubtotal : 0;
|
||||||
|
const pointsPerDollar = config.points.pointsPerDollar != null
|
||||||
|
? config.points.pointsPerDollar
|
||||||
|
: (totalSubtotal > 0 ? totalPointsAwarded / totalSubtotal : 0);
|
||||||
|
const pointDollarValue = config.points.pointDollarValue || DEFAULT_POINT_DOLLAR_VALUE;
|
||||||
|
let redemptionRate;
|
||||||
|
if (config.points.redemptionRate != null) {
|
||||||
|
redemptionRate = config.points.redemptionRate;
|
||||||
|
} else if (totalPointsAwarded > 0 && pointDollarValue > 0) {
|
||||||
|
const totalRedeemedPoints = totalPointsRedeemedDollars / pointDollarValue;
|
||||||
|
redemptionRate = Math.min(1, totalRedeemedPoints / totalPointsAwarded);
|
||||||
|
} else {
|
||||||
|
redemptionRate = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const derived = {
|
||||||
|
overallCogsPercentage,
|
||||||
|
pointsPerDollar,
|
||||||
|
redemptionRate,
|
||||||
|
pointDollarValue,
|
||||||
|
};
|
||||||
|
|
||||||
|
const buckets = new Map();
|
||||||
|
for (const range of RANGE_DEFINITIONS) {
|
||||||
|
buckets.set(range.key, emptyBucketAccumulator(range));
|
||||||
|
}
|
||||||
|
|
||||||
|
let grandTotalProfit = 0;
|
||||||
|
let grandTotalRevenue = 0;
|
||||||
|
|
||||||
|
for (const order of orders) {
|
||||||
|
const sim = simulateOrder(order, config, derived);
|
||||||
|
const bucketKey = bucketKeyFor(sim.orderValue);
|
||||||
|
const bucket = buckets.get(bucketKey);
|
||||||
|
accumulate(bucket, sim);
|
||||||
|
grandTotalProfit += sim.profit;
|
||||||
|
grandTotalRevenue += sim.totalRevenue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalOrders = orders.length;
|
||||||
|
const bucketResults = RANGE_DEFINITIONS.map((range) =>
|
||||||
|
finalizeBucket(buckets.get(range.key), totalOrders)
|
||||||
|
);
|
||||||
|
|
||||||
|
const weightedProfitAmount = totalOrders > 0 ? grandTotalProfit / totalOrders : 0;
|
||||||
|
const weightedProfitPercent = grandTotalRevenue > 0 ? grandTotalProfit / grandTotalRevenue : 0;
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
dateRange: {
|
dateRange: {
|
||||||
start: startDt.toISO(),
|
start: startDt.toISO(),
|
||||||
end: endDt.toISO()
|
end: endDt.toISO()
|
||||||
},
|
},
|
||||||
totals: {
|
totals: {
|
||||||
orders: totals.orders,
|
orders: totalOrders,
|
||||||
subtotal: totals.subtotal,
|
subtotal: totalSubtotal,
|
||||||
productDiscountRate,
|
productDiscountRate,
|
||||||
pointsPerDollar,
|
pointsPerDollar,
|
||||||
redemptionRate,
|
redemptionRate,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { DiscountPromoOption, DiscountPromoType, ShippingPromoType, ShippingTierConfig, SurchargeConfig, DiscountSimulationResponse, CogsCalculationMode } from "@/types/discount-simulator";
|
import { DiscountPromoOption, DiscountPromoType, ShippingPromoType, ShippingTierConfig, SurchargeConfig, DiscountSimulationResponse, CogsCalculationMode } from "@/types/discount-simulator";
|
||||||
import { formatNumber } from "@/utils/productUtils";
|
import { formatNumber } from "@/utils/productUtils";
|
||||||
import { PlusIcon, X } from "lucide-react";
|
import { PlusIcon, X } from "lucide-react";
|
||||||
@@ -43,6 +44,8 @@ interface ConfigPanelProps {
|
|||||||
onFixedCostChange: (value: number) => void;
|
onFixedCostChange: (value: number) => void;
|
||||||
cogsCalculationMode: CogsCalculationMode;
|
cogsCalculationMode: CogsCalculationMode;
|
||||||
onCogsCalculationModeChange: (mode: CogsCalculationMode) => void;
|
onCogsCalculationModeChange: (mode: CogsCalculationMode) => void;
|
||||||
|
applyHistoricalProductPromo: boolean;
|
||||||
|
onApplyHistoricalProductPromoChange: (value: boolean) => void;
|
||||||
pointsPerDollar: number;
|
pointsPerDollar: number;
|
||||||
redemptionRate: number;
|
redemptionRate: number;
|
||||||
onRedemptionRateChange: (value: number) => void;
|
onRedemptionRateChange: (value: number) => void;
|
||||||
@@ -58,6 +61,9 @@ interface ConfigPanelProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseNumber(value: string, fallback = 0) {
|
function parseNumber(value: string, fallback = 0) {
|
||||||
|
if (value.trim() === '') {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
const parsed = Number(value);
|
const parsed = Number(value);
|
||||||
return Number.isFinite(parsed) ? parsed : fallback;
|
return Number.isFinite(parsed) ? parsed : fallback;
|
||||||
}
|
}
|
||||||
@@ -113,6 +119,8 @@ export function ConfigPanel({
|
|||||||
onFixedCostChange,
|
onFixedCostChange,
|
||||||
cogsCalculationMode,
|
cogsCalculationMode,
|
||||||
onCogsCalculationModeChange,
|
onCogsCalculationModeChange,
|
||||||
|
applyHistoricalProductPromo,
|
||||||
|
onApplyHistoricalProductPromoChange,
|
||||||
pointsPerDollar,
|
pointsPerDollar,
|
||||||
redemptionRate,
|
redemptionRate,
|
||||||
onRedemptionRateChange,
|
onRedemptionRateChange,
|
||||||
@@ -480,6 +488,23 @@ export function ConfigPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="flex items-start justify-between gap-2 pt-1">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<Label className={labelClass}>Replay historical discount</Label>
|
||||||
|
<span className="text-[0.65rem] text-muted-foreground leading-snug">
|
||||||
|
Only when promo type is "No additional promo"
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={applyHistoricalProductPromo}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
onConfigInputChange();
|
||||||
|
onApplyHistoricalProductPromoChange(checked);
|
||||||
|
setTimeout(() => onRunSimulation(), 0);
|
||||||
|
}}
|
||||||
|
disabled={productPromo.type !== "none"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -826,11 +851,11 @@ export function ConfigPanel({
|
|||||||
<Input
|
<Input
|
||||||
className={compactNumberClass}
|
className={compactNumberClass}
|
||||||
type="number"
|
type="number"
|
||||||
step="1"
|
step="0.01"
|
||||||
value={Math.round(redemptionRate * 100)}
|
value={Number((redemptionRate * 100).toFixed(2))}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
onConfigInputChange();
|
onConfigInputChange();
|
||||||
onRedemptionRateChange(parseNumber(event.target.value, 90) / 100);
|
onRedemptionRateChange(parseNumber(event.target.value, redemptionRate * 100) / 100);
|
||||||
}}
|
}}
|
||||||
onBlur={handleFieldBlur}
|
onBlur={handleFieldBlur}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -67,12 +67,11 @@ export function ResultsChart({ buckets, isLoading }: ResultsChartProps) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the same format as the table - show top range value
|
|
||||||
const labels = buckets.map((bucket) => {
|
const labels = buckets.map((bucket) => {
|
||||||
if (bucket.max == null) {
|
if (bucket.max == null) {
|
||||||
return `$${bucket.min.toLocaleString()}+`;
|
return `$${bucket.min.toLocaleString()}+`;
|
||||||
}
|
}
|
||||||
return `$${bucket.max.toLocaleString()}`;
|
return `$${bucket.min.toLocaleString()}–$${bucket.max.toLocaleString()}`;
|
||||||
});
|
});
|
||||||
const profitPercentages = buckets.map((bucket) => Number((bucket.profitPercent * 100).toFixed(2)));
|
const profitPercentages = buckets.map((bucket) => Number((bucket.profitPercent * 100).toFixed(2)));
|
||||||
|
|
||||||
@@ -109,13 +108,28 @@ export function ResultsChart({ buckets, isLoading }: ResultsChartProps) {
|
|||||||
pointBorderColor: pointColors,
|
pointBorderColor: pointColors,
|
||||||
pointRadius: 6,
|
pointRadius: 6,
|
||||||
pointHoverRadius: 8,
|
pointHoverRadius: 8,
|
||||||
tension: 0.3,
|
tension: 0,
|
||||||
fill: false,
|
fill: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}, [buckets]);
|
}, [buckets]);
|
||||||
|
|
||||||
|
const yAxisBounds = useMemo(() => {
|
||||||
|
const populated = buckets.filter((bucket) => bucket.orderCount > 0);
|
||||||
|
if (populated.length === 0) {
|
||||||
|
return { min: 0, max: 50 };
|
||||||
|
}
|
||||||
|
const values = populated.map((bucket) => bucket.profitPercent * 100);
|
||||||
|
const rawMin = Math.min(...values, 0);
|
||||||
|
const rawMax = Math.max(...values, 0);
|
||||||
|
const span = Math.max(rawMax - rawMin, 5);
|
||||||
|
const pad = Math.max(span * 0.1, 1);
|
||||||
|
const min = Math.floor((rawMin - pad) / 5) * 5;
|
||||||
|
const max = Math.ceil((rawMax + pad) / 5) * 5;
|
||||||
|
return { min, max: Math.max(max, min + 5) };
|
||||||
|
}, [buckets]);
|
||||||
|
|
||||||
const options = useMemo(() => ({
|
const options = useMemo(() => ({
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
@@ -157,8 +171,8 @@ export function ResultsChart({ buckets, isLoading }: ResultsChartProps) {
|
|||||||
type: 'linear' as const,
|
type: 'linear' as const,
|
||||||
display: true,
|
display: true,
|
||||||
position: 'left' as const,
|
position: 'left' as const,
|
||||||
min: 0,
|
min: yAxisBounds.min,
|
||||||
max: 50,
|
max: yAxisBounds.max,
|
||||||
ticks: {
|
ticks: {
|
||||||
stepSize: 5,
|
stepSize: 5,
|
||||||
callback: (value: number | string) => `${Number(value).toFixed(0)}`,
|
callback: (value: number | string) => `${Number(value).toFixed(0)}`,
|
||||||
@@ -177,7 +191,7 @@ export function ResultsChart({ buckets, isLoading }: ResultsChartProps) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}), [buckets]);
|
}), [buckets, yAxisBounds]);
|
||||||
|
|
||||||
if (isLoading && !chartData) {
|
if (isLoading && !chartData) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -145,10 +145,10 @@ const rowLabels: RowConfig[] = [
|
|||||||
|
|
||||||
const formatRangeUpperBound = (bucket: DiscountSimulationBucket) => {
|
const formatRangeUpperBound = (bucket: DiscountSimulationBucket) => {
|
||||||
if (bucket.max == null) {
|
if (bucket.max == null) {
|
||||||
return `${formatCurrency(bucket.min)}+`;
|
return `${formatCurrency(bucket.min, 0)}+`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return formatCurrency(bucket.max);
|
return `${formatCurrency(bucket.min, 0)}–${formatCurrency(bucket.max, 0)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ResultsTable({ buckets, isLoading }: ResultsTableProps) {
|
export function ResultsTable({ buckets, isLoading }: ResultsTableProps) {
|
||||||
@@ -173,7 +173,7 @@ export function ResultsTable({ buckets, isLoading }: ResultsTableProps) {
|
|||||||
<colgroup>
|
<colgroup>
|
||||||
<col style={{ width: '156px' }} />
|
<col style={{ width: '156px' }} />
|
||||||
{buckets.map((bucket) => (
|
{buckets.map((bucket) => (
|
||||||
<col key={bucket.key} style={{ minWidth: '70px', width: '75px' }} />
|
<col key={bucket.key} style={{ minWidth: '95px', width: '110px' }} />
|
||||||
))}
|
))}
|
||||||
</colgroup>
|
</colgroup>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ interface SummaryCardProps {
|
|||||||
onClearBaseline?: () => void;
|
onClearBaseline?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rangesMatch(a: DiscountSimulationResponse, b: DiscountSimulationResponse): boolean {
|
||||||
|
return a.dateRange.start === b.dateRange.start && a.dateRange.end === b.dateRange.end;
|
||||||
|
}
|
||||||
|
|
||||||
function calculateAnnualizedProfitDiff(
|
function calculateAnnualizedProfitDiff(
|
||||||
current: DiscountSimulationResponse,
|
current: DiscountSimulationResponse,
|
||||||
baseline: DiscountSimulationResponse
|
baseline: DiscountSimulationResponse
|
||||||
@@ -65,6 +69,9 @@ function calculateAnnualizedProfitDiff(
|
|||||||
const baselineTotals = baseline.totals;
|
const baselineTotals = baseline.totals;
|
||||||
|
|
||||||
if (!currentTotals || !baselineTotals) return null;
|
if (!currentTotals || !baselineTotals) return null;
|
||||||
|
if (!Number.isFinite(currentTotals.weightedProfitAmount) || !Number.isFinite(baselineTotals.weightedProfitAmount)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate days in the current simulation period
|
// Calculate days in the current simulation period
|
||||||
const startDate = new Date(current.dateRange.start);
|
const startDate = new Date(current.dateRange.start);
|
||||||
@@ -118,7 +125,9 @@ export function SummaryCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const totals = result?.totals;
|
const totals = result?.totals;
|
||||||
const weightedProfitAmount = totals ? formatCurrency(totals.weightedProfitAmount) : '—';
|
const weightedProfitAmount = totals && Number.isFinite(totals.weightedProfitAmount)
|
||||||
|
? formatCurrency(totals.weightedProfitAmount)
|
||||||
|
: '—';
|
||||||
const weightedProfitPercent = totals ? formatPercent(totals.weightedProfitPercent) : '—';
|
const weightedProfitPercent = totals ? formatPercent(totals.weightedProfitPercent) : '—';
|
||||||
|
|
||||||
// Get color for profit percentage
|
// Get color for profit percentage
|
||||||
@@ -130,8 +139,9 @@ export function SummaryCard({
|
|||||||
: "destructive"
|
: "destructive"
|
||||||
: "secondary";
|
: "secondary";
|
||||||
|
|
||||||
// Calculate annualized profit difference if baseline exists
|
const baselineScopeMatches = !!(result && baselineResult && rangesMatch(result, baselineResult));
|
||||||
const annualizedDiff = result && baselineResult
|
// Calculate annualized profit difference if baseline exists and scope matches
|
||||||
|
const annualizedDiff = result && baselineResult && baselineScopeMatches
|
||||||
? calculateAnnualizedProfitDiff(result, baselineResult)
|
? calculateAnnualizedProfitDiff(result, baselineResult)
|
||||||
: null;
|
: null;
|
||||||
const hasBaseline = !!baselineResult;
|
const hasBaseline = !!baselineResult;
|
||||||
@@ -194,6 +204,27 @@ export function SummaryCard({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{hasBaseline && !baselineScopeMatches && (
|
||||||
|
<>
|
||||||
|
<Separator orientation="vertical" className="h-12" />
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm text-muted-foreground mb-2">Baseline</p>
|
||||||
|
<span className="inline-block px-3 py-1.5 rounded bg-secondary text-amber-700 text-sm">
|
||||||
|
Date range changed — clear or restore to compare
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={onClearBaseline}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3 mr-1" />
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Save as baseline button */}
|
{/* Save as baseline button */}
|
||||||
{result && !hasBaseline && (
|
{result && !hasBaseline && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export function DiscountSimulator() {
|
|||||||
const [merchantFeePercent, setMerchantFeePercent] = useState(DEFAULT_MERCHANT_FEE);
|
const [merchantFeePercent, setMerchantFeePercent] = useState(DEFAULT_MERCHANT_FEE);
|
||||||
const [fixedCostPerOrder, setFixedCostPerOrder] = useState(DEFAULT_FIXED_COST);
|
const [fixedCostPerOrder, setFixedCostPerOrder] = useState(DEFAULT_FIXED_COST);
|
||||||
const [cogsCalculationMode, setCogsCalculationMode] = useState<CogsCalculationMode>('actual');
|
const [cogsCalculationMode, setCogsCalculationMode] = useState<CogsCalculationMode>('actual');
|
||||||
|
const [applyHistoricalProductPromo, setApplyHistoricalProductPromo] = useState(false);
|
||||||
const [pointDollarValue, setPointDollarValue] = useState(DEFAULT_POINT_VALUE);
|
const [pointDollarValue, setPointDollarValue] = useState(DEFAULT_POINT_VALUE);
|
||||||
const [pointDollarTouched, setPointDollarTouched] = useState(false);
|
const [pointDollarTouched, setPointDollarTouched] = useState(false);
|
||||||
const [redemptionRate, setRedemptionRate] = useState(DEFAULT_REDEMPTION_RATE);
|
const [redemptionRate, setRedemptionRate] = useState(DEFAULT_REDEMPTION_RATE);
|
||||||
@@ -70,7 +71,6 @@ export function DiscountSimulator() {
|
|||||||
const [isSimulating, setIsSimulating] = useState(false);
|
const [isSimulating, setIsSimulating] = useState(false);
|
||||||
const [hasLoadedConfig, setHasLoadedConfig] = useState(false);
|
const [hasLoadedConfig, setHasLoadedConfig] = useState(false);
|
||||||
const [loadedFromStorage, setLoadedFromStorage] = useState(false);
|
const [loadedFromStorage, setLoadedFromStorage] = useState(false);
|
||||||
const initialRunRef = useRef(false);
|
|
||||||
const skipAutoRunRef = useRef(false);
|
const skipAutoRunRef = useRef(false);
|
||||||
const latestPayloadKeyRef = useRef('');
|
const latestPayloadKeyRef = useRef('');
|
||||||
const pendingCountRef = useRef(0);
|
const pendingCountRef = useRef(0);
|
||||||
@@ -127,13 +127,6 @@ export function DiscountSimulator() {
|
|||||||
|
|
||||||
const promosLoading = !promosQuery.data && promosQuery.isLoading;
|
const promosLoading = !promosQuery.data && promosQuery.isLoading;
|
||||||
|
|
||||||
const selectedPromoCode = useMemo(() => {
|
|
||||||
if (selectedPromoId == null) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return promosQuery.data?.find((promo) => promo.id === selectedPromoId)?.code || undefined;
|
|
||||||
}, [promosQuery.data, selectedPromoId]);
|
|
||||||
|
|
||||||
const createPayload = useCallback((): DiscountSimulationRequest => {
|
const createPayload = useCallback((): DiscountSimulationRequest => {
|
||||||
const { from, to } = ensureDateRange(dateRange);
|
const { from, to } = ensureDateRange(dateRange);
|
||||||
|
|
||||||
@@ -151,7 +144,6 @@ export function DiscountSimulator() {
|
|||||||
filters: {
|
filters: {
|
||||||
shipCountry: 'US',
|
shipCountry: 'US',
|
||||||
promoIds: selectedPromoId ? [selectedPromoId] : undefined,
|
promoIds: selectedPromoId ? [selectedPromoId] : undefined,
|
||||||
promoCodes: selectedPromoCode ? [selectedPromoCode] : undefined,
|
|
||||||
},
|
},
|
||||||
productPromo,
|
productPromo,
|
||||||
shippingPromo,
|
shippingPromo,
|
||||||
@@ -168,12 +160,12 @@ export function DiscountSimulator() {
|
|||||||
merchantFeePercent,
|
merchantFeePercent,
|
||||||
fixedCostPerOrder,
|
fixedCostPerOrder,
|
||||||
cogsCalculationMode,
|
cogsCalculationMode,
|
||||||
|
applyHistoricalProductPromo,
|
||||||
pointsConfig: payloadPointsConfig,
|
pointsConfig: payloadPointsConfig,
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
dateRange,
|
dateRange,
|
||||||
selectedPromoId,
|
selectedPromoId,
|
||||||
selectedPromoCode,
|
|
||||||
productPromo,
|
productPromo,
|
||||||
shippingPromo,
|
shippingPromo,
|
||||||
shippingTiers,
|
shippingTiers,
|
||||||
@@ -181,6 +173,7 @@ export function DiscountSimulator() {
|
|||||||
merchantFeePercent,
|
merchantFeePercent,
|
||||||
fixedCostPerOrder,
|
fixedCostPerOrder,
|
||||||
cogsCalculationMode,
|
cogsCalculationMode,
|
||||||
|
applyHistoricalProductPromo,
|
||||||
pointDollarValue,
|
pointDollarValue,
|
||||||
redemptionRate,
|
redemptionRate,
|
||||||
]);
|
]);
|
||||||
@@ -264,6 +257,7 @@ export function DiscountSimulator() {
|
|||||||
merchantFeePercent?: number;
|
merchantFeePercent?: number;
|
||||||
fixedCostPerOrder?: number;
|
fixedCostPerOrder?: number;
|
||||||
cogsCalculationMode?: CogsCalculationMode;
|
cogsCalculationMode?: CogsCalculationMode;
|
||||||
|
applyHistoricalProductPromo?: boolean;
|
||||||
pointsConfig?: {
|
pointsConfig?: {
|
||||||
pointsPerDollar?: number | null;
|
pointsPerDollar?: number | null;
|
||||||
redemptionRate?: number | null;
|
redemptionRate?: number | null;
|
||||||
@@ -319,6 +313,10 @@ export function DiscountSimulator() {
|
|||||||
setCogsCalculationMode(parsed.cogsCalculationMode);
|
setCogsCalculationMode(parsed.cogsCalculationMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof parsed.applyHistoricalProductPromo === 'boolean') {
|
||||||
|
setApplyHistoricalProductPromo(parsed.applyHistoricalProductPromo);
|
||||||
|
}
|
||||||
|
|
||||||
if (parsed.pointsConfig && typeof parsed.pointsConfig.pointDollarValue === 'number') {
|
if (parsed.pointsConfig && typeof parsed.pointsConfig.pointDollarValue === 'number') {
|
||||||
setPointDollarValue(parsed.pointsConfig.pointDollarValue);
|
setPointDollarValue(parsed.pointsConfig.pointDollarValue);
|
||||||
setPointDollarTouched(true);
|
setPointDollarTouched(true);
|
||||||
@@ -361,10 +359,11 @@ export function DiscountSimulator() {
|
|||||||
merchantFeePercent,
|
merchantFeePercent,
|
||||||
fixedCostPerOrder,
|
fixedCostPerOrder,
|
||||||
cogsCalculationMode,
|
cogsCalculationMode,
|
||||||
|
applyHistoricalProductPromo,
|
||||||
pointDollarValue,
|
pointDollarValue,
|
||||||
redemptionRate,
|
redemptionRate,
|
||||||
});
|
});
|
||||||
}, [dateRange, selectedPromoId, productPromo, shippingPromo, shippingTiers, surcharges, merchantFeePercent, fixedCostPerOrder, cogsCalculationMode, pointDollarValue, redemptionRate]);
|
}, [dateRange, selectedPromoId, productPromo, shippingPromo, shippingTiers, surcharges, merchantFeePercent, fixedCostPerOrder, cogsCalculationMode, applyHistoricalProductPromo, pointDollarValue, redemptionRate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasLoadedConfig) {
|
if (!hasLoadedConfig) {
|
||||||
@@ -379,10 +378,6 @@ export function DiscountSimulator() {
|
|||||||
}, [serializedConfig, hasLoadedConfig]);
|
}, [serializedConfig, hasLoadedConfig]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!initialRunRef.current) {
|
|
||||||
initialRunRef.current = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (skipAutoRunRef.current) {
|
if (skipAutoRunRef.current) {
|
||||||
skipAutoRunRef.current = false;
|
skipAutoRunRef.current = false;
|
||||||
return;
|
return;
|
||||||
@@ -448,6 +443,7 @@ export function DiscountSimulator() {
|
|||||||
setMerchantFeePercent(DEFAULT_MERCHANT_FEE);
|
setMerchantFeePercent(DEFAULT_MERCHANT_FEE);
|
||||||
setFixedCostPerOrder(DEFAULT_FIXED_COST);
|
setFixedCostPerOrder(DEFAULT_FIXED_COST);
|
||||||
setCogsCalculationMode('actual');
|
setCogsCalculationMode('actual');
|
||||||
|
setApplyHistoricalProductPromo(false);
|
||||||
setPointDollarValue(DEFAULT_POINT_VALUE);
|
setPointDollarValue(DEFAULT_POINT_VALUE);
|
||||||
setPointDollarTouched(false);
|
setPointDollarTouched(false);
|
||||||
setRedemptionRate(DEFAULT_REDEMPTION_RATE);
|
setRedemptionRate(DEFAULT_REDEMPTION_RATE);
|
||||||
@@ -499,6 +495,8 @@ export function DiscountSimulator() {
|
|||||||
onFixedCostChange={setFixedCostPerOrder}
|
onFixedCostChange={setFixedCostPerOrder}
|
||||||
cogsCalculationMode={cogsCalculationMode}
|
cogsCalculationMode={cogsCalculationMode}
|
||||||
onCogsCalculationModeChange={setCogsCalculationMode}
|
onCogsCalculationModeChange={setCogsCalculationMode}
|
||||||
|
applyHistoricalProductPromo={applyHistoricalProductPromo}
|
||||||
|
onApplyHistoricalProductPromoChange={setApplyHistoricalProductPromo}
|
||||||
pointsPerDollar={currentPointsPerDollar}
|
pointsPerDollar={currentPointsPerDollar}
|
||||||
redemptionRate={redemptionRate}
|
redemptionRate={redemptionRate}
|
||||||
onRedemptionRateChange={setRedemptionRate}
|
onRedemptionRateChange={setRedemptionRate}
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ export interface DiscountSimulationRequest {
|
|||||||
merchantFeePercent: number;
|
merchantFeePercent: number;
|
||||||
fixedCostPerOrder: number;
|
fixedCostPerOrder: number;
|
||||||
cogsCalculationMode: CogsCalculationMode;
|
cogsCalculationMode: CogsCalculationMode;
|
||||||
|
applyHistoricalProductPromo: boolean;
|
||||||
pointsConfig: {
|
pointsConfig: {
|
||||||
pointsPerDollar: number | null;
|
pointsPerDollar: number | null;
|
||||||
redemptionRate: number | null;
|
redemptionRate: number | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user