forked from lunanting/cangku
346 lines
11 KiB
JavaScript
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,
|
|
};
|