'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, };