cangku/backend/src/services/jxStockSelectionSync.js

461 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
const dataSource = require('../config/database');
const cloudDb = require('../config/cloudDatabase');
const { ensureLocalSchema } = require('./localSchema');
const localDb = dataSource.local;
const BASE_TABLE = 'op_JxBarcodeChannelDecisionBase';
const RESULT_TABLE = 'op_JxBarcodeChannelDecisionResult';
const SYNC_LOG_TABLE = 'op_jx_barcode_channel_decision_sync_log';
const DEFAULT_CHUNK_SIZE = Math.max(parseInt(process.env.JX_STOCK_SELECTION_SYNC_CHUNK_SIZE || '1000', 10), 100);
const DEFAULT_REQUEST_TIMEOUT_MS = Math.max(parseInt(process.env.JX_STOCK_SELECTION_SYNC_TIMEOUT_MS || '600000', 10), 60000);
const CHANNEL_NAME = '京喜';
const CHANNEL_PLATFORM = '京东供销';
const REDLINE = 0.05;
function formatDate(value) {
if (!value) {
return null;
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return null;
}
const beijingDate = new Date(date.getTime() + (8 * 60 * 60 * 1000));
return beijingDate.toISOString().slice(0, 10);
}
function formatDateTime(value) {
if (!value) {
return null;
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return null;
}
const beijingDate = new Date(date.getTime() + (8 * 60 * 60 * 1000));
return beijingDate.toISOString().slice(0, 19).replace('T', ' ');
}
function toNullableNumber(value, digits = null) {
if (value === null || value === undefined || value === '') {
return null;
}
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return null;
}
return digits === null ? parsed : Number(parsed.toFixed(digits));
}
function buildInsertSql(tableName, columns, rowCount) {
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) => !['statDate', 'channelName', 'barcode'].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, chunkSize) {
if (!Array.isArray(rows) || rows.length === 0) {
return 0;
}
let affected = 0;
for (let index = 0; index < rows.length; index += chunkSize) {
const chunk = rows.slice(index, index + chunkSize);
const sql = buildInsertSql(tableName, columns, chunk.length);
const params = [];
chunk.forEach((row) => {
columns.forEach((column) => params.push(row[column] ?? null));
});
await localDb.query(sql, params);
affected += chunk.length;
}
return affected;
}
async function writeSyncLog(payload) {
await localDb.insert(
`INSERT INTO \`${SYNC_LOG_TABLE}\` (
sync_type,
source_latest_stat_date,
base_row_count,
result_row_count,
status,
error_message,
synced_at
) VALUES (?, ?, ?, ?, ?, ?, NOW())`,
[
payload.syncType,
payload.sourceLatestStatDate,
payload.baseRowCount,
payload.resultRowCount,
payload.status,
payload.errorMessage,
]
);
}
async function getLatestBaseStatDate() {
const rows = await localDb.query(
`SELECT DATE_FORMAT(MAX(\`statDate\`), '%Y-%m-%d') AS latestStatDate FROM \`${BASE_TABLE}\``
);
return rows?.[0]?.latestStatDate || null;
}
async function getBaseRowsByStatDate(statDate) {
if (!statDate) {
return [];
}
return localDb.query(
`
SELECT
\`statDate\`,
\`channelName\`,
\`barcode\`,
\`isListedInChannel\`,
DATE_FORMAT(\`firstListedDate\`, '%Y-%m-%d') AS \`firstListedDate\`,
\`listedDays\`,
\`listingShopCount\`,
\`dailySales30d\`,
\`dailySales90d\`,
\`momGrowth30d\`,
\`realAfterSaleRate90d\`,
\`channelAfterSaleRedline\`
FROM \`${BASE_TABLE}\`
WHERE \`statDate\` = ?
ORDER BY \`barcode\`
`,
[statDate]
);
}
async function fetchBaseRowsFromSource(requestTimeout) {
const sql = `
SET NOCOUNT ON;
DECLARE @statDate DATE =
(
SELECT MAX(d.stat_date)
FROM JSDataWarehouse.dws.dws_sn_sales_full_info_di d
WHERE d.stat_date < CAST(GETDATE() AS DATE)
AND ISNULL(CAST(d.platform AS NVARCHAR(50)), N'') = N'${CHANNEL_PLATFORM}'
);
IF @statDate IS NULL
BEGIN
SET @statDate = CAST(DATEADD(DAY, -1, GETDATE()) AS DATE);
END
IF OBJECT_ID('tempdb..#BaseRaw') IS NOT NULL DROP TABLE #BaseRaw;
IF OBJECT_ID('tempdb..#AllListing') IS NOT NULL DROP TABLE #AllListing;
IF OBJECT_ID('tempdb..#JxListing') IS NOT NULL DROP TABLE #JxListing;
IF OBJECT_ID('tempdb..#Base') IS NOT NULL DROP TABLE #Base;
IF OBJECT_ID('tempdb..#BarcodeSkuidMap') IS NOT NULL DROP TABLE #BarcodeSkuidMap;
IF OBJECT_ID('tempdb..#SalesAgg') IS NOT NULL DROP TABLE #SalesAgg;
SELECT DISTINCT
CAST(s.sku_id AS VARCHAR(255)) AS barcode
INTO #BaseRaw
FROM dbo.jst_sku s WITH (NOLOCK)
WHERE ISNULL(CAST(s.sku_id AS NVARCHAR(255)), N'') <> N''
AND ISNULL(CAST(s.enabled AS INT), 0) = 1
AND ISNULL(CAST(s.other_5 AS NVARCHAR(100)), N'') = N'销售';
CREATE UNIQUE CLUSTERED INDEX IX_BaseRaw_barcode ON #BaseRaw (barcode);
SELECT
CAST(v.sku_id AS VARCHAR(255)) AS barcode,
MIN(CAST(v.shop_created AS DATE)) AS firstListedDate,
COUNT(DISTINCT CAST(v.shop_id AS VARCHAR(50))) AS listingShopCount
INTO #AllListing
FROM dbo.jst_skumap v WITH (NOLOCK)
INNER JOIN #BaseRaw b ON CAST(v.sku_id AS VARCHAR(255)) = b.barcode
WHERE v.shop_created IS NOT NULL
GROUP BY CAST(v.sku_id AS VARCHAR(255));
CREATE UNIQUE CLUSTERED INDEX IX_AllListing_barcode ON #AllListing (barcode);
SELECT
CAST(d.skuid AS VARCHAR(255)) AS barcode,
CAST(1 AS BIT) AS isListedInChannel
INTO #JxListing
FROM JSDataWarehouse.dws.dws_sn_sales_full_info_di d
INNER JOIN #BaseRaw b ON CAST(d.skuid AS VARCHAR(255)) = b.barcode
WHERE ISNULL(CAST(d.platform AS NVARCHAR(50)), N'') = N'${CHANNEL_PLATFORM}'
AND d.stat_date <= @statDate
GROUP BY CAST(d.skuid AS VARCHAR(255));
CREATE UNIQUE CLUSTERED INDEX IX_JxListing_barcode ON #JxListing (barcode);
SELECT
N'${CHANNEL_NAME}' AS channelName,
b.barcode,
ISNULL(j.isListedInChannel, 0) AS isListedInChannel,
a.firstListedDate,
ISNULL(a.listingShopCount, 0) AS listingShopCount
INTO #Base
FROM #BaseRaw b
LEFT JOIN #JxListing j ON j.barcode = b.barcode
LEFT JOIN #AllListing a ON a.barcode = b.barcode;
CREATE UNIQUE CLUSTERED INDEX IX_Base_channel_barcode ON #Base (channelName, barcode);
SELECT DISTINCT
N'${CHANNEL_NAME}' AS channelName,
b.barcode,
CAST(v.shop_sku_id AS VARCHAR(100)) AS skuid
INTO #BarcodeSkuidMap
FROM #BaseRaw b
INNER JOIN dbo.jst_skumap v WITH (NOLOCK)
ON CAST(v.sku_id AS VARCHAR(255)) = b.barcode
WHERE ISNULL(CAST(v.shop_sku_id AS VARCHAR(100)), '') <> '';
CREATE UNIQUE CLUSTERED INDEX IX_BarcodeSkuidMap_channel_barcode_skuid
ON #BarcodeSkuidMap (channelName, barcode, skuid);
SELECT
m.channelName,
m.barcode,
CAST(SUM(CASE WHEN d.stat_date >= DATEADD(DAY, -29, @statDate) THEN ISNULL(TRY_CAST(d.goods_count AS DECIMAL(18, 4)), 0) ELSE 0 END) AS DECIMAL(18, 4)) AS qty30,
CAST(SUM(CASE WHEN d.stat_date >= DATEADD(DAY, -89, @statDate) THEN ISNULL(TRY_CAST(d.goods_count AS DECIMAL(18, 4)), 0) ELSE 0 END) AS DECIMAL(18, 4)) AS qty90,
CAST(SUM(CASE WHEN d.stat_date BETWEEN DATEADD(DAY, -59, @statDate) AND DATEADD(DAY, -30, @statDate) THEN ISNULL(TRY_CAST(d.goods_count AS DECIMAL(18, 4)), 0) ELSE 0 END) AS DECIMAL(18, 4)) AS qtyPrev30,
CAST(SUM(CASE WHEN d.stat_date >= DATEADD(DAY, -89, @statDate) THEN ISNULL(TRY_CAST(d.after_sale_amount AS DECIMAL(18, 6)), 0) ELSE 0 END) AS DECIMAL(18, 6)) AS afterSale90,
CAST(SUM(CASE WHEN d.stat_date >= DATEADD(DAY, -89, @statDate) THEN ISNULL(TRY_CAST(d.sales_amount AS DECIMAL(18, 6)), 0) ELSE 0 END) AS DECIMAL(18, 6)) AS sales90
INTO #SalesAgg
FROM #BarcodeSkuidMap m
LEFT JOIN JSDataWarehouse.dws.dws_sn_sales_full_info_di d
ON d.skuid = m.skuid
AND ISNULL(CAST(d.platform AS NVARCHAR(50)), N'') = m.channelName
AND d.stat_date BETWEEN DATEADD(DAY, -89, @statDate) AND @statDate
GROUP BY m.channelName, m.barcode;
CREATE UNIQUE CLUSTERED INDEX IX_SalesAgg_channel_barcode ON #SalesAgg (channelName, barcode);
SELECT
@statDate AS statDate,
b.channelName,
b.barcode,
b.isListedInChannel,
b.firstListedDate,
CASE WHEN b.firstListedDate IS NULL THEN NULL ELSE DATEDIFF(DAY, b.firstListedDate, @statDate) END AS listedDays,
b.listingShopCount,
CAST(ISNULL(sa.qty30, 0) / 30.0 AS DECIMAL(18, 4)) AS dailySales30d,
CAST(ISNULL(sa.qty90, 0) / 90.0 AS DECIMAL(18, 4)) AS dailySales90d,
CAST(
CASE
WHEN ISNULL(sa.qtyPrev30, 0) = 0 THEN NULL
ELSE ((ISNULL(sa.qty30, 0) / 30.0) - (ISNULL(sa.qtyPrev30, 0) / 30.0)) / NULLIF((ISNULL(sa.qtyPrev30, 0) / 30.0), 0)
END
AS DECIMAL(18, 4)
) AS momGrowth30d,
CAST(ISNULL(sa.afterSale90, 0) / NULLIF(ISNULL(sa.sales90, 0), 0) AS DECIMAL(18, 6)) AS realAfterSaleRate90d,
CAST(0.05 AS DECIMAL(18, 6)) AS channelAfterSaleRedline
FROM #Base b
LEFT JOIN #SalesAgg sa ON sa.channelName = b.channelName AND sa.barcode = b.barcode
ORDER BY b.barcode;
`;
return cloudDb.query(sql, {}, { requestTimeout });
}
function getRuleOutcome(baseRow) {
const isListedInChannel = Number(baseRow.isListedInChannel || 0);
const listedDays = toNullableNumber(baseRow.listedDays, 0) ?? 0;
const listingShopCount = toNullableNumber(baseRow.listingShopCount, 0) ?? 0;
const dailySales30d = toNullableNumber(baseRow.dailySales30d, 4) ?? 0;
const dailySales90d = toNullableNumber(baseRow.dailySales90d, 4) ?? 0;
const momGrowth30d = toNullableNumber(baseRow.momGrowth30d, 4);
const realAfterSaleRate90d = toNullableNumber(baseRow.realAfterSaleRate90d, 6) ?? 0;
if (realAfterSaleRate90d > 0.05) {
return { ruleCode: '3-9', poolType: '禁止选品', ruleTag: '高售后,禁止选品', ruleExplain: '命中3-9:90天真实售后率(realAfterSaleRate90d)>5%' };
}
if (dailySales30d === 0 && dailySales90d === 0 && momGrowth30d === null && listedDays >= 30 && listingShopCount >= 200) {
return { ruleCode: '3-8', poolType: '企划复盘', ruleTag: '企划复盘-产品,策划', ruleExplain: '命中3-8:30天日销(dailySales30d)=0,90天日销(dailySales90d)=0,30天环比(momGrowth30d)为NULL,且全渠道首次铺货天数(listedDays)>=30,铺货数量(listingShopCount)>=200' };
}
if (dailySales30d === 0 && dailySales90d === 0 && momGrowth30d === null && listedDays >= 30 && listingShopCount >= 1 && listingShopCount < 200) {
return { ruleCode: '3-8-1', poolType: '企划复盘', ruleTag: '企划复盘-铺货渠道', ruleExplain: '命中3-8-1:30天日销(dailySales30d)=0,90天日销(dailySales90d)=0,30天环比(momGrowth30d)为NULL,且全渠道首次铺货天数(listedDays)>=30,铺货数量(listingShopCount)<200且>=1' };
}
if (isListedInChannel === 1 && dailySales30d > 1 && (momGrowth30d ?? 0) > 0.5 && realAfterSaleRate90d < 0.05) {
return { ruleCode: '3-4', poolType: '优质选品待选池', ruleTag: 'gmv高,环比增长中,售后符合费比【优质品】', ruleExplain: '命中3-4:本渠道是否上架(isListedInChannel)=1,30天日销(dailySales30d)>1,30天环比(momGrowth30d)>0.5,且90天真实售后率(realAfterSaleRate90d)<5%' };
}
if (isListedInChannel === 1 && dailySales30d > 1 && (momGrowth30d ?? -99999) < 0.5 && realAfterSaleRate90d < 0.05) {
return { ruleCode: '3-4-1', poolType: '选品待选池', ruleTag: 'gmv高,无增长,售后符合费比【普通品】', ruleExplain: '命中3-4-1:本渠道是否上架(isListedInChannel)=1,30天日销(dailySales30d)>1,30天环比(momGrowth30d)<0.5,且90天真实售后率(realAfterSaleRate90d)<5%' };
}
if (isListedInChannel === 1 && dailySales30d <= 1 && (momGrowth30d === 0 || momGrowth30d === null) && listedDays > 30) {
return { ruleCode: '3-4-2', poolType: '清货待选池', ruleTag: 'gmv下滑中,低日销,请立即清货', ruleExplain: '命中3-4-2:本渠道是否上架(isListedInChannel)=1,30天日销(dailySales30d)<=1,且30天环比(momGrowth30d)=0或NULL,且全渠道首次铺货天数(listedDays)>30' };
}
if (isListedInChannel === 1 && dailySales30d <= 1 && (momGrowth30d ?? 0) < 0) {
return { ruleCode: '3-5', poolType: '清货待选池', ruleTag: 'gmv下滑中,低日销,请及时清货', ruleExplain: '命中3-5:本渠道是否上架(isListedInChannel)=1,30天日销(dailySales30d)<=1,且30天环比(momGrowth30d)<0' };
}
if (isListedInChannel === 0 && listingShopCount >= 1 && listedDays <= 15) {
return { ruleCode: '3-3-1', poolType: '选品待选池', ruleTag: '新品,可铺货,注意控价', ruleExplain: '命中3-3-1:本渠道是否上架(isListedInChannel)=0,且其他渠道已有铺货(listingShopCount)>=1,且全渠道首次铺货天数(listedDays)<=15' };
}
if (isListedInChannel === 0 && dailySales30d <= 1) {
return { ruleCode: '3-2', poolType: '选品待选池', ruleTag: 'gmv低,售后不判定', ruleExplain: '命中3-2:本渠道是否上架(isListedInChannel)=0,且30天日销(dailySales30d)<=1' };
}
if (isListedInChannel === 0 && dailySales30d > 1 && realAfterSaleRate90d < 0.05) {
return { ruleCode: '3-3', poolType: '选品待选池', ruleTag: 'gmv高,售后符合渠道规定', ruleExplain: '命中3-3:本渠道是否上架(isListedInChannel)=0,30天日销(dailySales30d)>1,且90天真实售后率(realAfterSaleRate90d)<5%' };
}
return {
ruleCode: 'NA',
poolType: '未命中',
ruleTag: '未命中规则',
ruleExplain:
`未命中:本渠道是否上架(isListedInChannel)=${isListedInChannel}` +
`;30天日销(dailySales30d)=${dailySales30d.toFixed(4)}` +
`;90天日销(dailySales90d)=${dailySales90d.toFixed(4)}` +
`;30天环比(momGrowth30d)=${momGrowth30d === null ? 'NULL' : momGrowth30d.toFixed(4)}` +
`;90天真实售后率(realAfterSaleRate90d)=${realAfterSaleRate90d.toFixed(6)}` +
`;全渠道首次铺货天数(listedDays)=${listedDays}` +
`;全渠道铺货数量(listingShopCount)=${listingShopCount}` +
`;售后红线=${REDLINE.toFixed(6)}`,
};
}
function buildResultRows(baseRows, nowText) {
return baseRows.map((row) => ({
statDate: row.statDate,
channelName: row.channelName,
barcode: row.barcode,
...getRuleOutcome(row),
createdAt: nowText,
updatedAt: nowText,
}));
}
async function rebuildJxStockSelectionBase(options = {}) {
const syncType = options.syncType || 'base_rebuild';
const chunkSize = Math.max(parseInt(options.chunkSize || DEFAULT_CHUNK_SIZE, 10), 100);
const requestTimeout = Math.max(parseInt(options.requestTimeout || DEFAULT_REQUEST_TIMEOUT_MS, 10), 60000);
const onProgress = typeof options.onProgress === 'function' ? options.onProgress : null;
const startedAt = Date.now();
let sourceLatestStatDate = null;
let baseRowCount = 0;
try {
await ensureLocalSchema();
onProgress?.({ stage: 'schema', message: '系统库表结构检查完成' });
const sourceRows = await fetchBaseRowsFromSource(requestTimeout);
const nowText = formatDateTime(new Date());
const baseRows = sourceRows.map((row) => ({
statDate: formatDate(row.statDate),
channelName: row.channelName || CHANNEL_NAME,
barcode: row.barcode,
isListedInChannel: row.isListedInChannel === null || row.isListedInChannel === undefined ? null : Number(row.isListedInChannel ? 1 : 0),
firstListedDate: formatDate(row.firstListedDate),
listedDays: toNullableNumber(row.listedDays, 0),
listingShopCount: toNullableNumber(row.listingShopCount, 0),
dailySales30d: toNullableNumber(row.dailySales30d, 4),
dailySales90d: toNullableNumber(row.dailySales90d, 4),
momGrowth30d: toNullableNumber(row.momGrowth30d, 4),
realAfterSaleRate90d: toNullableNumber(row.realAfterSaleRate90d, 6),
channelAfterSaleRedline: toNullableNumber(row.channelAfterSaleRedline, 6) ?? REDLINE,
createdAt: nowText,
updatedAt: nowText,
}));
sourceLatestStatDate = baseRows[0]?.statDate || null;
onProgress?.({ stage: 'read-base', rowCount: baseRows.length, sourceLatestStatDate });
if (sourceLatestStatDate) {
await localDb.query(`DELETE FROM \`${BASE_TABLE}\` WHERE \`statDate\` = ?`, [sourceLatestStatDate]);
}
baseRowCount = await upsertRows(
BASE_TABLE,
['statDate', 'channelName', 'barcode', 'isListedInChannel', 'firstListedDate', 'listedDays', 'listingShopCount', 'dailySales30d', 'dailySales90d', 'momGrowth30d', 'realAfterSaleRate90d', 'channelAfterSaleRedline', 'createdAt', 'updatedAt'],
baseRows,
chunkSize
);
const elapsedMs = Date.now() - startedAt;
await writeSyncLog({ syncType, sourceLatestStatDate, baseRowCount, resultRowCount: 0, status: 'success', errorMessage: null });
return { success: true, syncType, sourceLatestStatDate, baseRowCount, resultRowCount: 0, elapsedMs };
} catch (error) {
await writeSyncLog({ syncType, sourceLatestStatDate, baseRowCount, resultRowCount: 0, status: 'failed', errorMessage: String(error.message || error).slice(0, 2000) });
throw error;
}
}
async function rebuildJxStockSelectionResult(options = {}) {
const syncType = options.syncType || 'result_rebuild';
const chunkSize = Math.max(parseInt(options.chunkSize || DEFAULT_CHUNK_SIZE, 10), 100);
const onProgress = typeof options.onProgress === 'function' ? options.onProgress : null;
const startedAt = Date.now();
let sourceLatestStatDate = null;
let resultRowCount = 0;
let baseRowCount = 0;
try {
await ensureLocalSchema();
onProgress?.({ stage: 'schema', message: '系统库表结构检查完成' });
sourceLatestStatDate = await getLatestBaseStatDate();
if (!sourceLatestStatDate) {
throw new Error('系统库底表暂无数据,无法生成结果表');
}
const baseRows = await getBaseRowsByStatDate(sourceLatestStatDate);
baseRowCount = baseRows.length;
const nowText = formatDateTime(new Date());
const resultRows = buildResultRows(baseRows, nowText);
onProgress?.({ stage: 'build-result', rowCount: resultRows.length, sourceLatestStatDate });
await localDb.query(`DELETE FROM \`${RESULT_TABLE}\` WHERE \`statDate\` = ?`, [sourceLatestStatDate]);
resultRowCount = await upsertRows(
RESULT_TABLE,
['statDate', 'channelName', 'barcode', 'ruleCode', 'poolType', 'ruleTag', 'ruleExplain', 'createdAt', 'updatedAt'],
resultRows,
chunkSize
);
const elapsedMs = Date.now() - startedAt;
await writeSyncLog({ syncType, sourceLatestStatDate, baseRowCount, resultRowCount, status: 'success', errorMessage: null });
return { success: true, syncType, sourceLatestStatDate, baseRowCount, resultRowCount, elapsedMs };
} catch (error) {
await writeSyncLog({ syncType, sourceLatestStatDate, baseRowCount, resultRowCount, status: 'failed', errorMessage: String(error.message || error).slice(0, 2000) });
throw error;
}
}
async function syncJxStockSelectionTables(options = {}) {
const baseResult = await rebuildJxStockSelectionBase({
...options,
syncType: options.syncType ? `${options.syncType}_base` : 'full_base_rebuild',
});
const resultResult = await rebuildJxStockSelectionResult({
...options,
syncType: options.syncType ? `${options.syncType}_result` : 'full_result_rebuild',
});
return {
success: true,
syncType: options.syncType || 'full',
sourceLatestStatDate: resultResult.sourceLatestStatDate || baseResult.sourceLatestStatDate,
baseRowCount: baseResult.baseRowCount,
resultRowCount: resultResult.resultRowCount,
elapsedMs: (baseResult.elapsedMs || 0) + (resultResult.elapsedMs || 0),
};
}
module.exports = {
syncJxStockSelectionTables,
rebuildJxStockSelectionBase,
rebuildJxStockSelectionResult,
DEFAULT_REQUEST_TIMEOUT_MS,
};