const express = require('express'); const jianSunDb = require('../config/jianSunDatabase'); const middleDb = require('../config/middlePlatformDatabase'); const router = express.Router(); const MAX_PAGE_SIZE = 100; function safeInt(value, fallback) { const parsed = Number.parseInt(value, 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; } function toNum(value) { const n = Number(value); return Number.isFinite(n) ? n : 0; } function fmtMoney(value) { const n = Number(value); return Number.isFinite(n) ? n.toFixed(2) : '-'; } function fmtRate(value) { const n = Number(value); return Number.isFinite(n) ? (n * 100).toFixed(2) : '-'; } function fmtNum(value) { const n = Number(value); return Number.isFinite(n) ? n.toLocaleString('zh-CN') : '-'; } function getDateRange(period, customStart, customEnd) { const now = new Date(); const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); switch (period) { case 'realtime': { const d = new Date(today); return { start: formatDate(d), end: formatDate(d) }; } case 'yesterday': { const d = new Date(today); d.setDate(d.getDate() - 1); return { start: formatDate(d), end: formatDate(d) }; } case 'week': { const d = new Date(today); d.setDate(d.getDate() - d.getDay() + (d.getDay() === 0 ? -6 : 1)); return { start: formatDate(d), end: formatDate(today) }; } case 'month': { const d = new Date(today.getFullYear(), today.getMonth(), 1); return { start: formatDate(d), end: formatDate(today) }; } case 'last7d': { const d = new Date(today); d.setDate(d.getDate() - 6); return { start: formatDate(d), end: formatDate(today) }; } case 'last30d': { const d = new Date(today); d.setDate(d.getDate() - 29); return { start: formatDate(d), end: formatDate(today) }; } case 'custom': { return { start: customStart || formatDate(today), end: customEnd || formatDate(today), }; } default: { const d = new Date(today); d.setDate(d.getDate() - 6); return { start: formatDate(d), end: formatDate(today) }; } } } function formatDate(d) { const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); return `${y}-${m}-${day}`; } // 环比:取紧邻上一段等长周期(如近7天 → 再往前7天;自定义区间 → 等长前移) function getPreviousRange(range) { const start = new Date(`${range.start}T00:00:00`); const end = new Date(`${range.end}T00:00:00`); if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { return null; } const lengthDays = Math.max(1, Math.round((end - start) / 86400000) + 1); const prevEnd = new Date(start); prevEnd.setDate(prevEnd.getDate() - 1); const prevStart = new Date(prevEnd); prevStart.setDate(prevStart.getDate() - (lengthDays - 1)); return { start: formatDate(prevStart), end: formatDate(prevEnd) }; } const PROMOTION_SUMMARY_SELECT = `SELECT COUNT(*) AS totalRecords, SUM(TRY_CONVERT(decimal(18,2), [orderSpend])) / 1000.0 AS totalOrderSpend, SUM(TRY_CONVERT(decimal(18,2), [gmv])) / 1000.0 AS totalGmv, CASE WHEN SUM(TRY_CONVERT(decimal(18,2), [orderSpend])) > 0 THEN SUM(TRY_CONVERT(decimal(18,2), [gmv])) / SUM(TRY_CONVERT(decimal(18,2), [orderSpend])) ELSE 0 END AS avgRoi, SUM(TRY_CONVERT(decimal(18,2), [spend])) / 1000.0 AS totalSpend, SUM(TRY_CONVERT(decimal(18,2), [netGmv])) / 1000.0 AS totalNetGmv, CASE WHEN SUM(TRY_CONVERT(decimal(18,2), [orderSpend])) > 0 THEN SUM(TRY_CONVERT(decimal(18,2), [netGmv])) / SUM(TRY_CONVERT(decimal(18,2), [orderSpend])) ELSE 0 END AS avgNetRoi, SUM(TRY_CONVERT(int, [netOrderNum])) AS totalNetOrderNum, CASE WHEN SUM(TRY_CONVERT(int, [netOrderNum])) > 0 THEN SUM(TRY_CONVERT(decimal(18,2), [orderSpend])) / 1000.0 / SUM(TRY_CONVERT(int, [netOrderNum])) ELSE 0 END AS avgCostPerNetOrder FROM dbo.pdd_ReportGoodsSpread WHERE LEFT([createTime], 10) >= @startDate AND LEFT([createTime], 10) <= @endDate`; async function queryPromotionSummary(range) { const [row] = await jianSunDb.query(PROMOTION_SUMMARY_SELECT, { startDate: range.start, endDate: range.end, }); const stats = row || {}; return { totalRecords: toNum(stats.totalRecords), orderSpend: toNum(stats.totalOrderSpend), gmv: toNum(stats.totalGmv), roi: toNum(stats.avgRoi), spend: toNum(stats.totalSpend), netGmv: toNum(stats.totalNetGmv), netRoi: toNum(stats.avgNetRoi), netOrderNum: toNum(stats.totalNetOrderNum), costPerNetOrder: toNum(stats.avgCostPerNetOrder), }; } // 环比变化率:(当前 - 上一周期) / |上一周期|,上一周期为 0 时返回 null function buildPeriodChanges(current, previous) { const changes = {}; Object.keys(current).forEach((key) => { const base = Math.abs(previous[key]); changes[key] = base > 0 ? (current[key] - previous[key]) / base : null; }); return changes; } async function getImageMap(goodsIds) { if (!goodsIds.length) return new Map(); try { const placeholders = goodsIds.map((_, i) => `@gid${i}`).join(','); const params = {}; goodsIds.forEach((id, i) => { params[`gid${i}`] = String(id); }); const rows = await middleDb.query( `SELECT DISTINCT CONVERT(varchar(50), goodsId) AS goodsId, hdThumbUrl FROM dbo.pdd_DataGoods WHERE CONVERT(varchar(50), goodsId) IN (${placeholders})`, params ); const map = new Map(); rows.forEach((row) => { if (row.hdThumbUrl) map.set(String(row.goodsId), row.hdThumbUrl); }); return map; } catch { return new Map(); } } router.get('/latest-date', async (req, res) => { try { const rows = await jianSunDb.query( `SELECT DISTINCT TOP 2 LEFT([createTime], 10) AS createDate FROM dbo.pdd_ReportGoodsSpread WHERE [createTime] IS NOT NULL AND LTRIM(RTRIM([createTime])) != '' ORDER BY createDate DESC` ); const date = rows.length >= 2 ? rows[1].createDate : (rows[0]?.createDate || ''); res.json({ success: true, data: { date: String(date || '').trim() } }); } catch (error) { console.error('[PromotionModule] 查询最新数据日期失败:', error.message); res.status(500).json({ success: false, error: error.message }); } }); router.get('/shops', async (req, res) => { try { const rows = await jianSunDb.query( `SELECT DISTINCT [shopName] FROM dbo.pdd_ReportGoodsSpread WHERE [shopName] IS NOT NULL AND LTRIM(RTRIM([shopName])) != '' ORDER BY [shopName]` ); res.json({ success: true, data: rows.map((r) => r.shopName) }); } catch (error) { console.error('[PromotionModule] 查询店铺列表失败:', error.message); res.status(500).json({ success: false, error: error.message }); } }); router.get('/summary', async (req, res) => { try { const { date, period, start: customStart, end: customEnd } = req.query; let filterDate = (date && String(date).trim()) || ''; let range; if (period === 'custom') { range = getDateRange('custom', customStart, customEnd); } else if (period) { range = getDateRange(period); filterDate = ''; } if (!range) { // 单日口径:未带 period 时按最新数据日(默认取倒数第二个有数据的日期) if (!filterDate) { const dateRows = await jianSunDb.query( `SELECT DISTINCT TOP 2 LEFT([createTime], 10) AS createDate FROM dbo.pdd_ReportGoodsSpread WHERE [createTime] IS NOT NULL AND LTRIM(RTRIM([createTime])) != '' ORDER BY createDate DESC` ); filterDate = dateRows.length >= 2 ? String(dateRows[1].createDate || '').trim() : String(dateRows[0]?.createDate || '').trim(); } range = { start: filterDate, end: filterDate }; } const previousRange = getPreviousRange(range); const [current, previous] = await Promise.all([ queryPromotionSummary(range), previousRange ? queryPromotionSummary(previousRange) : Promise.resolve(null), ]); res.json({ success: true, data: { filterDate: `${range.start} ~ ${range.end}`, previousRange: previousRange ? `${previousRange.start} ~ ${previousRange.end}` : '', totalRecords: current.totalRecords, orderSpend: fmtMoney(current.orderSpend), gmv: fmtMoney(current.gmv), roi: fmtNum(current.roi), spend: fmtMoney(current.spend), netGmv: fmtMoney(current.netGmv), netRoi: fmtNum(current.netRoi), netOrderNum: fmtNum(current.netOrderNum), costPerNetOrder: fmtMoney(current.costPerNetOrder), changes: previous ? buildPeriodChanges(current, previous) : null, }, }); } catch (error) { console.error('[PromotionModule] 查询汇总数据失败:', error.message); res.status(500).json({ success: false, error: error.message }); } }); router.get('/list', async (req, res) => { try { const { page = 1, pageSize = 20, sortBy = 'orderSpend', sortOrder = 'DESC', keyword, shopName, date, period, start: customStart, end: customEnd, } = req.query; const currentPage = safeInt(page, 1); const limit = Math.min(safeInt(pageSize, 20), MAX_PAGE_SIZE); const offset = (currentPage - 1) * limit; const whereParts = []; const params = {}; if (period && period !== 'custom') { const range = getDateRange(period); whereParts.push('LEFT([createTime], 10) >= @startDate AND LEFT([createTime], 10) <= @endDate'); params.startDate = range.start; params.endDate = range.end; } else if (period === 'custom' && customStart && customEnd) { whereParts.push('LEFT([createTime], 10) >= @startDate AND LEFT([createTime], 10) <= @endDate'); params.startDate = customStart; params.endDate = customEnd; } else { let filterDate = (date && String(date).trim()) || ''; if (!filterDate) { const dateRows = await jianSunDb.query( `SELECT DISTINCT TOP 2 LEFT([createTime], 10) AS createDate FROM dbo.pdd_ReportGoodsSpread WHERE [createTime] IS NOT NULL AND LTRIM(RTRIM([createTime])) != '' ORDER BY createDate DESC` ); filterDate = dateRows.length >= 2 ? String(dateRows[1].createDate || '').trim() : String(dateRows[0]?.createDate || '').trim(); } if (filterDate) { whereParts.push('LEFT([createTime], 10) = @filterDate'); params.filterDate = filterDate; } } if (keyword && keyword.trim()) { whereParts.push('([goodsName] LIKE @kw OR [goodsId] LIKE @kw)'); params.kw = `%${keyword.trim()}%`; } if (shopName && shopName.trim()) { whereParts.push('[shopName] = @shopName'); params.shopName = shopName.trim(); } const whereSql = whereParts.length > 0 ? `WHERE ${whereParts.join(' AND ')}` : ''; const allowedSort = new Set([ 'goodsName', 'goodsId', 'shopName', 'createTime', 'orderSpend', 'gmv', 'roi', 'spend', 'orderNum', 'netGmv', 'netRoi', 'netOrderNum', 'netAvgPayAmount', 'settlementGmv', 'settlementRoi', 'settlementOrder', 'impression', 'click', 'ctr', 'cvr', ]); const sortCol = allowedSort.has(sortBy) ? `[${sortBy}]` : '[orderSpend]'; const sortDir = String(sortOrder || '').toLowerCase() === 'asc' ? 'ASC' : 'DESC'; const countResult = await jianSunDb.query( `SELECT COUNT(*) AS total FROM dbo.pdd_ReportGoodsSpread ${whereSql}`, params ); const total = countResult[0]?.total || 0; const dataResult = await jianSunDb.query( `SELECT [id], [goodsName], [goodsId], [shopName], [createTime], [orderSpend], [gmv], [roi], [spend], [orderNum], [transactionCost], [avgPayAmount], [directGmv], [indirectGmv], [directOrderNum], [indirectOrderNum], [netGmv], [netRoi], [netOrderNum], [netAvgPayAmount], [netRate], [settlementGmv], [settlementRoi], [settlementOrder], [refundGmvRate30d], [refundOrderRate30d], [exemptRefundGmvRate30d], [exemptRefundOrderRate30d], [settlementGmvRate], [settlementOrderRate], [costPerSettlementOrderForDeal], [impression], [click], [ctr], [cvr], [targetRoi], [targetBid] FROM dbo.pdd_ReportGoodsSpread ${whereSql} ORDER BY ${sortCol} ${sortDir} OFFSET @offset ROWS FETCH NEXT @limit ROWS ONLY`, { ...params, offset, limit } ); const goodsIds = dataResult.map((r) => String(r.goodsId)).filter(Boolean); const imageMap = await getImageMap(goodsIds); const list = dataResult.map((row) => ({ ...row, thumbUrl: imageMap.get(String(row.goodsId)) || '', orderSpend: row.orderSpend != null ? toNum(row.orderSpend) / 1000.0 : null, gmv: row.gmv != null ? toNum(row.gmv) / 1000.0 : null, spend: row.spend != null ? toNum(row.spend) / 1000.0 : null, transactionCost: row.transactionCost != null ? toNum(row.transactionCost) / 1000.0 : null, avgPayAmount: row.avgPayAmount != null ? toNum(row.avgPayAmount) / 1000.0 : null, directGmv: row.directGmv != null ? toNum(row.directGmv) / 1000.0 : null, indirectGmv: row.indirectGmv != null ? toNum(row.indirectGmv) / 1000.0 : null, netGmv: row.netGmv != null ? toNum(row.netGmv) / 1000.0 : null, netAvgPayAmount: row.netAvgPayAmount != null ? toNum(row.netAvgPayAmount) / 1000.0 : null, settlementGmv: row.settlementGmv != null ? toNum(row.settlementGmv) / 1000.0 : null, costPerSettlementOrderForDeal: row.costPerSettlementOrderForDeal != null ? toNum(row.costPerSettlementOrderForDeal) / 1000.0 : null, })); res.json({ success: true, data: { list, total, page: currentPage, pageSize: limit, }, }); } catch (error) { console.error('[PromotionModule] 查询推广数据失败:', error.message); res.status(500).json({ success: false, error: error.message }); } }); module.exports = router;