461 lines
20 KiB
JavaScript
461 lines
20 KiB
JavaScript
'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,
|
||
};
|