'use strict'; const cloudDb = require('../config/cloudDatabase'); const planningDatabase = require('./planningDatabase'); const { ensurePlanningStockSelectionSchema } = require('./planningSupportSchema'); const localDb = planningDatabase; const BASE_TABLE = 'op_BarcodeChannelDecisionBase'; const RESULT_TABLE = 'op_BarcodeChannelDecisionResult'; const SYNC_LOG_TABLE = 'op_barcode_channel_decision_sync_log'; const DEFAULT_CHUNK_SIZE = Math.max(parseInt(process.env.STOCK_SELECTION_SYNC_CHUNK_SIZE || '1000', 10), 100); const DEFAULT_REQUEST_TIMEOUT_MS = Math.max(parseInt(process.env.STOCK_SELECTION_SYNC_TIMEOUT_MS || '600000', 10), 60000); const CHANNEL_NAME = '拼多多'; 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.execute( `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\`, \`isListedInTmall\`, \`isListedInJingxi\`, \`isListedInAlibaba\`, 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) ); 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..#ChannelListing') IS NOT NULL DROP TABLE #ChannelListing; 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(v.sku_id AS VARCHAR(255)) AS barcode, CAST(MAX(CASE WHEN LTRIM(RTRIM(ISNULL(CAST(v.channel AS NVARCHAR(50)), N''))) = N'Pinduoduo' THEN 1 ELSE 0 END) AS BIT) AS isListedInChannel, CAST(MAX(CASE WHEN LTRIM(RTRIM(ISNULL(CAST(v.channel AS NVARCHAR(50)), N''))) IN (N'Tmall', N'??') THEN 1 ELSE 0 END) AS BIT) AS isListedInTmall, CAST(MAX(CASE WHEN LTRIM(RTRIM(ISNULL(CAST(v.channel AS NVARCHAR(50)), N''))) IN (N'Jingxi', N'??') THEN 1 ELSE 0 END) AS BIT) AS isListedInJingxi, CAST(MAX(CASE WHEN LTRIM(RTRIM(ISNULL(CAST(v.channel AS NVARCHAR(50)), N''))) IN (N'Alibaba', N'1688', N'????') THEN 1 ELSE 0 END) AS BIT) AS isListedInAlibaba INTO #ChannelListing FROM dbo.jst_skumap v WITH (NOLOCK) INNER JOIN #BaseRaw b ON CAST(v.sku_id AS VARCHAR(255)) = b.barcode WHERE ISNULL(CAST(v.enabled AS INT), 0) = 1 GROUP BY CAST(v.sku_id AS VARCHAR(255)); CREATE UNIQUE CLUSTERED INDEX IX_ChannelListing_barcode ON #ChannelListing (barcode); SELECT N'???' AS channelName, b.barcode, ISNULL(c.isListedInChannel, 0) AS isListedInChannel, ISNULL(c.isListedInTmall, 0) AS isListedInTmall, ISNULL(c.isListedInJingxi, 0) AS isListedInJingxi, ISNULL(c.isListedInAlibaba, 0) AS isListedInAlibaba, a.firstListedDate, ISNULL(a.listingShopCount, 0) AS listingShopCount INTO #Base FROM #BaseRaw b LEFT JOIN #ChannelListing c ON c.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'拼多多' 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.channel AS NVARCHAR(50)), N'') = N'Pinduoduo' AND 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.isListedInTmall, b.isListedInJingxi, b.isListedInAlibaba, 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) >= 0.5) { return { ruleCode: '3-4-3', poolType: '\u9009\u54c1\u5f85\u9009\u6c60', ruleTag: '\u6f5c\u529b\u5546\u54c1', ruleExplain: '\u547d\u4e2d3-4-3\uff1a\u672c\u6e20\u9053\u662f\u5426\u4e0a\u67b6\uff08isListedInChannel\uff09=1\uff0c30\u5929\u65e5\u9500\uff08dailySales30d\uff09<=1\uff0c\u4e1430\u5929\u73af\u6bd4\uff08momGrowth30d\uff09>=50%' }; } if (isListedInChannel === 1 && dailySales30d <= 1 && (momGrowth30d ?? 0) > 0 && (momGrowth30d ?? 0) < 0.5) { return { ruleCode: '3-4-4', poolType: '\u9009\u54c1\u5f85\u9009\u6c60', ruleTag: '\u8f7b\u589e\u957f\u5546\u54c1', ruleExplain: '\u547d\u4e2d3-4-4\uff1a\u672c\u6e20\u9053\u662f\u5426\u4e0a\u67b6\uff08isListedInChannel\uff09=1\uff0c30\u5929\u65e5\u9500\uff08dailySales30d\uff09<=1\uff0c\u4e1430\u5929\u73af\u6bd4\uff08momGrowth30d\uff09>0\u4e14<50%' }; } 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 rebuildStockSelectionBase(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 ensurePlanningStockSelectionSchema(); 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), isListedInTmall: row.isListedInTmall === null || row.isListedInTmall === undefined ? null : Number(row.isListedInTmall ? 1 : 0), isListedInJingxi: row.isListedInJingxi === null || row.isListedInJingxi === undefined ? null : Number(row.isListedInJingxi ? 1 : 0), isListedInAlibaba: row.isListedInAlibaba === null || row.isListedInAlibaba === undefined ? null : Number(row.isListedInAlibaba ? 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', 'isListedInTmall', 'isListedInJingxi', 'isListedInAlibaba', '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 rebuildStockSelectionResult(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 ensurePlanningStockSelectionSchema(); 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 syncStockSelectionTables(options = {}) { const baseResult = await rebuildStockSelectionBase({ ...options, syncType: options.syncType ? `${options.syncType}_base` : 'full_base_rebuild', }); const resultResult = await rebuildStockSelectionResult({ ...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 = { syncStockSelectionTables, rebuildStockSelectionBase, rebuildStockSelectionResult, DEFAULT_REQUEST_TIMEOUT_MS, };