diff --git a/inventory-server/dashboard/acot-server/routes/discounts.js b/inventory-server/dashboard/acot-server/routes/discounts.js index efaebdd..522f196 100644 --- a/inventory-server/dashboard/acot-server/routes/discounts.js +++ b/inventory-server/dashboard/acot-server/routes/discounts.js @@ -4,57 +4,54 @@ const { getDbConnection } = require('../db/connection'); 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 = [ 10, 20, 30, 40, 50, 60, 70, 80, 90, 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() { const ranges = []; let previous = 0; for (const bound of RANGE_BOUNDS) { - const label = `$${previous.toLocaleString()} - $${bound.toLocaleString()}`; const key = bound.toString().padStart(5, '0'); ranges.push({ min: previous, max: bound, - label, + label: `$${previous.toLocaleString()} - $${bound.toLocaleString()}`, key, - sort: 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; } const RANGE_DEFINITIONS = buildRangeDefinitions(); -const BUCKET_CASE = (() => { - const parts = []; - for (let i = 0; i < RANGE_BOUNDS.length; i++) { - const bound = RANGE_BOUNDS[i]; - 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}'`); - } +function bucketKeyFor(subtotal) { + for (const range of RANGE_DEFINITIONS) { + if (range.max == null) return range.key; + if (subtotal <= range.max) return range.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 = { merchantFeePercent: 2.9, - fixedCostPerOrder: 1.5, - pointsPerDollar: 0, - pointsRedemptionRate: 0, // Will be calculated from actual data + fixedCostPerOrder: 1.25, pointDollarValue: DEFAULT_POINT_DOLLAR_VALUE, }; @@ -73,13 +70,6 @@ function formatDateForSql(dt) { 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) => { let connection; try { @@ -101,7 +91,7 @@ router.get('/promos', async (req, res) => { const rangeEndSql = formatDateForSql(rangeEnd); const sql = ` - SELECT + SELECT p.promo_id AS id, p.promo_code AS code, 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 FROM promos p LEFT JOIN ( - SELECT - discount_code, + SELECT + discount_code, COUNT(DISTINCT order_id) AS usage_count FROM order_discounts 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) => { const { dateRange = {}, @@ -167,6 +339,7 @@ router.post('/simulate', async (req, res) => { merchantFeePercent, fixedCostPerOrder, cogsCalculationMode = 'actual', + applyHistoricalProductPromo = false, pointsConfig = {} } = req.body || {}; @@ -176,20 +349,15 @@ router.post('/simulate', async (req, res) => { const endDt = parseDate(dateRange.end, endDefault).endOf('day'); const shipCountry = filters.shipCountry || 'US'; - const rawPromoFilters = [ - ...(Array.isArray(filters.promoIds) ? filters.promoIds : []), - ...(Array.isArray(filters.promoCodes) ? filters.promoCodes : []), - ]; - const promoCodes = Array.from( + const promoIds = Array.from( new Set( - rawPromoFilters + [ + ...(Array.isArray(filters.promoIds) ? filters.promoIds : []), + ...(Array.isArray(filters.promoCodes) ? filters.promoCodes : []), + ] .map((value) => { - if (typeof value === 'string') { - return value.trim(); - } - if (typeof value === 'number') { - return String(value); - } + if (typeof value === 'string') return value.trim(); + if (typeof value === 'number') return String(value); return ''; }) .filter((value) => value.length > 0) @@ -199,6 +367,8 @@ router.post('/simulate', async (req, res) => { const config = { merchantFeePercent: typeof merchantFeePercent === 'number' ? merchantFeePercent : DEFAULTS.merchantFeePercent, fixedCostPerOrder: typeof fixedCostPerOrder === 'number' ? fixedCostPerOrder : DEFAULTS.fixedCostPerOrder, + cogsCalculationMode, + applyHistoricalProductPromo: applyHistoricalProductPromo === true, productPromo: { type: productPromo.type || 'none', value: Number(productPromo.value || 0), @@ -248,300 +418,131 @@ router.post('/simulate', async (req, res) => { connection = dbConn.connection; release = dbConn.release; - const filteredOrdersParams = [ - shipCountry, - formatDateForSql(startDt), - formatDateForSql(endDt) - ]; - const promoJoin = promoCodes.length > 0 - ? 'JOIN order_discounts od ON od.order_id = o.order_id AND od.discount_active = 1 AND od.discount_type = 10' - : ''; - - let promoFilterClause = ''; - if (promoCodes.length > 0) { - const placeholders = promoCodes.map(() => '?').join(','); - promoFilterClause = `AND od.discount_code IN (${placeholders})`; - filteredOrdersParams.push(...promoCodes); + const params = [shipCountry, formatDateForSql(startDt), formatDateForSql(endDt)]; + let promoExistsClause = ''; + if (promoIds.length > 0) { + const placeholders = promoIds.map(() => '?').join(','); + promoExistsClause = ` + AND EXISTS ( + SELECT 1 FROM order_discounts od + WHERE od.order_id = o.order_id + AND od.discount_active = 1 + AND od.discount_type = 10 + AND od.discount_code IN (${placeholders}) + ) + `; + params.push(...promoIds); } - const filteredOrdersQuery = ` + const ordersQuery = ` SELECT o.order_id, - o.order_cid, o.summary_subtotal, - o.summary_discount_subtotal, - o.summary_shipping, - o.ship_method_rate, - o.ship_method_cost, - o.summary_points, - ${BUCKET_CASE} AS bucket_key + COALESCE(o.summary_subtotal_retail, o.summary_subtotal) AS summary_subtotal_retail, + COALESCE(o.summary_discount_subtotal, 0) AS summary_discount_subtotal, + COALESCE(o.summary_shipping, 0) AS summary_shipping, + COALESCE(o.summary_shipping_rush, 0) AS summary_shipping_rush, + COALESCE(o.ship_method_cost, 0) AS ship_method_cost, + 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 - ${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 ( SELECT order_id, SUM(cogs_amount) AS total_cogs FROM report_sales_data WHERE action IN (1,2,3) - AND date_change BETWEEN ? AND ? GROUP BY order_id - ) AS c ON c.order_id = f.order_id + ) c ON c.order_id = o.order_id LEFT JOIN ( - SELECT order_id, SUM(discount_amount) AS points_redeemed + SELECT order_id, SUM(discount_amount_subtotal) AS points_redeemed FROM order_discounts WHERE discount_type = 20 AND discount_active = 1 GROUP BY order_id - ) AS p ON p.order_id = f.order_id - GROUP BY f.bucket_key + ) p ON p.order_id = o.order_id + 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 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 - }); - } + const [orders] = await connection.execute(ordersQuery, params); if (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({ dateRange: { start: startDt.toISO(), end: endDt.toISO() }, totals: { - orders: totals.orders, - subtotal: totals.subtotal, + orders: totalOrders, + subtotal: totalSubtotal, productDiscountRate, pointsPerDollar, redemptionRate, diff --git a/inventory/src/components/discount-simulator/ConfigPanel.tsx b/inventory/src/components/discount-simulator/ConfigPanel.tsx index 3fecf44..75350e9 100644 --- a/inventory/src/components/discount-simulator/ConfigPanel.tsx +++ b/inventory/src/components/discount-simulator/ConfigPanel.tsx @@ -8,6 +8,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; 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 { formatNumber } from "@/utils/productUtils"; import { PlusIcon, X } from "lucide-react"; @@ -43,6 +44,8 @@ interface ConfigPanelProps { onFixedCostChange: (value: number) => void; cogsCalculationMode: CogsCalculationMode; onCogsCalculationModeChange: (mode: CogsCalculationMode) => void; + applyHistoricalProductPromo: boolean; + onApplyHistoricalProductPromoChange: (value: boolean) => void; pointsPerDollar: number; redemptionRate: number; onRedemptionRateChange: (value: number) => void; @@ -58,6 +61,9 @@ interface ConfigPanelProps { } function parseNumber(value: string, fallback = 0) { + if (value.trim() === '') { + return fallback; + } const parsed = Number(value); return Number.isFinite(parsed) ? parsed : fallback; } @@ -113,6 +119,8 @@ export function ConfigPanel({ onFixedCostChange, cogsCalculationMode, onCogsCalculationModeChange, + applyHistoricalProductPromo, + onApplyHistoricalProductPromoChange, pointsPerDollar, redemptionRate, onRedemptionRateChange, @@ -480,6 +488,23 @@ export function ConfigPanel({ )} +
Baseline
+ + Date range changed — clear or restore to compare + +