forked from lunanting/cangku
476 lines
16 KiB
JavaScript
476 lines
16 KiB
JavaScript
'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 });
|
||
}
|
||
});
|
||
};
|