本次将此前被列入退役名单的运营分析相关模块恢复为可用,并新增「模块化库存监控」
一级菜单及其两个子页面。
主要变更:
1. 恢复启用:商品监控、激励看板、营销活动、推广模块、商品分析
- retiredFeatures 移出对应退役名单与清表语句,改为正常保留
- 补齐后端路由、前端页面与 API 封装、侧边栏配置、桌面客户端能力项
- 同步更新 retiredFeatures / retiredRoutes / retiredNavigation / desktopSessionService 测试
2. 新增「模块化库存监控」一级菜单
- 配件库存监控:仅取「简尚-崔桥工厂半成品包装项目仓」SKU 的实时库存、
当日入库/出库、近 7 日日均发货量、库存可用天数与预警(库存可用天数 ≤ 7 预警)
- MKH-BOM资料:按商品编码展示配件明细、数量、仓位、库存,支持 A4 纵向打印
3. 新增产品资料系统数据库(PostgreSQL)连接模块:读取系统库 database_configs 中
name = product_information_database 的配置,凭据只从配置读取,不落代码
4. 新增系统设置页配置项:商品标签配置、部门商品标签配置、部门SKU负责人配置
5. 旧 /api/dashboard 接口迁移至 /api/productAnalysis,仪表盘预览页改用新接口,
相应移除 dashboard 路由模块及其测试
6. 修复 retiredRoutes 测试在 Windows 下挂起超时的问题:该测试用
parent.filename.endsWith('/api/planningUpload.js') 判断是否注入 mock,
而 Windows 的 filename 为反斜杠路径,导致判断恒为 false、mock 全部失效、
真实模块去连中央系统库而挂起(Linux CI 正常)。改为平台无关的路径匹配。
7. .gitignore 忽略本地验证产物(outputs/、.git-status.txt)
验证:
- 前端:lint / test(16 项)/ build(含 planning entry 校验)全部通过
- 后端:语法检查 149 个文件通过;测试 38/39 通过
- 已知未通过项:resilientSecretStore.test.js 断言密钥备份文件权限为 0o600,
Windows 下 chmod 无法表达该权限(实际为 0o666),属既有的平台相关失败,
与本次改动无关,本次未修改该文件
- 说明:本机 frontend/ 下存在未纳入版本库的旧依赖备份目录 node_modules_old_*,
会污染 ESLint 扫描(十余万条第三方报错),故 lint 步骤以
--ignore-pattern node_modules_old_*/** 排除后执行,其余步骤与仓库 check 一致
未提交内容:本地验证截图 outputs/ 与 .git-status.txt 已被 .gitignore 排除。
418 lines
14 KiB
JavaScript
418 lines
14 KiB
JavaScript
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;
|