cangku/backend/src/services/followUpMonitorSync.js

346 lines
11 KiB
JavaScript

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