'use strict'; const dayjs = require('dayjs'); const dataSource = require('../config/database'); const cloudDb = require('../config/cloudDatabase'); const { ensureLocalSchema } = require('./localSchema'); const localDb = dataSource.local; const FOLLOW_UP_TARGET_TABLE = 'op_follow_up_monitor_targets'; const FOLLOW_UP_DAILY_TABLE = 'op_follow_up_monitor_daily'; const DEFAULT_PLATFORM = 'pdd'; const DEFAULT_GMV_THRESHOLD = 100; const DEFAULT_FEE_RATIO_THRESHOLD = 85; const DEFAULT_AFTER_SALE_RATE_THRESHOLD = 5; const PDD_DATA_GOODS_SCHEMA = 'dbo'; const PDD_DATA_GOODS_TABLE = 'pdd_DataGoods'; const PDD_TARGET_TABLE_FULL_NAME = 'JSDataMiddlePlatform.dbo.op_TargetManagement'; const DEFAULT_REQUEST_TIMEOUT_MS = Math.max(parseInt(process.env.FOLLOW_UP_MONITOR_TIMEOUT_MS || '600000', 10), 120000); const DWS_NON_VIRTUAL_ORDER_FILTER_SQL = ` AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.[ps_payment_classify_isYes]), 0) = 0 AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.[refund_amount_isZc]), 0) = 0 AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.[refund_sale_compensate_isZc]), 0) = 0 AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.[oo_mix_zc_kd]), 0) = 0 AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.[fr_zc_dynamic_express_cost]), 0) = 0 AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.[fr_zcd_pj_fdf_express_cost]), 0) = 0`; function normalizeDate(value) { if (!value) return null; return dayjs(value).format('YYYY-MM-DD'); } function toNumber(value, digits = 2) { const parsed = Number(value || 0); if (!Number.isFinite(parsed)) return 0; return Number(parsed.toFixed(digits)); } function buildInsertSql(tableName, columns, rowCount, keyColumns) { const columnSql = columns.map((column) => `\`${column}\``).join(', '); const rowPlaceholder = `(${columns.map(() => '?').join(', ')})`; const valuesSql = new Array(rowCount).fill(rowPlaceholder).join(', '); const updateSql = columns .filter((column) => !keyColumns.includes(column)) .map((column) => `\`${column}\` = VALUES(\`${column}\`)`) .join(', '); return ` INSERT INTO \`${tableName}\` (${columnSql}) VALUES ${valuesSql} ON DUPLICATE KEY UPDATE ${updateSql} `; } async function upsertRows(tableName, columns, rows, keyColumns) { if (!Array.isArray(rows) || rows.length === 0) { return 0; } const sql = buildInsertSql(tableName, columns, rows.length, keyColumns); const params = []; rows.forEach((row) => { columns.forEach((column) => params.push(row[column] ?? null)); }); await localDb.query(sql, params); return rows.length; } async function getActiveTargets({ platform = DEFAULT_PLATFORM, goodsIds = null } = {}) { const whereParts = [ 'platform = ?', 'status = ?' ]; const params = [ platform, 'active' ]; if (Array.isArray(goodsIds) && goodsIds.length > 0) { whereParts.push(`goods_id IN (${goodsIds.map(() => '?').join(', ')})`); params.push(...goodsIds); } return localDb.query( `SELECT id, platform, goods_id AS goodsId, shop_name AS shopName, sku_code AS skuCode, gmv_threshold AS gmvThreshold, fee_ratio_threshold AS feeRatioThreshold, after_sale_rate_threshold AS afterSaleRateThreshold FROM \`${FOLLOW_UP_TARGET_TABLE}\` WHERE ${whereParts.join(' AND ')} ORDER BY id ASC`, params ); } async function getLatestBizDateForGoodsIds(goodsIds, requestTimeout = DEFAULT_REQUEST_TIMEOUT_MS) { if (!Array.isArray(goodsIds) || goodsIds.length === 0) { return null; } const insertValuesSql = goodsIds.map((_, index) => `(@goodsId${index})`).join(', '); const params = {}; goodsIds.forEach((goodsId, index) => { params[`goodsId${index}`] = String(goodsId || '').trim(); }); const rows = await cloudDb.query( ` SET NOCOUNT ON; IF OBJECT_ID('tempdb..#goods_scope') IS NOT NULL DROP TABLE #goods_scope; CREATE TABLE #goods_scope ([goodsId] NVARCHAR(50) NOT NULL PRIMARY KEY); INSERT INTO #goods_scope ([goodsId]) VALUES ${insertValuesSql}; SELECT MAX(TRY_CONVERT(date, g.[createTime])) AS latestBizDate FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g INNER JOIN #goods_scope gs ON gs.[goodsId] = LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) WHERE TRY_CONVERT(date, g.[createTime]) IS NOT NULL; `, params, { requestTimeout } ); return normalizeDate(rows?.[0]?.latestBizDate || null); } async function fetchPddFollowUpSourceRows({ goodsIds, startDate, endDate, requestTimeout = DEFAULT_REQUEST_TIMEOUT_MS }) { if (!Array.isArray(goodsIds) || goodsIds.length === 0) { return []; } const insertValuesSql = goodsIds.map((_, index) => `(@goodsId${index})`).join(', '); const params = { startDate, endDate, startDateTime: `${startDate} 00:00:00`, endDateExclusive: dayjs(endDate).add(1, 'day').format('YYYY-MM-DD 00:00:00'), }; goodsIds.forEach((goodsId, index) => { params[`goodsId${index}`] = String(goodsId || '').trim(); }); return cloudDb.query( ` SET NOCOUNT ON; IF OBJECT_ID('tempdb..#goods_scope') IS NOT NULL DROP TABLE #goods_scope; IF OBJECT_ID('tempdb..#goods_base') IS NOT NULL DROP TABLE #goods_base; IF OBJECT_ID('tempdb..#dws_agg') IS NOT NULL DROP TABLE #dws_agg; CREATE TABLE #goods_scope ([goodsId] NVARCHAR(50) NOT NULL PRIMARY KEY); INSERT INTO #goods_scope ([goodsId]) VALUES ${insertValuesSql}; SELECT gs.[goodsId], MAX(TRY_CONVERT(NVARCHAR(500), g.[goodsName])) AS [goodsName], MAX(TRY_CONVERT(NVARCHAR(255), g.[shopName])) AS [shopName], MAX(tm.[ider]) AS [idOwner], MAX(tm.[dept]) AS [ownerDept] INTO #goods_base FROM #goods_scope gs LEFT JOIN [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g ON gs.[goodsId] = LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) AND g.[createTime] >= @startDateTime AND g.[createTime] < @endDateExclusive LEFT JOIN ${PDD_TARGET_TABLE_FULL_NAME} tm ON tm.[shopproid] = gs.[goodsId] GROUP BY gs.[goodsId]; CREATE UNIQUE CLUSTERED INDEX IX_follow_up_goods_base_goodsId ON #goods_base([goodsId]); SELECT gb.[goodsId], CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.[real_performance]), 0)) AS DECIMAL(38, 2)) AS [gmv], CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.[real_conclude_fee]), 0)) AS DECIMAL(38, 2)) AS [realConcludeFee], CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.[after_sale_amount]), 0)) AS DECIMAL(38, 2)) AS [afterSaleAmount] INTO #dws_agg FROM #goods_base gb LEFT JOIN [JSDataWarehouse].[dws].[dws_sn_sales_full_info_di] d ON d.[item_id] = CONVERT(VARCHAR(50), gb.[goodsId]) AND d.[stat_date] BETWEEN TRY_CONVERT(DATE, @startDate) AND TRY_CONVERT(DATE, @endDate) AND ISNULL(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), d.[platform]))), N'') IN (N'Pinduoduo', N'拼多多') ${DWS_NON_VIRTUAL_ORDER_FILTER_SQL} GROUP BY gb.[goodsId]; CREATE UNIQUE CLUSTERED INDEX IX_follow_up_dws_agg_goodsId ON #dws_agg([goodsId]); SELECT gb.[goodsId], gb.[goodsName], gb.[shopName], gb.[idOwner], gb.[ownerDept], ISNULL(da.[gmv], 0) AS [gmv], CAST(CASE WHEN ISNULL(da.[gmv], 0) = 0 THEN 0 ELSE ISNULL(da.[realConcludeFee], 0) * 100.0 / NULLIF(da.[gmv], 0) END AS DECIMAL(38, 2)) AS [feeRatio], CAST(CASE WHEN ISNULL(da.[gmv], 0) = 0 THEN 0 ELSE ISNULL(da.[afterSaleAmount], 0) * 100.0 / NULLIF(da.[gmv], 0) END AS DECIMAL(38, 2)) AS [afterSaleRate] FROM #goods_base gb LEFT JOIN #dws_agg da ON da.[goodsId] = gb.[goodsId] ORDER BY gb.[goodsId] ASC; `, params, { requestTimeout } ); } async function deleteExistingDailyRows(statDate, targetIds) { if (!statDate || !Array.isArray(targetIds) || targetIds.length === 0) { return; } await localDb.query( `DELETE FROM \`${FOLLOW_UP_DAILY_TABLE}\` WHERE stat_date = ? AND target_id IN (${targetIds.map(() => '?').join(', ')})`, [ statDate, ...targetIds ] ); } async function syncFollowUpMonitorDailySnapshot({ platform = DEFAULT_PLATFORM, targetDate = null, goodsIds = null, requestTimeout = DEFAULT_REQUEST_TIMEOUT_MS, } = {}) { await ensureLocalSchema(); const targets = await getActiveTargets({ platform, goodsIds }); if (!Array.isArray(targets) || targets.length === 0) { return { success: false, skipped: true, reason: 'no_active_targets', platform }; } const targetGoodsIds = targets.map((item) => String(item.goodsId || '').trim()).filter(Boolean); const statDate = targetDate || await getLatestBizDateForGoodsIds(targetGoodsIds, requestTimeout); if (!statDate) { return { success: false, skipped: true, reason: 'no_biz_date', platform }; } const startDate = dayjs(statDate).subtract(6, 'day').format('YYYY-MM-DD'); const sourceRows = await fetchPddFollowUpSourceRows({ goodsIds: targetGoodsIds, startDate, endDate: statDate, requestTimeout, }); const sourceMap = new Map(); (sourceRows || []).forEach((row) => { const goodsId = String(row.goodsId || '').trim(); if (!goodsId) return; sourceMap.set(goodsId, row); }); const dailyRows = targets .map((target) => { const source = sourceMap.get(String(target.goodsId || '').trim()); if (!source) { return null; } const gmvThreshold = toNumber(target.gmvThreshold || DEFAULT_GMV_THRESHOLD); const feeRatioThreshold = toNumber(target.feeRatioThreshold || DEFAULT_FEE_RATIO_THRESHOLD); const afterSaleRateThreshold = toNumber(target.afterSaleRateThreshold || DEFAULT_AFTER_SALE_RATE_THRESHOLD); const gmv = toNumber(source.gmv); const feeRatio = toNumber(source.feeRatio); const afterSaleRate = toNumber(source.afterSaleRate); return { stat_date: statDate, target_id: target.id, platform, goods_id: target.goodsId, goods_name: source.goodsName || null, shop_name: source.shopName || target.shopName || null, id_owner: source.idOwner || null, owner_dept: source.ownerDept || null, gmv, fee_ratio: feeRatio, after_sale_rate: afterSaleRate, gmv_threshold: gmvThreshold, fee_ratio_threshold: feeRatioThreshold, after_sale_rate_threshold: afterSaleRateThreshold, gmv_alert_flag: gmv < gmvThreshold ? 1 : 0, fee_ratio_alert_flag: feeRatio > feeRatioThreshold ? 1 : 0, after_sale_alert_flag: afterSaleRate > afterSaleRateThreshold ? 1 : 0, }; }) .filter(Boolean); await deleteExistingDailyRows(statDate, targets.map((item) => item.id)); const columns = [ 'stat_date', 'target_id', 'platform', 'goods_id', 'goods_name', 'shop_name', 'id_owner', 'owner_dept', 'gmv', 'fee_ratio', 'after_sale_rate', 'gmv_threshold', 'fee_ratio_threshold', 'after_sale_rate_threshold', 'gmv_alert_flag', 'fee_ratio_alert_flag', 'after_sale_alert_flag', ]; const affectedRows = await upsertRows( FOLLOW_UP_DAILY_TABLE, columns, dailyRows, [ 'stat_date', 'target_id' ] ); return { success: true, platform, statDate, startDate, targetCount: targets.length, rowCount: affectedRows, }; } module.exports = { FOLLOW_UP_TARGET_TABLE, FOLLOW_UP_DAILY_TABLE, DEFAULT_PLATFORM, DEFAULT_GMV_THRESHOLD, DEFAULT_FEE_RATIO_THRESHOLD, DEFAULT_AFTER_SALE_RATE_THRESHOLD, DEFAULT_REQUEST_TIMEOUT_MS, syncFollowUpMonitorDailySnapshot, };