cangku/backend/src/services/jdFeeMonitor.js

659 lines
19 KiB
JavaScript

'use strict';
const dayjs = require('dayjs');
const sql = require('mssql');
const database = require('../config/database');
const cloudDb = require('../config/cloudDatabase');
const SOURCE_TABLE = 'dws_sn_sales_full_info_di';
const SOURCE_FQN = `[JSDataWarehouse].[dws].[${SOURCE_TABLE}]`;
const LOCAL_ALERT_TABLE = 'jd_fee_monitor_alerts';
const LOCAL_CONFIG_TABLE = 'jd_fee_monitor_config';
const DEFAULT_CONFIG = {
target_fee_ratio: 0.76,
platform: '京东供销',
listed_days_threshold: 30,
};
function normalizeText(value) {
if (value === null || value === undefined) {
return '';
}
return String(value).trim();
}
function toNumber(value, defaultValue = null) {
const num = Number(value);
return Number.isFinite(num) ? num : defaultValue;
}
function parsePercent(value, fallback) {
const num = Number(value);
if (!Number.isFinite(num)) {
return fallback;
}
return num > 1 ? num / 100 : num;
}
function normalizeRatio(value, defaultValue = null) {
const num = Number(value);
if (!Number.isFinite(num)) {
return defaultValue;
}
if (num > 1 && num <= 100) {
return num / 100;
}
return num;
}
function hasDirectSourceConfig() {
return Boolean(process.env.CLOUD_DB_HOST && process.env.CLOUD_DB_NAME && process.env.CLOUD_DB_USER);
}
function buildDirectSourceConfig() {
if (!hasDirectSourceConfig()) {
return null;
}
return {
server: process.env.CLOUD_DB_HOST,
port: Number.parseInt(process.env.CLOUD_DB_PORT || '1433', 10) || 1433,
database: process.env.CLOUD_DB_NAME,
user: process.env.CLOUD_DB_USER,
password: process.env.CLOUD_DB_PASSWORD || '',
options: {
encrypt: String(process.env.CLOUD_DB_SSL || '').toLowerCase() === 'true',
trustServerCertificate: true,
},
connectionTimeout: Number.parseInt(process.env.CLOUD_DB_CONNECT_TIMEOUT_MS || '30000', 10) || 30000,
requestTimeout: Number.parseInt(process.env.CLOUD_DB_REQUEST_TIMEOUT_MS || '600000', 10) || 600000,
};
}
async function runSourceQuery(sqlText, params = {}, missingMessage = 'source unavailable') {
const directConfig = buildDirectSourceConfig();
if (directConfig) {
let pool = null;
try {
pool = await sql.connect(directConfig);
const request = pool.request();
for (const [key, value] of Object.entries(params || {})) {
request.input(key, value);
}
const result = await request.query(cloudDb.rewriteReportReplicaSql(sqlText, {
database: directConfig.database,
}));
return { ok: true, rows: result.recordset || [], message: '' };
} catch (error) {
return {
ok: false,
rows: [],
message: missingMessage || error.message || 'source unavailable',
};
} finally {
if (pool) {
try {
await pool.close();
} catch {
// ignore close errors
}
try {
sql.close();
} catch {
// ignore close errors
}
}
}
}
try {
const rows = await cloudDb.query(sqlText, params);
return { ok: true, rows: rows || [], message: '' };
} catch (error) {
return {
ok: false,
rows: [],
message: missingMessage || error.message || 'source unavailable',
};
}
}
function classifyRisk(feeRatioGap) {
if (feeRatioGap === null || feeRatioGap === undefined || Number.isNaN(feeRatioGap)) {
return {
risk_level: 'missing_source',
risk_label: '待补数',
};
}
if (feeRatioGap <= -0.5) {
return {
risk_level: 'severe_over_fee',
risk_label: '严重超费比,请立即排查,减少高费比情况',
};
}
if (feeRatioGap >= -0.5 && feeRatioGap < -0.2) {
return {
risk_level: 'over_fee',
risk_label: '费比超过目标费比,需排查核对数据',
};
}
if (feeRatioGap > 0.05) {
return {
risk_level: 'optimize_conversion',
risk_label: '建议优化转化率',
};
}
if (feeRatioGap > 0) {
return {
risk_level: 'safe_expand',
risk_label: '酌情放大转化率',
};
}
return {
risk_level: 'neutral',
risk_label: '刚好打平',
};
}
function buildFollowUpHints(realPerformance) {
const hints = [];
if (realPerformance !== null && realPerformance !== undefined) {
hints.push(realPerformance < 1000 ? '真实业绩偏小,先判断是否值得继续加投或扩量' : '真实业绩有基础,可优先考虑放量或精细化优化');
} else {
hints.push('真实业绩缺失,建议补数后再判断放量价值');
}
return hints;
}
function normalizeConfigRows(rows) {
const config = { ...DEFAULT_CONFIG };
for (const row of rows || []) {
const key = normalizeText(row.config_key).toLowerCase();
if (!key) continue;
if (key === 'target_fee_ratio') {
config.target_fee_ratio = parsePercent(row.config_value, DEFAULT_CONFIG.target_fee_ratio);
} else if (key === 'platform') {
config.platform = normalizeText(row.config_value) || DEFAULT_CONFIG.platform;
} else if (key === 'listed_days_threshold') {
config.listed_days_threshold = Math.max(
1,
Number.parseInt(row.config_value, 10) || DEFAULT_CONFIG.listed_days_threshold
);
}
}
return config;
}
async function getFeeMonitorConfig() {
const rows = await database.local.query(
`SELECT config_key, config_value, remark, updated_at
FROM ${LOCAL_CONFIG_TABLE}
ORDER BY config_key ASC`
);
return normalizeConfigRows(rows || []);
}
async function upsertFeeMonitorConfig(input = {}, operatorId = null) {
const entries = [
['target_fee_ratio', parsePercent(input.targetFeeRatio ?? input.target_fee_ratio, DEFAULT_CONFIG.target_fee_ratio)],
['platform', normalizeText(input.platform) || DEFAULT_CONFIG.platform],
[
'listed_days_threshold',
Math.max(
1,
Number.parseInt(input.listedDaysThreshold ?? input.listed_days_threshold, 10) ||
DEFAULT_CONFIG.listed_days_threshold
),
],
];
for (const [configKey, configValue] of entries) {
await database.local.query(
`
INSERT INTO ${LOCAL_CONFIG_TABLE} (config_key, config_value, remark, updated_by)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
config_value = VALUES(config_value),
remark = VALUES(remark),
updated_by = VALUES(updated_by),
updated_at = CURRENT_TIMESTAMP
`,
[configKey, String(configValue), normalizeText(input.remark) || null, operatorId || null]
);
}
return getFeeMonitorConfig();
}
async function buildSourceSnapshotQuery({ platform, listedDaysThreshold }) {
const sourceSql = `
WITH source_base AS (
SELECT
TRY_CONVERT(date, d.stat_date) AS stat_date,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.skuoid))), '') AS skuoid,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.item_id))), '') AS item_id,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.ider))), '') AS ider,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.cater))), '') AS cater,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.dept_two))), '') AS dept_two,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(200), d.shop_name))), '') AS shop_name,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), d.platform))), '') AS platform,
TRY_CONVERT(DECIMAL(18, 4), d.real_conclude_fee) AS real_conclude_fee,
TRY_CONVERT(DECIMAL(18, 4), d.real_performance) AS real_performance
FROM ${SOURCE_FQN} d WITH (NOLOCK)
WHERE NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), d.platform))), '') = @platform
AND NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.skuoid))), '') IS NOT NULL
AND TRY_CONVERT(date, d.stat_date) IS NOT NULL
),
latest_stat AS (
SELECT MAX(stat_date) AS stat_date
FROM source_base
),
first_listed AS (
SELECT
skuoid,
MAX(item_id) AS item_id,
MAX(ider) AS ider,
MAX(cater) AS cater,
MAX(dept_two) AS dept_two,
shop_name,
platform,
MIN(stat_date) AS first_stat_date
FROM source_base
GROUP BY skuoid, shop_name, platform
),
agg AS (
SELECT
b.stat_date,
b.skuoid,
COALESCE(MAX(b.item_id), b.skuoid) AS item_id,
MAX(b.ider) AS ider,
MAX(b.cater) AS cater,
MAX(b.dept_two) AS dept_two,
b.shop_name,
b.platform,
fl.first_stat_date,
DATEDIFF(DAY, fl.first_stat_date, b.stat_date) AS listed_days,
SUM(ISNULL(b.real_conclude_fee, 0)) AS real_conclude_fee,
SUM(ISNULL(b.real_performance, 0)) AS real_performance
FROM source_base b
INNER JOIN latest_stat ls ON b.stat_date = ls.stat_date
INNER JOIN first_listed fl
ON fl.skuoid = b.skuoid
AND fl.shop_name = b.shop_name
AND fl.platform = b.platform
GROUP BY b.stat_date, b.skuoid, b.shop_name, b.platform, fl.first_stat_date
)
SELECT
stat_date,
skuoid,
item_id,
ider,
cater,
dept_two,
shop_name,
platform,
listed_days,
real_conclude_fee,
real_performance,
CASE
WHEN real_performance IS NULL OR real_performance = 0 THEN NULL
ELSE real_conclude_fee / NULLIF(real_performance, 0)
END AS real_fee_ratio
FROM agg
WHERE listed_days >= @listedDaysThreshold
ORDER BY stat_date DESC, real_fee_ratio ASC, real_conclude_fee DESC, skuoid ASC, shop_name ASC
`;
return {
ok: true,
sql: sourceSql,
params: {
platform,
listedDaysThreshold,
},
message: '',
};
}
function formatMonitorRow(row, targetFeeRatio) {
const realConcludeFee = toNumber(row.real_conclude_fee, null);
const realPerformance = toNumber(row.real_performance, null);
const realFeeRatio =
normalizeRatio(row.real_fee_ratio, null) ??
(realConcludeFee !== null && realPerformance ? realConcludeFee / realPerformance : null);
const feeRatioGap = realFeeRatio === null ? null : Number((targetFeeRatio - realFeeRatio).toFixed(4));
const risk = classifyRisk(feeRatioGap);
const statDate = row.stat_date ? dayjs(row.stat_date).format('YYYY-MM-DD') : null;
const skuoid = normalizeText(row.skuoid);
const itemId = normalizeText(row.item_id) || skuoid || null;
const ider = normalizeText(row.ider) || null;
const cater = normalizeText(row.cater) || null;
const deptTwo = normalizeText(row.dept_two) || null;
const shopName = normalizeText(row.shop_name) || null;
return {
stat_date: statDate,
skuoid,
item_id: itemId,
procode: itemId || skuoid,
ider,
cater,
dept_two: deptTwo,
shop_name: shopName,
listed_days: toNumber(row.listed_days, null),
target_fee_ratio: Number(targetFeeRatio.toFixed(4)),
real_conclude_fee: realConcludeFee,
real_performance: realPerformance,
real_fee_ratio: realFeeRatio,
fee_ratio_gap: feeRatioGap,
gmv: realPerformance,
owner: null,
dept: null,
risk_level: risk.risk_level,
risk_label: risk.risk_label,
follow_up_json: JSON.stringify(buildFollowUpHints(realPerformance)),
};
}
async function refreshFeeMonitorAlerts(input = {}, operatorId = null) {
const config = await getFeeMonitorConfig();
const platform = normalizeText(input.platform) || config.platform;
const targetFeeRatio = parsePercent(input.targetFeeRatio ?? input.target_fee_ratio, config.target_fee_ratio);
const listedDaysThreshold = Math.max(
1,
Number.parseInt(input.listedDaysThreshold ?? input.listed_days_threshold, 10) || config.listed_days_threshold
);
const sourceQuery = await buildSourceSnapshotQuery({ platform, listedDaysThreshold });
if (!sourceQuery.ok) {
return sourceQuery;
}
const sourceRowsResult = await runSourceQuery(sourceQuery.sql, sourceQuery.params, sourceQuery.message);
if (!sourceRowsResult.ok) {
return sourceRowsResult;
}
const formattedRows = sourceRowsResult.rows.map((row) => formatMonitorRow(row, targetFeeRatio));
await database.local.query(`DELETE FROM ${LOCAL_ALERT_TABLE} WHERE platform = ?`, [platform]);
for (const row of formattedRows) {
await database.local.query(
`
INSERT INTO ${LOCAL_ALERT_TABLE} (
stat_date, platform, skuoid, item_id, procode, ider, cater, dept_two, shop_name, listed_days,
target_fee_ratio, real_conclude_fee, real_performance, real_fee_ratio, fee_ratio_gap,
click_rate, conversion_rate, gmv,
risk_level, risk_label, follow_up_json, owner, dept, created_by, updated_by
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
skuoid = VALUES(skuoid),
item_id = VALUES(item_id),
procode = VALUES(procode),
ider = VALUES(ider),
cater = VALUES(cater),
dept_two = VALUES(dept_two),
shop_name = VALUES(shop_name),
listed_days = VALUES(listed_days),
target_fee_ratio = VALUES(target_fee_ratio),
real_conclude_fee = VALUES(real_conclude_fee),
real_performance = VALUES(real_performance),
real_fee_ratio = VALUES(real_fee_ratio),
fee_ratio_gap = VALUES(fee_ratio_gap),
click_rate = VALUES(click_rate),
conversion_rate = VALUES(conversion_rate),
gmv = VALUES(gmv),
risk_level = VALUES(risk_level),
risk_label = VALUES(risk_label),
follow_up_json = VALUES(follow_up_json),
owner = VALUES(owner),
dept = VALUES(dept),
updated_by = VALUES(updated_by),
updated_at = CURRENT_TIMESTAMP
`,
[
row.stat_date,
row.platform || platform,
row.skuoid,
row.item_id,
row.procode || row.item_id || row.skuoid || '',
row.ider,
row.cater,
row.dept_two,
row.shop_name || '',
row.listed_days,
row.target_fee_ratio,
row.real_conclude_fee,
row.real_performance,
row.real_fee_ratio,
row.fee_ratio_gap,
row.click_rate,
row.conversion_rate,
row.gmv,
row.risk_level,
row.risk_label,
row.follow_up_json,
row.owner,
row.dept,
operatorId || null,
operatorId || null,
]
);
}
return {
ok: true,
message: '费比监控刷新成功',
inserted: formattedRows.length,
config: {
target_fee_ratio: Number(targetFeeRatio.toFixed(4)),
platform,
listed_days_threshold: listedDaysThreshold,
},
};
}
async function listFeeMonitorAlerts(query = {}) {
const page = Math.max(1, Number.parseInt(query.page, 10) || 1);
const pageSize = Math.max(1, Math.min(200, Number.parseInt(query.pageSize, 10) || 20));
const offset = (page - 1) * pageSize;
const where = [];
const params = [];
const statDateStart = normalizeText(query.startDate || query.statDateStart);
const statDateEnd = normalizeText(query.endDate || query.statDateEnd);
const platform = normalizeText(query.platform);
const skuoid = normalizeText(query.skuoid || query.itemId || query.goodsId);
const shopName = normalizeText(query.shopName);
const riskLevel = normalizeText(query.riskLevel);
const owner = normalizeText(query.owner);
const dept = normalizeText(query.dept);
if (statDateStart) {
where.push('stat_date >= ?');
params.push(statDateStart);
}
if (statDateEnd) {
where.push('stat_date <= ?');
params.push(statDateEnd);
}
if (platform) {
where.push('platform = ?');
params.push(platform);
}
if (skuoid) {
where.push('(skuoid = ? OR item_id = ?)');
params.push(skuoid, skuoid);
}
if (shopName) {
where.push('shop_name = ?');
params.push(shopName);
}
if (riskLevel) {
where.push('risk_level = ?');
params.push(riskLevel);
}
if (owner) {
where.push('owner = ?');
params.push(owner);
}
if (dept) {
where.push('dept = ?');
params.push(dept);
}
const countRows = await database.local.query(
`
SELECT COUNT(1) AS total
FROM ${LOCAL_ALERT_TABLE}
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
`,
params
);
const total = Number(countRows?.[0]?.total || 0);
const rows = await database.local.query(
`
SELECT
id,
stat_date AS statDate,
platform,
skuoid,
item_id AS itemId,
procode,
ider,
cater,
dept_two AS deptTwo,
shop_name AS shopName,
listed_days AS listedDays,
target_fee_ratio AS targetFeeRatio,
real_conclude_fee AS realConcludeFee,
real_performance AS realPerformance,
real_fee_ratio AS realFeeRatio,
fee_ratio_gap AS feeRatioGap,
click_rate AS clickRate,
conversion_rate AS conversionRate,
gmv,
risk_level AS riskLevel,
risk_label AS riskLabel,
follow_up_json AS followUpJson,
owner,
dept,
created_at AS createdAt,
updated_at AS updatedAt
FROM ${LOCAL_ALERT_TABLE}
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
ORDER BY stat_date DESC, fee_ratio_gap ASC, real_conclude_fee DESC, id DESC
LIMIT ? OFFSET ?
`,
[...params, pageSize, offset]
);
return {
rows: rows || [],
total,
page,
pageSize,
};
}
async function getFeeMonitorDetail(query = {}) {
const skuoid = normalizeText(query.skuoid || query.itemId || query.goodsId);
const shopName = normalizeText(query.shopName);
const procode = normalizeText(query.procode);
const where = [];
const params = [];
if (skuoid) {
where.push('(skuoid = ? OR item_id = ?)');
params.push(skuoid, skuoid);
}
if (shopName) {
where.push('shop_name = ?');
params.push(shopName);
}
if (procode) {
where.push('procode = ?');
params.push(procode);
}
const rows = await database.local.query(
`
SELECT
id,
stat_date AS statDate,
platform,
skuoid,
item_id AS itemId,
procode,
ider,
cater,
dept_two AS deptTwo,
shop_name AS shopName,
listed_days AS listedDays,
real_conclude_fee AS realConcludeFee,
real_performance AS realPerformance,
real_fee_ratio AS realFeeRatio,
fee_ratio_gap AS feeRatioGap,
click_rate AS clickRate,
conversion_rate AS conversionRate,
gmv,
risk_level AS riskLevel,
risk_label AS riskLabel,
follow_up_json AS followUpJson,
owner,
dept,
created_at AS createdAt,
updated_at AS updatedAt
FROM ${LOCAL_ALERT_TABLE}
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
ORDER BY stat_date DESC, id DESC
LIMIT 100
`,
params
);
return rows || [];
}
function getFeeMonitorMeta() {
return {
boardKey: 'fee-monitor',
title: '费比监控',
description: '监控京东供销铺货超过30天的 skuoid 费比差值,按费用合计 / 真实业绩 判定风险等级',
defaultDateRangeDays: 7,
supportedFilters: ['platform', 'shopName', 'skuoid', 'itemId', 'riskLevel', 'owner', 'dept'],
calcStatusDefault: 'ok',
defaultConfig: DEFAULT_CONFIG,
};
}
module.exports = {
DEFAULT_CONFIG,
getFeeMonitorConfig,
getFeeMonitorDetail,
getFeeMonitorMeta,
listFeeMonitorAlerts,
refreshFeeMonitorAlerts,
upsertFeeMonitorConfig,
};