496 lines
16 KiB
JavaScript
496 lines
16 KiB
JavaScript
const express = require('express');
|
|
const { DateTime } = require('luxon');
|
|
const { getDbConnection } = require('../db/connection');
|
|
|
|
const router = express.Router();
|
|
|
|
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
|
|
];
|
|
|
|
const FINAL_BUCKET_KEY = 'PLUS';
|
|
|
|
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,
|
|
key,
|
|
sort: bound
|
|
});
|
|
previous = bound;
|
|
}
|
|
// Remove the 2000+ category - all orders >2000 will go into the 2000 bucket
|
|
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}'`);
|
|
}
|
|
}
|
|
return `CASE\n ${parts.join('\n ')}\n END`;
|
|
})();
|
|
|
|
const DEFAULT_POINT_DOLLAR_VALUE = 0.005; // 1000 points = $5
|
|
|
|
const DEFAULTS = {
|
|
merchantFeePercent: 2.9,
|
|
fixedCostPerOrder: 1.5,
|
|
pointsPerDollar: 0,
|
|
pointsRedemptionRate: 0,
|
|
pointDollarValue: DEFAULT_POINT_DOLLAR_VALUE,
|
|
};
|
|
|
|
function parseDate(value, fallback) {
|
|
if (!value) {
|
|
return fallback;
|
|
}
|
|
const parsed = DateTime.fromISO(value);
|
|
if (!parsed.isValid) {
|
|
return fallback;
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
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 {
|
|
const { connection: conn, release } = await getDbConnection();
|
|
connection = conn;
|
|
const releaseConnection = release;
|
|
|
|
const { startDate, endDate } = req.query || {};
|
|
const now = DateTime.now().endOf('day');
|
|
const defaultStart = now.minus({ years: 3 }).startOf('day');
|
|
|
|
const parsedStart = startDate ? parseDate(startDate, defaultStart).startOf('day') : defaultStart;
|
|
const parsedEnd = endDate ? parseDate(endDate, now).endOf('day') : now;
|
|
|
|
const rangeStart = parsedStart <= parsedEnd ? parsedStart : parsedEnd;
|
|
const rangeEnd = parsedEnd >= parsedStart ? parsedEnd : parsedStart;
|
|
|
|
const rangeStartSql = formatDateForSql(rangeStart);
|
|
const rangeEndSql = formatDateForSql(rangeEnd);
|
|
|
|
const sql = `
|
|
SELECT
|
|
p.promo_id AS id,
|
|
p.promo_code AS code,
|
|
p.promo_description_online AS description_online,
|
|
p.promo_description_private AS description_private,
|
|
p.date_start,
|
|
p.date_end,
|
|
COALESCE(u.usage_count, 0) AS usage_count
|
|
FROM promos p
|
|
LEFT JOIN (
|
|
SELECT
|
|
discount_code,
|
|
COUNT(DISTINCT order_id) AS usage_count
|
|
FROM order_discounts
|
|
WHERE discount_type = 10 AND discount_active = 1
|
|
GROUP BY discount_code
|
|
) u ON u.discount_code = p.promo_id
|
|
WHERE p.date_start IS NOT NULL
|
|
AND p.date_end IS NOT NULL
|
|
AND NOT (p.date_end < ? OR p.date_start > ?)
|
|
AND p.store = 1
|
|
AND p.date_start >= '2010-01-01'
|
|
ORDER BY p.promo_id DESC
|
|
LIMIT 200
|
|
`;
|
|
|
|
const [rows] = await connection.execute(sql, [rangeStartSql, rangeEndSql]);
|
|
releaseConnection();
|
|
|
|
const promos = rows.map(row => ({
|
|
id: Number(row.id),
|
|
code: row.code,
|
|
description: row.description_online || row.description_private || '',
|
|
privateDescription: row.description_private || '',
|
|
promo_description_online: row.description_online || '',
|
|
promo_description_private: row.description_private || '',
|
|
dateStart: row.date_start,
|
|
dateEnd: row.date_end,
|
|
usageCount: Number(row.usage_count || 0)
|
|
}));
|
|
|
|
res.json({ promos });
|
|
} catch (error) {
|
|
if (connection) {
|
|
try {
|
|
connection.destroy();
|
|
} catch (destroyError) {
|
|
console.error('Failed to destroy connection after error:', destroyError);
|
|
}
|
|
}
|
|
console.error('Error fetching promos:', error);
|
|
res.status(500).json({ error: 'Failed to fetch promos' });
|
|
}
|
|
});
|
|
|
|
router.post('/simulate', async (req, res) => {
|
|
const {
|
|
dateRange = {},
|
|
filters = {},
|
|
productPromo = {},
|
|
shippingPromo = {},
|
|
shippingTiers = [],
|
|
merchantFeePercent,
|
|
fixedCostPerOrder,
|
|
pointsConfig = {}
|
|
} = req.body || {};
|
|
|
|
const endDefault = DateTime.now();
|
|
const startDefault = endDefault.minus({ months: 6 });
|
|
const startDt = parseDate(dateRange.start, startDefault).startOf('day');
|
|
const endDt = parseDate(dateRange.end, endDefault).endOf('day');
|
|
|
|
const shipCountry = filters.shipCountry || 'US';
|
|
const promoIds = Array.isArray(filters.promoIds) ? filters.promoIds.filter(Boolean) : [];
|
|
|
|
const config = {
|
|
merchantFeePercent: typeof merchantFeePercent === 'number' ? merchantFeePercent : DEFAULTS.merchantFeePercent,
|
|
fixedCostPerOrder: typeof fixedCostPerOrder === 'number' ? fixedCostPerOrder : DEFAULTS.fixedCostPerOrder,
|
|
productPromo: {
|
|
type: productPromo.type || 'none',
|
|
value: Number(productPromo.value || 0),
|
|
minSubtotal: Number(productPromo.minSubtotal || 0)
|
|
},
|
|
shippingPromo: {
|
|
type: shippingPromo.type || 'none',
|
|
value: Number(shippingPromo.value || 0),
|
|
minSubtotal: Number(shippingPromo.minSubtotal || 0),
|
|
maxDiscount: Number(shippingPromo.maxDiscount || 0)
|
|
},
|
|
shippingTiers: Array.isArray(shippingTiers)
|
|
? shippingTiers
|
|
.map(tier => ({
|
|
threshold: Number(tier.threshold || 0),
|
|
mode: tier.mode === 'percentage' || tier.mode === 'flat' ? tier.mode : 'percentage',
|
|
value: Number(tier.value || 0)
|
|
}))
|
|
.filter(tier => tier.threshold >= 0 && tier.value >= 0)
|
|
.sort((a, b) => a.threshold - b.threshold)
|
|
: [],
|
|
points: {
|
|
pointsPerDollar: typeof pointsConfig.pointsPerDollar === 'number' ? pointsConfig.pointsPerDollar : null,
|
|
redemptionRate: typeof pointsConfig.redemptionRate === 'number' ? pointsConfig.redemptionRate : null,
|
|
pointDollarValue: typeof pointsConfig.pointDollarValue === 'number'
|
|
? pointsConfig.pointDollarValue
|
|
: DEFAULT_POINT_DOLLAR_VALUE
|
|
}
|
|
};
|
|
|
|
let connection;
|
|
let release;
|
|
|
|
try {
|
|
const dbConn = await getDbConnection();
|
|
connection = dbConn.connection;
|
|
release = dbConn.release;
|
|
|
|
const params = [
|
|
shipCountry,
|
|
formatDateForSql(startDt),
|
|
formatDateForSql(endDt)
|
|
];
|
|
const promoJoin = promoIds.length > 0
|
|
? 'JOIN order_discounts od ON od.order_id = o.order_id AND od.discount_active = 1 AND od.discount_type = 10'
|
|
: '';
|
|
|
|
if (promoIds.length > 0) {
|
|
params.push(promoIds);
|
|
}
|
|
params.push(formatDateForSql(startDt), formatDateForSql(endDt));
|
|
|
|
const filteredOrdersQuery = `
|
|
SELECT
|
|
o.order_id,
|
|
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
|
|
FROM _order o
|
|
${promoJoin}
|
|
WHERE o.summary_total > 0
|
|
AND o.summary_subtotal > 0
|
|
AND o.order_status NOT IN (15)
|
|
AND o.ship_method_selected <> 'holdit'
|
|
AND o.ship_country = ?
|
|
AND o.date_placed BETWEEN ? AND ?
|
|
${promoIds.length > 0 ? 'AND od.discount_code IN (?)' : ''}
|
|
`;
|
|
|
|
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
|
|
LEFT JOIN (
|
|
SELECT order_id, SUM(discount_amount_points) 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
|
|
`;
|
|
|
|
const [rows] = await connection.execute(bucketQuery, 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 redemptionRate = config.points.redemptionRate != null
|
|
? config.points.redemptionRate
|
|
: totals.pointsAwarded > 0
|
|
? Math.min(1, totals.pointsRedeemed / totals.pointsAwarded)
|
|
: 0;
|
|
|
|
const pointDollarValue = config.points.pointDollarValue || DEFAULT_POINT_DOLLAR_VALUE;
|
|
|
|
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.avgShipRate > 0 ? data.avgShipRate : 0;
|
|
const actualShippingCost = data.avgShipCost > 0 ? data.avgShipCost : 0;
|
|
const 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);
|
|
}
|
|
|
|
const customerShipCost = Math.max(0, shippingAfterAuto - shipPromoDiscount);
|
|
const customerItemCost = Math.max(0, orderValue - promoProductDiscount);
|
|
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,
|
|
customerShipCost,
|
|
actualShippingCost,
|
|
totalRevenue,
|
|
productCogs,
|
|
merchantFees,
|
|
pointsCost,
|
|
fixedCosts,
|
|
totalCosts,
|
|
profit,
|
|
profitPercent
|
|
});
|
|
}
|
|
|
|
if (release) {
|
|
release();
|
|
}
|
|
|
|
res.json({
|
|
dateRange: {
|
|
start: startDt.toISO(),
|
|
end: endDt.toISO()
|
|
},
|
|
totals: {
|
|
orders: totals.orders,
|
|
subtotal: totals.subtotal,
|
|
productDiscountRate,
|
|
pointsPerDollar,
|
|
redemptionRate,
|
|
pointDollarValue,
|
|
weightedProfitAmount,
|
|
weightedProfitPercent
|
|
},
|
|
buckets: bucketResults
|
|
});
|
|
} catch (error) {
|
|
if (release) {
|
|
try {
|
|
release();
|
|
} catch (releaseError) {
|
|
console.error('Failed to release connection after error:', releaseError);
|
|
}
|
|
} else if (connection) {
|
|
try {
|
|
connection.destroy();
|
|
} catch (destroyError) {
|
|
console.error('Failed to destroy connection after error:', destroyError);
|
|
}
|
|
}
|
|
|
|
console.error('Error running discount simulation:', error);
|
|
res.status(500).json({ error: 'Failed to run discount simulation' });
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|