cangku/backend/src/api/pddFollowUpBoard.js

476 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
const dayjs = require('dayjs');
const dataSource = require('../config/database');
const {
FOLLOW_UP_TARGET_TABLE,
FOLLOW_UP_DAILY_TABLE,
DEFAULT_GMV_THRESHOLD,
DEFAULT_FEE_RATIO_THRESHOLD,
DEFAULT_AFTER_SALE_RATE_THRESHOLD,
} = require('../services/followUpMonitorSync');
const localDb = dataSource.local;
const PLATFORM = 'pdd';
const PDD_ACCESS_DEPARTMENT_MAP = {
'运营中心A': '运营中心A',
'运营中心B': '运营中心B',
'运营中心C': '运营中心C',
'运营中心G': '运营中心G',
'品牌中心': '龙晨品牌中心',
'龙晨品牌中心': '龙晨品牌中心',
};
const FOLLOW_UP_MONITOR_COLUMNS = [
{ key: 'goodsId', title: '商品id' },
{ key: 'goodsName', title: '商品名称' },
{ key: 'shopName', title: '店铺名称' },
{ key: 'idOwner', title: 'id负责人' },
{ key: 'ownerDept', title: '所属部门' },
{ key: 'status', title: '状态' },
{ key: 'gmvThreshold', title: 'GMV阈值' },
{ key: 'feeRatioThreshold', title: '费比阈值' },
{ key: 'afterSaleRateThreshold', title: '售后阈值' },
{ key: 'latestStatDate', title: '最新业务日期' },
{ key: 'latestGmv', title: '当日GMV' },
{ key: 'latestFeeRatio', title: '当日费比' },
{ key: 'latestAfterSaleRate', title: '当日售后率' },
{ key: 'gmvAlertDays', title: 'GMV连续异常天数' },
{ key: 'feeRatioAlertDays', title: '费比连续异常天数' },
{ key: 'afterSaleAlertDays', title: '售后连续异常天数' },
{ key: 'warningLevel', title: '预警级别' },
{ key: 'issueSummary', title: '异常摘要' },
{ key: 'actionAdvice', title: '处理建议' },
{ key: 'remark', title: '备注' },
{ key: 'updatedAt', title: '更新时间' },
];
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);
}
async function getPddDataAccessScope(userId) {
const rows = await localDb.query(
`SELECT
u.id,
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),
};
}
function toFiniteNumber(value, fallback = 0) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : fallback;
}
function normalizeMonitorTargetStatus(value) {
return String(value || '').trim().toLowerCase() === 'inactive' ? 'inactive' : 'active';
}
function parseThreshold(value, fallback) {
if (value === '' || value === undefined || value === null) {
return fallback;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
function validateThresholds({ gmvThreshold, feeRatioThreshold, afterSaleRateThreshold }) {
if (gmvThreshold < 0) {
const error = new Error('GMV阈值不能小于 0');
error.statusCode = 400;
throw error;
}
if (afterSaleRateThreshold < 0 || afterSaleRateThreshold > 10) {
const error = new Error('售后阈值只能填写 0-10');
error.statusCode = 400;
throw error;
}
if (feeRatioThreshold < 20 || feeRatioThreshold > 100) {
const error = new Error('费比阈值只能填写 20-100');
error.statusCode = 400;
throw error;
}
}
function countConsecutiveAlerts(rows, predicate) {
let count = 0;
for (const row of rows) {
if (!predicate(row)) break;
count += 1;
}
return count;
}
function buildFollowUpWarningMeta({ gmvAlertDays, feeRatioAlertDays, afterSaleAlertDays }) {
const maxAlertDays = Math.max(gmvAlertDays, feeRatioAlertDays, afterSaleAlertDays);
if (afterSaleAlertDays >= 20) {
return {
warningLevel: '立即下架',
actionAdvice: '售后指标连续 20 天超阈值,建议立即下架并通知负责人处理。',
};
}
if (maxAlertDays >= 20) {
return {
warningLevel: '重点处理',
actionAdvice: '指标异常已连续超过 20 天,建议升级到管理层并明确整改时限。',
};
}
if (maxAlertDays >= 10) {
return {
warningLevel: '预警',
actionAdvice: '指标异常已连续超过 10 天,建议尽快复盘并执行优化动作。',
};
}
return {
warningLevel: '正常',
actionAdvice: '当前未达到连续预警阈值,继续观察即可。',
};
}
function buildIssueSummary({ gmvAlertDays, feeRatioAlertDays, afterSaleAlertDays }) {
const segments = [];
if (gmvAlertDays > 0) segments.push(`GMV连续异常${gmvAlertDays}天`);
if (feeRatioAlertDays > 0) segments.push(`费比连续异常${feeRatioAlertDays}天`);
if (afterSaleAlertDays > 0) segments.push(`售后连续异常${afterSaleAlertDays}天`);
return segments.join(',') || '暂无连续异常';
}
async function listFollowUpMonitorTargets() {
return localDb.query(
`SELECT
id,
goods_id AS goodsId,
fee_ratio_threshold AS feeRatioThreshold,
gmv_threshold AS gmvThreshold,
after_sale_rate_threshold AS afterSaleRateThreshold,
status,
remark,
created_by AS createdBy,
updated_by AS updatedBy,
DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS createdAt,
DATE_FORMAT(updated_at, '%Y-%m-%d %H:%i:%s') AS updatedAt
FROM \`${FOLLOW_UP_TARGET_TABLE}\`
WHERE platform = ?
ORDER BY status = 'active' DESC, updated_at DESC, id DESC`,
[ PLATFORM ]
);
}
async function getLatestDailyWindow() {
const rows = await localDb.query(
`SELECT DATE_FORMAT(MAX(stat_date), '%Y-%m-%d') AS latestStatDate
FROM \`${FOLLOW_UP_DAILY_TABLE}\`
WHERE platform = ?`,
[ PLATFORM ]
);
const latestStatDate = rows?.[0]?.latestStatDate || null;
if (!latestStatDate) return null;
return {
latestStatDate,
startDate: dayjs(latestStatDate).subtract(29, 'day').format('YYYY-MM-DD'),
};
}
async function getFollowUpMonitorSeries(targetIds, startDate, endDate) {
if (!Array.isArray(targetIds) || targetIds.length === 0) {
return new Map();
}
const rows = await localDb.query(
`SELECT
target_id AS targetId,
goods_id AS goodsId,
goods_name AS goodsName,
shop_name AS shopName,
id_owner AS idOwner,
owner_dept AS ownerDept,
DATE_FORMAT(stat_date, '%Y-%m-%d') AS statDate,
gmv,
fee_ratio AS feeRatio,
after_sale_rate AS afterSaleRate,
gmv_alert_flag AS gmvAlertFlag,
fee_ratio_alert_flag AS feeRatioAlertFlag,
after_sale_alert_flag AS afterSaleAlertFlag
FROM \`${FOLLOW_UP_DAILY_TABLE}\`
WHERE platform = ?
AND target_id IN (${targetIds.map(() => '?').join(', ')})
AND stat_date BETWEEN ? AND ?
ORDER BY target_id ASC, stat_date DESC`,
[ PLATFORM, ...targetIds, startDate, endDate ]
);
const seriesMap = new Map();
rows.forEach((row) => {
const targetId = Number(row.targetId || 0);
if (!targetId) return;
if (!seriesMap.has(targetId)) seriesMap.set(targetId, []);
seriesMap.get(targetId).push({
...row,
gmv: toFiniteNumber(row.gmv, 0),
feeRatio: toFiniteNumber(row.feeRatio, 0),
afterSaleRate: toFiniteNumber(row.afterSaleRate, 0),
gmvAlertFlag: Number(row.gmvAlertFlag || 0),
feeRatioAlertFlag: Number(row.feeRatioAlertFlag || 0),
afterSaleAlertFlag: Number(row.afterSaleAlertFlag || 0),
});
});
return seriesMap;
}
async function buildFollowUpMonitorList(accessScope) {
const targets = await listFollowUpMonitorTargets();
const activeTargets = targets.filter((item) => normalizeMonitorTargetStatus(item.status) === 'active');
const latestWindow = await getLatestDailyWindow();
if (!latestWindow) {
return {
success: true,
data: {
columns: FOLLOW_UP_MONITOR_COLUMNS,
rows: [],
total: 0,
startDate: null,
endDate: null,
latestStatDate: null,
},
};
}
const seriesMap = await getFollowUpMonitorSeries(
activeTargets.map((item) => item.id),
latestWindow.startDate,
latestWindow.latestStatDate
);
const allowedDepartments = accessScope?.isAllData
? null
: new Set(Array.isArray(accessScope?.allowedDepartments) ? accessScope.allowedDepartments : []);
const rows = activeTargets.map((target) => {
const targetRows = seriesMap.get(target.id) || [];
const latestRow = targetRows[0] || null;
if (latestRow && allowedDepartments && allowedDepartments.size > 0 && !allowedDepartments.has(String(latestRow.ownerDept || '').trim())) {
return null;
}
if (!latestRow) {
return {
id: target.id,
goodsId: target.goodsId,
goodsName: '',
shopName: '',
idOwner: '',
ownerDept: '',
status: normalizeMonitorTargetStatus(target.status),
gmvThreshold: target.gmvThreshold,
feeRatioThreshold: target.feeRatioThreshold,
afterSaleRateThreshold: target.afterSaleRateThreshold,
latestStatDate: latestWindow.latestStatDate,
latestGmv: 0,
latestFeeRatio: 0,
latestAfterSaleRate: 0,
gmvAlertDays: 0,
feeRatioAlertDays: 0,
afterSaleAlertDays: 0,
warningLevel: '无数据',
issueSummary: '最近30天没有监控结果',
actionAdvice: '请确认该商品ID是否存在拼多多源数据,或等待定时任务执行。',
remark: target.remark || '',
updatedAt: target.updatedAt,
};
}
const gmvAlertDays = countConsecutiveAlerts(targetRows, (row) => Number(row.gmvAlertFlag || 0) === 1);
const feeRatioAlertDays = countConsecutiveAlerts(targetRows, (row) => Number(row.feeRatioAlertFlag || 0) === 1);
const afterSaleAlertDays = countConsecutiveAlerts(targetRows, (row) => Number(row.afterSaleAlertFlag || 0) === 1);
const warningMeta = buildFollowUpWarningMeta({ gmvAlertDays, feeRatioAlertDays, afterSaleAlertDays });
return {
id: target.id,
goodsId: target.goodsId,
goodsName: latestRow.goodsName || '',
shopName: latestRow.shopName || '',
idOwner: latestRow.idOwner || '',
ownerDept: latestRow.ownerDept || '',
status: normalizeMonitorTargetStatus(target.status),
gmvThreshold: target.gmvThreshold,
feeRatioThreshold: target.feeRatioThreshold,
afterSaleRateThreshold: target.afterSaleRateThreshold,
latestStatDate: latestRow.statDate,
latestGmv: latestRow.gmv,
latestFeeRatio: latestRow.feeRatio,
latestAfterSaleRate: latestRow.afterSaleRate,
gmvAlertDays,
feeRatioAlertDays,
afterSaleAlertDays,
warningLevel: warningMeta.warningLevel,
issueSummary: buildIssueSummary({ gmvAlertDays, feeRatioAlertDays, afterSaleAlertDays }),
actionAdvice: warningMeta.actionAdvice,
remark: target.remark || '',
updatedAt: target.updatedAt,
};
}).filter(Boolean);
return {
success: true,
data: {
columns: FOLLOW_UP_MONITOR_COLUMNS,
rows,
total: rows.length,
startDate: latestWindow.startDate,
endDate: latestWindow.latestStatDate,
latestStatDate: latestWindow.latestStatDate,
},
};
}
module.exports = function registerPddFollowUpBoard({ router, authMiddleware }) {
router.get('/pdd/product-dashboard/follow-up-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: '未获取到数据权限' });
}
res.json(await buildFollowUpMonitorList(accessScope));
} catch (error) {
res.status(error.statusCode || 500).json({ success: false, error: error.message });
}
});
router.get('/pdd/product-dashboard/follow-up-board/targets', authMiddleware, async (_req, res) => {
try {
const rows = await listFollowUpMonitorTargets();
res.json({ success: true, data: rows });
} catch (error) {
res.status(error.statusCode || 500).json({ success: false, error: error.message });
}
});
router.post('/pdd/product-dashboard/follow-up-board/targets', authMiddleware, async (req, res) => {
try {
const userId = req.user?.sub || req.user?.id || req.user?.userId || null;
const goodsId = String(req.body?.goodsId || '').trim();
if (!goodsId) {
return res.status(400).json({ success: false, error: 'goodsId is required' });
}
const gmvThreshold = parseThreshold(req.body?.gmvThreshold, DEFAULT_GMV_THRESHOLD);
const feeRatioThreshold = parseThreshold(req.body?.feeRatioThreshold, DEFAULT_FEE_RATIO_THRESHOLD);
const afterSaleRateThreshold = parseThreshold(req.body?.afterSaleRateThreshold, DEFAULT_AFTER_SALE_RATE_THRESHOLD);
validateThresholds({ gmvThreshold, feeRatioThreshold, afterSaleRateThreshold });
const status = normalizeMonitorTargetStatus(req.body?.status);
const remark = String(req.body?.remark || '').trim() || null;
await localDb.query(
`INSERT INTO \`${FOLLOW_UP_TARGET_TABLE}\`
(platform, goods_id, gmv_threshold, fee_ratio_threshold, after_sale_rate_threshold, status, remark, created_by, updated_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
gmv_threshold = VALUES(gmv_threshold),
fee_ratio_threshold = VALUES(fee_ratio_threshold),
after_sale_rate_threshold = VALUES(after_sale_rate_threshold),
status = VALUES(status),
remark = VALUES(remark),
updated_by = VALUES(updated_by)`,
[ PLATFORM, goodsId, gmvThreshold, feeRatioThreshold, afterSaleRateThreshold, status, remark, userId, userId ]
);
res.json({ success: true });
} catch (error) {
res.status(error.statusCode || 500).json({ success: false, error: error.message });
}
});
router.post('/pdd/product-dashboard/follow-up-board/targets/:id/toggle', authMiddleware, async (req, res) => {
try {
const userId = req.user?.sub || req.user?.id || req.user?.userId || null;
const id = Math.max(parseInt(req.params.id || '0', 10), 0);
if (!id) {
return res.status(400).json({ success: false, error: 'id is required' });
}
await localDb.query(
`UPDATE \`${FOLLOW_UP_TARGET_TABLE}\`
SET
status = CASE WHEN status = 'active' THEN 'inactive' ELSE 'active' END,
updated_by = ?
WHERE id = ?
AND platform = ?`,
[ userId, id, PLATFORM ]
);
const rows = await localDb.query(
`SELECT goods_id AS goodsId, status
FROM \`${FOLLOW_UP_TARGET_TABLE}\`
WHERE id = ?
AND platform = ?
LIMIT 1`,
[ id, PLATFORM ]
);
res.json({ success: true });
} catch (error) {
res.status(error.statusCode || 500).json({ success: false, error: error.message });
}
});
router.delete('/pdd/product-dashboard/follow-up-board/targets/:id', authMiddleware, async (req, res) => {
try {
const id = Math.max(parseInt(req.params.id || '0', 10), 0);
if (!id) {
return res.status(400).json({ success: false, error: 'id is required' });
}
await localDb.query(`DELETE FROM \`${FOLLOW_UP_DAILY_TABLE}\` WHERE target_id = ? AND platform = ?`, [ id, PLATFORM ]);
await localDb.query(`DELETE FROM \`${FOLLOW_UP_TARGET_TABLE}\` WHERE id = ? AND platform = ?`, [ id, PLATFORM ]);
res.json({ success: true });
} catch (error) {
res.status(error.statusCode || 500).json({ success: false, error: error.message });
}
});
};