cangku/backend/src/api/pddSkuDimensionBoard.js

633 lines
18 KiB
JavaScript

const dataSource = require('../config/database');
const planningDatabase = require('../services/planningDatabase');
const { ensurePlanningPddSkuDimensionSchema } = require('../services/planningSupportSchema');
const localDb = planningDatabase;
const systemDb = dataSource.local;
const PDD_ACCESS_DEPARTMENT_MAP = {
'运营中心A': '运营中心A',
'运营中心B': '运营中心B',
'运营中心C': '运营中心C',
'运营中心G': '运营中心G',
'品牌中心': '龙晨品牌中心',
'龙晨品牌中心': '龙晨品牌中心',
};
const SPU_TABLE = 'op_pdd_sku_dimension_spu_daily';
const SKU_TABLE = 'op_pdd_sku_dimension_sku_daily';
const GOODS_TABLE = 'op_pdd_sku_dimension_goods_daily';
const CACHE_TTL_MS = 60 * 1000;
const MAIN_COLUMNS = [
{ key: 'spu', title: 'SPU' },
{ key: 'goodsIdCount', title: '铺货商品id数' },
{ key: 'activeGoodsIdCount', title: '动销商品id数' },
{ key: 'skuListingActiveRate', title: 'SPU铺货动销率' },
{ key: 'skuIdEfficiency', title: 'SPU链接效能' },
{ key: 'isPriceBreak', title: '是否破价' },
{ key: 'payOrdrAmt', title: '近7天真实销售额' },
{ key: 'feeRatio', title: '近7天真实费比' },
{ key: 'afterSaleRate', title: '近7天真实售后率' },
{ key: 'goodsUv', title: '近7天访客数' },
{ key: 'orderNum', title: '近7天订单量' },
{ key: 'payOrdrUsrCnt', title: '近7天买家数' },
{ key: 'payOrdrGoodsQty', title: '近7天成交件数' },
{ key: 'ctr', title: '近7天点击率' },
{ key: 'goodsVcr', title: '近7天成交转化率' },
{ key: 'customerUnitPrice', title: '近7天客单价' },
{ key: 'pieceUnitPrice', title: '近7天件单价' },
{ key: 'spend', title: '近7天总花费' },
];
const SKU_DETAIL_COLUMNS = [
{ key: 'skuCode', title: 'SKU货号' },
...MAIN_COLUMNS.slice(1),
];
const GOODS_DETAIL_COLUMNS = [
{ key: 'goodsId', title: '商品id' },
{ key: 'goodsName', title: '商品名称' },
{ key: 'hdThumbUrl', title: '商品主图' },
{ key: 'shopName', title: '店铺名称' },
{ key: 'idOwner', title: 'id负责人' },
{ key: 'ownerDept', title: '所属部门' },
{ key: 'goodsUv', title: '近7天访客数' },
{ key: 'inquiryNum', title: '近7天询单量' },
{ key: 'goodsFavCnt', title: '近7天收藏用户' },
{ key: 'goodsFavNum', title: '近7天收藏量' },
{ key: 'orderUserCount', title: '近7天下单人数' },
{ key: 'ctr', title: '近7天点击率' },
{ key: 'clickConversionRate', title: '近7天点击转化率' },
{ key: 'goodsVcr', title: '近7天成交转化率' },
{ key: 'ordrVstrRto', title: '近7天下单率' },
{ key: 'payOrdrRto', title: '近7天成交率' },
{ key: 'imprUsrCnt', title: '近7天流失指数' },
{ key: 'unpaidUserCount', title: '近7天未支付人数' },
{ key: 'payOrdrAmt', title: '近7天真实销售额' },
{ key: 'orderNum', title: '近7天订单量' },
{ key: 'payOrdrUsrCnt', title: '近7天买家数' },
{ key: 'payOrdrGoodsQty', title: '近7天成交件数' },
{ key: 'customerUnitPrice', title: '近7天客单价' },
{ key: 'pieceUnitPrice', title: '近7天件单价' },
{ key: 'spend', title: '近7天总花费' },
{ key: 'feeRatio', title: '近7天真实费比' },
{ key: 'afterSaleRate', title: '近7天真实售后率' },
{ key: 'afterSaleCount', title: '近7天售后单量' },
{ key: 'badReviewCount', title: '近7天差评量' },
{ key: 'isPriceBreak', title: '是否破价' },
];
const MAIN_SORT_FIELDS = {
spu: '`spu`',
goodsIdCount: '`goodsIdCount`',
activeGoodsIdCount: '`activeGoodsIdCount`',
skuListingActiveRate: '`skuListingActiveRate`',
skuIdEfficiency: '`skuIdEfficiency`',
isPriceBreak: '`isPriceBreak`',
payOrdrAmt: '`payOrdrAmt`',
feeRatio: '`feeRatio`',
afterSaleRate: '`afterSaleRate`',
goodsUv: '`goodsUv`',
orderNum: '`orderNum`',
payOrdrUsrCnt: '`payOrdrUsrCnt`',
payOrdrGoodsQty: '`payOrdrGoodsQty`',
ctr: '`ctr`',
goodsVcr: '`goodsVcr`',
customerUnitPrice: '`customerUnitPrice`',
pieceUnitPrice: '`pieceUnitPrice`',
spend: '`spend`',
};
const valueCache = new Map();
function getMemoryCache(key) {
const cached = valueCache.get(key);
if (!cached) return null;
if (cached.expiresAt <= Date.now()) {
valueCache.delete(key);
return null;
}
return cached.value;
}
function setMemoryCache(key, value, ttlMs = CACHE_TTL_MS) {
valueCache.set(key, {
value,
expiresAt: Date.now() + ttlMs,
});
}
function normalizeMultiValueQueryParam(value) {
const rawValues = Array.isArray(value) ? value : [ value ];
return rawValues
.flatMap((item) => String(item || '').split(/[,\uff0c]/))
.map((item) => item.trim())
.filter(Boolean);
}
function normalizePddDepartments(...departmentValues) {
const result = new Set();
departmentValues
.filter(Boolean)
.flatMap((value) => String(value).split(/[\/,,、]/))
.map((value) => value.trim())
.filter(Boolean)
.forEach((value) => {
const mapped = PDD_ACCESS_DEPARTMENT_MAP[value];
if (mapped) {
result.add(mapped);
}
});
return Array.from(result);
}
function getEffectiveDepartmentFilters(accessScope, departmentValues = []) {
const requestedDepartments = normalizePddDepartments(...departmentValues);
if (accessScope?.isAllData) {
return requestedDepartments;
}
const allowedDepartments = Array.isArray(accessScope?.allowedDepartments)
? accessScope.allowedDepartments
: [];
if (allowedDepartments.length === 0) {
return null;
}
if (requestedDepartments.length === 0) {
return allowedDepartments;
}
const allowedSet = new Set(allowedDepartments);
return requestedDepartments.filter((item) => allowedSet.has(item));
}
async function getPddDataAccessScope(userId) {
const rows = await systemDb.query(
`SELECT
u.id,
u.username,
u.role,
u.department_names,
d.name AS department_name
FROM users u
LEFT JOIN departments d ON u.department_id = d.id
WHERE u.id = ?
LIMIT 1`,
[ userId ]
);
const user = rows[0];
if (!user) {
return null;
}
if (user.role === 'admin') {
return {
isAllData: true,
allowedDepartments: [],
};
}
return {
isAllData: false,
allowedDepartments: normalizePddDepartments(user.department_names, user.department_name),
};
}
async function getLatestSnapshotInfo(tableName) {
await ensurePlanningPddSkuDimensionSchema();
const cacheKey = `pdd:sku-snapshot:latest:${tableName}`;
const cached = getMemoryCache(cacheKey);
if (cached) {
return cached;
}
const rows = await localDb.query(
`SELECT
DATE_FORMAT(\`statDate\`, '%Y-%m-%d') AS statDate,
DATE_FORMAT(\`startDate\`, '%Y-%m-%d') AS startDate,
DATE_FORMAT(\`endDate\`, '%Y-%m-%d') AS endDate
FROM \`${tableName}\`
ORDER BY \`statDate\` DESC
LIMIT 1`
);
const value = rows[0] || null;
setMemoryCache(cacheKey, value, CACHE_TTL_MS);
return value;
}
function buildLikeAnyClause(columnName, values, params) {
if (!Array.isArray(values) || values.length === 0) {
return '';
}
const placeholders = values.map((value) => {
params.push(`%${value}%`);
return `${columnName} LIKE ?`;
});
return `(${placeholders.join(' OR ')})`;
}
function getSortSql(sortField, sortOrder, defaultExpr, defaultDirection = 'DESC', tieBreaker = '') {
const fieldExpr = MAIN_SORT_FIELDS[sortField] || defaultExpr;
const direction = sortOrder === 'ascend' ? 'ASC' : (sortOrder === 'descend' ? 'DESC' : defaultDirection);
return `${fieldExpr} ${direction}${tieBreaker}`;
}
function formatBoolText(value) {
return Number(value || 0) === 1 ? '是' : '否';
}
function normalizeMainRows(rows = []) {
return rows.map((row) => ({
...row,
isPriceBreak: formatBoolText(row.isPriceBreak),
}));
}
function normalizeGoodsRows(rows = []) {
return rows.map((row) => ({
...row,
isPriceBreak: formatBoolText(row.isPriceBreak),
}));
}
async function fetchSpuList({
statDate,
startDate,
endDate,
page,
pageSize,
sortField,
sortOrder,
spu,
shopNames,
departments,
idOwners,
accessScope,
}) {
const whereParts = [ '`statDate` = ?' ];
const params = [ statDate ];
if (spu) {
whereParts.push('`spu` LIKE ?');
params.push(`%${spu}%`);
}
const shopClause = buildLikeAnyClause('`shopNameList`', shopNames, params);
if (shopClause) whereParts.push(shopClause);
const departmentFilters = getEffectiveDepartmentFilters(accessScope, departments);
if (departmentFilters === null) {
return {
success: true,
data: {
columns: MAIN_COLUMNS,
rows: [],
total: 0,
page,
pageSize,
startDate,
endDate,
sortField: '',
sortOrder: '',
},
};
}
if (Array.isArray(departmentFilters) && departmentFilters.length === 0 && departments.length > 0) {
return {
success: true,
data: {
columns: MAIN_COLUMNS,
rows: [],
total: 0,
page,
pageSize,
startDate,
endDate,
sortField: '',
sortOrder: '',
},
};
}
const departmentClause = buildLikeAnyClause('`ownerDeptList`', departmentFilters, params);
if (departmentClause) whereParts.push(departmentClause);
const idOwnerClause = buildLikeAnyClause('`idOwnerList`', idOwners, params);
if (idOwnerClause) whereParts.push(idOwnerClause);
const whereSql = `WHERE ${whereParts.join(' AND ')}`;
const totalRows = await localDb.query(
`SELECT COUNT(1) AS total FROM \`${SPU_TABLE}\` ${whereSql}`,
params
);
const total = Number(totalRows?.[0]?.total || 0);
const orderBySql = getSortSql(sortField, sortOrder, '`payOrdrAmt`', 'DESC', ', `spu` ASC');
const listRows = await localDb.query(
`SELECT
\`spu\`,
\`goodsIdCount\`,
\`activeGoodsIdCount\`,
\`skuListingActiveRate\`,
\`skuIdEfficiency\`,
\`isPriceBreak\`,
\`payOrdrAmt\`,
\`feeRatio\`,
\`afterSaleRate\`,
\`goodsUv\`,
\`orderNum\`,
\`payOrdrUsrCnt\`,
\`payOrdrGoodsQty\`,
\`ctr\`,
\`goodsVcr\`,
\`customerUnitPrice\`,
\`pieceUnitPrice\`,
\`spend\`
FROM \`${SPU_TABLE}\`
${whereSql}
ORDER BY ${orderBySql}
LIMIT ? OFFSET ?`,
[ ...params, pageSize, (page - 1) * pageSize ]
);
return {
success: true,
data: {
columns: MAIN_COLUMNS,
rows: normalizeMainRows(listRows),
total,
page,
pageSize,
startDate,
endDate,
sortField: MAIN_SORT_FIELDS[sortField] ? sortField : '',
sortOrder: MAIN_SORT_FIELDS[sortField] ? (sortOrder || 'descend') : '',
},
};
}
async function fetchSkuDetail({ spu, accessScope }) {
const latest = await getLatestSnapshotInfo(SKU_TABLE);
if (!latest?.statDate) {
return {
success: true,
data: {
spu,
startDate: null,
endDate: null,
columns: SKU_DETAIL_COLUMNS,
rows: [],
},
};
}
const whereParts = [ '`statDate` = ?', '`spu` = ?' ];
const params = [ latest.statDate, spu ];
const departmentFilters = getEffectiveDepartmentFilters(accessScope, []);
if (departmentFilters === null) {
return {
success: true,
data: {
spu,
startDate: latest.startDate,
endDate: latest.endDate,
columns: SKU_DETAIL_COLUMNS,
rows: [],
},
};
}
if (Array.isArray(departmentFilters) && departmentFilters.length > 0) {
const scopeClause = buildLikeAnyClause('`ownerDeptList`', departmentFilters, params);
if (scopeClause) whereParts.push(scopeClause);
}
const rows = await localDb.query(
`SELECT
\`skuCode\`,
\`goodsIdCount\`,
\`activeGoodsIdCount\`,
\`skuListingActiveRate\`,
\`skuIdEfficiency\`,
\`isPriceBreak\`,
\`payOrdrAmt\`,
\`feeRatio\`,
\`afterSaleRate\`,
\`goodsUv\`,
\`orderNum\`,
\`payOrdrUsrCnt\`,
\`payOrdrGoodsQty\`,
\`ctr\`,
\`goodsVcr\`,
\`customerUnitPrice\`,
\`pieceUnitPrice\`,
\`spend\`
FROM \`${SKU_TABLE}\`
WHERE ${whereParts.join(' AND ')}
ORDER BY \`payOrdrAmt\` DESC, \`goodsIdCount\` DESC, \`skuCode\` ASC`,
params
);
return {
success: true,
data: {
spu,
startDate: latest.startDate,
endDate: latest.endDate,
columns: SKU_DETAIL_COLUMNS,
rows: normalizeMainRows(rows),
},
};
}
async function fetchGoodsDetail({ skuCode, accessScope }) {
const latest = await getLatestSnapshotInfo(GOODS_TABLE);
if (!latest?.statDate) {
return {
success: true,
data: {
skuCode,
startDate: null,
endDate: null,
columns: GOODS_DETAIL_COLUMNS,
rows: [],
},
};
}
const whereParts = [ '`statDate` = ?', '`skuCode` = ?' ];
const params = [ latest.statDate, skuCode ];
const departmentFilters = getEffectiveDepartmentFilters(accessScope, []);
if (departmentFilters === null) {
return {
success: true,
data: {
skuCode,
startDate: latest.startDate,
endDate: latest.endDate,
columns: GOODS_DETAIL_COLUMNS,
rows: [],
},
};
}
if (Array.isArray(departmentFilters) && departmentFilters.length > 0) {
whereParts.push(`\`ownerDept\` IN (${new Array(departmentFilters.length).fill('?').join(', ')})`);
params.push(...departmentFilters);
}
const rows = await localDb.query(
`SELECT
\`goodsId\`,
\`goodsName\`,
\`hdThumbUrl\`,
\`shopName\`,
\`idOwner\`,
\`ownerDept\`,
\`goodsUv\`,
\`inquiryNum\`,
\`goodsFavCnt\`,
\`goodsFavNum\`,
\`orderUserCount\`,
\`ctr\`,
\`clickConversionRate\`,
\`goodsVcr\`,
\`ordrVstrRto\`,
\`payOrdrRto\`,
\`imprUsrCnt\`,
\`unpaidUserCount\`,
\`payOrdrAmt\`,
\`orderNum\`,
\`payOrdrUsrCnt\`,
\`payOrdrGoodsQty\`,
\`customerUnitPrice\`,
\`pieceUnitPrice\`,
\`spend\`,
\`feeRatio\`,
\`afterSaleRate\`,
\`afterSaleCount\`,
\`badReviewCount\`,
\`isPriceBreak\`
FROM \`${GOODS_TABLE}\`
WHERE ${whereParts.join(' AND ')}
ORDER BY \`payOrdrAmt\` DESC, \`goodsUv\` DESC, \`goodsId\` ASC`,
params
);
return {
success: true,
data: {
skuCode,
startDate: latest.startDate,
endDate: latest.endDate,
columns: GOODS_DETAIL_COLUMNS,
rows: normalizeGoodsRows(rows),
},
};
}
module.exports = function registerPddSkuDimensionBoard({
router,
authMiddleware,
}) {
router.get('/pdd/product-dashboard/sku-dimension-board/list', authMiddleware, async (req, res) => {
try {
const userId = req.user?.sub || req.user?.id || req.user?.userId;
if (!userId) {
return res.status(401).json({ success: false, error: '未获取到登录用户信息' });
}
const accessScope = await getPddDataAccessScope(userId);
if (!accessScope) {
return res.status(403).json({ success: false, error: '未获取到数据权限' });
}
const latest = await getLatestSnapshotInfo(SPU_TABLE);
const page = Math.max(parseInt(req.query.page || '1', 10), 1);
const pageSize = Math.min(Math.max(parseInt(req.query.pageSize || '20', 10), 1), 500);
const sortField = String(req.query.sortField || '').trim();
const sortOrder = String(req.query.sortOrder || '').trim().toLowerCase();
const spu = String(req.query.spu || '').trim();
const shopNames = normalizeMultiValueQueryParam(req.query.shopName);
const departments = normalizeMultiValueQueryParam(req.query.department);
const idOwners = normalizeMultiValueQueryParam(req.query.idOwners);
if (!latest?.statDate) {
return res.json({
success: true,
data: {
columns: MAIN_COLUMNS,
rows: [],
total: 0,
page,
pageSize,
startDate: null,
endDate: null,
sortField: '',
sortOrder: '',
},
});
}
const result = await fetchSpuList({
statDate: latest.statDate,
startDate: latest.startDate,
endDate: latest.endDate,
page,
pageSize,
sortField,
sortOrder,
spu,
shopNames,
departments,
idOwners,
accessScope,
});
res.json(result);
} catch (error) {
res.status(error.statusCode || 500).json({ success: false, error: error.message });
}
});
router.get('/pdd/product-dashboard/sku-dimension-board/sku-detail', authMiddleware, async (req, res) => {
try {
const spu = String(req.query.spu || '').trim();
if (!spu) {
return res.status(400).json({ success: false, error: 'spu is required' });
}
const userId = req.user?.sub || req.user?.id || req.user?.userId;
const accessScope = await getPddDataAccessScope(userId);
if (!accessScope) {
return res.status(403).json({ success: false, error: '未获取到数据权限' });
}
res.json(await fetchSkuDetail({ spu, accessScope }));
} catch (error) {
res.status(error.statusCode || 500).json({ success: false, error: error.message });
}
});
router.get('/pdd/product-dashboard/sku-dimension-board/goods-detail', authMiddleware, async (req, res) => {
try {
const skuCode = String(req.query.skuCode || '').trim();
if (!skuCode) {
return res.status(400).json({ success: false, error: 'skuCode is required' });
}
const userId = req.user?.sub || req.user?.id || req.user?.userId;
const accessScope = await getPddDataAccessScope(userId);
if (!accessScope) {
return res.status(403).json({ success: false, error: '未获取到数据权限' });
}
res.json(await fetchGoodsDetail({ skuCode, accessScope }));
} catch (error) {
res.status(error.statusCode || 500).json({ success: false, error: error.message });
}
});
};