const cloudDb = require('../config/cloudDatabase'); const planningDatabase = require('./planningDatabase'); const { getPlanningMayProductDataSummary, MAY_PRODUCT_SNAPSHOT_TABLE, MAY_PRODUCT_REVIEW_SNAPSHOT_TABLE, } = require('./planningMayProductDataService'); const CHANNEL_ID_READ_TABLE = 'channel_id_daily_read'; const TMALL_LISTING_MAP_TABLE = 'jst_skumap_qmcust_add'; const PDD_LISTING_MAP_TABLE = 'jst_skumap_pdd'; const GENERIC_LISTING_MAP_TABLE = 'jst_skumap'; const SHOP_INFO_TABLE = 'jst_shopInfo'; const TARGET_MANAGEMENT_TABLE = 'op_TargetManagement'; const MAY_CHANNEL_ACTIVE_SKU_TABLE = 'planning_may_channel_active_sku_index_di'; const MAY_CHANNEL_SHOP_SNAPSHOT_TABLE = 'planning_may_channel_shop_snapshot_di'; const MAY_CHANNEL_LAYOUT_SNAPSHOT_TABLE = 'planning_may_channel_layout_snapshot_di'; const MAY_CHANNEL_INSIGHT_CALC_VERSION = 'may-channel-insight-v20260430-v9'; const TMALL_LISTING_SHOP_CHUNK_SIZE = Number.parseInt(process.env.PLANNING_MAY_CHANNEL_TMALL_LISTING_SHOP_CHUNK_SIZE || '20', 10); const PDD_LISTING_SHOP_CHUNK_SIZE = Number.parseInt(process.env.PLANNING_MAY_CHANNEL_PDD_LISTING_SHOP_CHUNK_SIZE || '200', 10); const GENERIC_LISTING_SHOP_CHUNK_SIZE = Number.parseInt(process.env.PLANNING_MAY_CHANNEL_GENERIC_LISTING_SHOP_CHUNK_SIZE || '10', 10); const HIGH_INVENTORY_THRESHOLD = Number.parseInt(process.env.PLANNING_MAY_HIGH_INVENTORY_THRESHOLD || '50', 10); const LOW_ITEM_ID_THRESHOLD = Number.parseInt(process.env.PLANNING_MAY_LOW_ITEM_ID_THRESHOLD || '1', 10); const LOW_PROMOTION_ROI_THRESHOLD = Number.parseFloat(process.env.PLANNING_MAY_LOW_PROMOTION_ROI_THRESHOLD || '2'); let mayChannelInsightRefreshPromise = null; let mayChannelInsightRefreshKey = ''; const MAJOR_CHANNELS = Object.freeze([ { key: '1688', label: '1688' }, { key: 'taobao', label: '淘宝' }, { key: 'tmall', label: '天猫' }, { key: 'pdd', label: '拼多多' }, { key: 'jd', label: '京东' }, { key: 'douyin', label: '抖音' }, { key: 'vip', label: '唯品会' }, ]); function escapeIdentifier(identifier) { return `\`${String(identifier).replace(/`/g, '``')}\``; } function toNumber(value, fallback = 0) { const numeric = Number(value); return Number.isFinite(numeric) ? numeric : fallback; } function normalizeText(value) { return String(value ?? '').trim(); } function formatDateOnly(value) { if (!value) { return ''; } if (value instanceof Date) { const year = value.getFullYear(); const month = String(value.getMonth() + 1).padStart(2, '0'); const day = String(value.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; } const text = String(value).trim(); const match = text.match(/^(\d{4}-\d{2}-\d{2})/); return match ? match[1] : text; } function buildNotDiscontinuedCondition(columnExpression) { return `LOCATE('停产', REPLACE(COALESCE(NULLIF(TRIM(${columnExpression}), ''), ''), '''', '')) = 0`; } function buildChannelLabelExpression(alias = 'd') { const source = `LOWER(CONCAT_WS(' ', ${alias}.channel_name, ${alias}.shop_name))`; const sourceRaw = `CONCAT_WS(' ', ${alias}.channel_name, ${alias}.shop_name)`; return ` CASE WHEN ${source} LIKE '%1688%' OR ${sourceRaw} LIKE '%阿里巴巴%' THEN '1688' WHEN ${source} LIKE '%c2m%' OR ${sourceRaw} LIKE '%淘工厂%' THEN '淘工厂' WHEN ${sourceRaw} LIKE '%天猫%' OR ${source} LIKE '%tmall%' THEN '天猫' WHEN ${sourceRaw} LIKE '%淘宝%' THEN '淘宝' WHEN ${sourceRaw} LIKE '%拼多多%' OR ${sourceRaw} LIKE '%多多%' OR ${source} LIKE '%pdd%' THEN '拼多多' WHEN ${sourceRaw} LIKE '%京东%' OR ${sourceRaw} LIKE '%京喜%' OR ${source} LIKE '%jd%' THEN '京东' WHEN ${sourceRaw} LIKE '%抖音%' OR ${source} LIKE '%douyin%' THEN '抖音' WHEN ${sourceRaw} LIKE '%得物%' OR ${source} LIKE '%dewu%' THEN '得物' WHEN ${sourceRaw} LIKE '%唯品会%' OR ${source} LIKE '%vip%' THEN '唯品会' WHEN ${sourceRaw} LIKE '%视频号%' OR ${source} LIKE '%weixin%' OR ${source} LIKE '%wechat%' THEN '视频号小店' ELSE COALESCE(NULLIF(TRIM(${alias}.channel_name), ''), '其他') END `; } function buildMajorChannelCountSelects() { return MAJOR_CHANNELS.map((channel) => { return `SUM(CASE WHEN c.channel_label = '${channel.label}' THEN 1 ELSE 0 END) AS channel_${channel.key}_row_count`; }).join(',\n '); } function buildMissingChannelNamesExpression(prefix = 'c') { return `CONCAT_WS('、', ${MAJOR_CHANNELS.map((channel) => `IF(COALESCE(${prefix}.channel_${channel.key}_row_count, 0) = 0, '${channel.label}', NULL)`).join(',\n ')} )`; } function buildMissingChannelCountExpression(prefix = 'c') { return `(${MAJOR_CHANNELS.map((channel) => `IF(COALESCE(${prefix}.channel_${channel.key}_row_count, 0) = 0, 1, 0)`).join(' + ')})`; } function chunkArray(items = [], chunkSize = 300) { const normalizedSize = Math.max(Number(chunkSize) || 300, 1); const chunks = []; for (let index = 0; index < items.length; index += normalizedSize) { chunks.push(items.slice(index, index + normalizedSize)); } return chunks; } function buildInsightSnapshotKey(snapshotMeta = {}) { return [ formatDateOnly(snapshotMeta.snapshotDate), normalizeText(snapshotMeta.snapshotBatchId), MAY_CHANNEL_INSIGHT_CALC_VERSION, ].filter(Boolean).join('__'); } function normalizeShopKey(value) { return normalizeText(value).replace(/[\s_-]+/g, '').toLowerCase(); } function normalizeShopDisplayName(value) { return normalizeText(value).replace(/-/g, '_'); } function normalizeComparableShopKey(value) { return normalizeShopKey(normalizeText(value).replace(/(?:(作废)|\(作废\))$/u, '')); } function resolveChannelLabelFromText(channelName = '', shopName = '') { const source = `${normalizeText(channelName)} ${normalizeText(shopName)}`.toLowerCase(); const sourceRaw = `${normalizeText(channelName)} ${normalizeText(shopName)}`; if (source.includes('1688') || sourceRaw.includes('阿里巴巴')) return '1688'; if (source.includes('c2m') || sourceRaw.includes('淘工厂')) return '淘工厂'; if (sourceRaw.includes('天猫') || source.includes('tmall')) return '天猫'; if (sourceRaw.includes('淘宝')) return '淘宝'; if (sourceRaw.includes('拼多多') || sourceRaw.includes('多多') || source.includes('pdd')) return '拼多多'; if (sourceRaw.includes('京东') || sourceRaw.includes('京喜') || source.includes('jd')) return '京东'; if (sourceRaw.includes('抖音') || source.includes('douyin')) return '抖音'; if (sourceRaw.includes('得物') || source.includes('dewu')) return '得物'; if (sourceRaw.includes('唯品会') || source.includes('vip')) return '唯品会'; if (sourceRaw.includes('视频号') || source.includes('weixin') || source.includes('wechat')) return '视频号小店'; return normalizeText(channelName) || '其他'; } function resolveListingSourceBucket(channelName = '', shopName = '') { const normalizedChannelName = normalizeText(channelName); const resolvedChannelName = resolveChannelLabelFromText(normalizedChannelName, shopName); if (resolvedChannelName === '拼多多') { return 'pdd_listing'; } if (['天猫', '淘宝', '1688', '淘工厂'].includes(resolvedChannelName)) { return 'qmcust_listing'; } return 'generic_listing'; } function deriveProductCodeFromSku(value) { const skuCode = normalizeText(value).toUpperCase().replace(/[-_].*$/u, ''); if (!skuCode) { return ''; } const match = skuCode.match(/^([A-Z]+[0-9]+)/u); return match ? match[1] : skuCode; } function buildMayCoverageMatcher(mayRows = []) { const exactCodeSet = new Set(); const prefixCodeSet = new Set(); (mayRows || []).forEach((row) => { const maySkuCode = normalizeText(row.maySkuCode || row.may_sku_code).toUpperCase(); const productCode = normalizeText(row.productCode || row.product_code).toUpperCase(); const spuCode = normalizeText(row.spuCode || row.spu_code).toUpperCase(); const derivedProductCode = deriveProductCodeFromSku(maySkuCode); [maySkuCode, productCode, spuCode] .map((value) => normalizeText(value).toUpperCase()) .filter(Boolean) .forEach((value) => exactCodeSet.add(value)); [productCode, spuCode, derivedProductCode] .map((value) => normalizeText(value).toUpperCase()) .filter((value) => value && value.length >= 5) .forEach((value) => prefixCodeSet.add(value)); }); const prefixCodes = Array.from(prefixCodeSet) .sort((left, right) => right.length - left.length || left.localeCompare(right, 'en')); return { matchesSkuCode(value) { const skuCode = normalizeText(value).toUpperCase(); if (!skuCode) { return false; } if (exactCodeSet.has(skuCode)) { return true; } return prefixCodes.some((prefix) => skuCode.startsWith(prefix)); }, matchesAnySkuCode(values = []) { return (values || []).some((value) => this.matchesSkuCode(value)); }, }; } function shouldPreferRawSkuIdentity(row = {}) { const channelSource = normalizeText(row.channelName).toLowerCase(); return channelSource.includes('抖音') || channelSource.includes('douyin') || channelSource.includes('toutiao') || channelSource.includes('fxg'); } function getPreferredListingSkuIdentity(row = {}) { if (shouldPreferRawSkuIdentity(row)) { const shortVideoCandidate = [ row.rawSkuCode, row.outerSkuCode, row.linkSkuCode, row.shopItemId, ] .map((value) => normalizeText(value).toUpperCase()) .find(Boolean); if (shortVideoCandidate) { return shortVideoCandidate; } } const codeCandidate = getListingSkuCandidates(row)[0]; if (codeCandidate) { return codeCandidate; } return normalizeText(row.shopSkuId).toUpperCase(); } function getListingSkuCandidates(row = {}) { const candidateValues = shouldPreferRawSkuIdentity(row) ? [row.rawSkuCode, row.outerSkuCode, row.linkSkuCode, row.skuCode] : [row.skuCode, row.rawSkuCode, row.outerSkuCode, row.linkSkuCode]; return Array.from( new Set( candidateValues .map((value) => normalizeText(value).toUpperCase()) .filter(Boolean) ) ); } function buildCurrentListingPredicate(alias = 'm', options = {}) { const enabledExpr = `TRY_CONVERT(INT, ${alias}.enabled)`; if (options.allowNullEnabled) { return `( ISNULL(${enabledExpr}, 0) = 1 OR (${enabledExpr} IS NULL AND ${alias}.pull_off_time IS NULL AND COALESCE(${alias}.shop_created, ${alias}.created) IS NOT NULL) )`; } return `ISNULL(${enabledExpr}, 0) = 1`; } function createShopCoverageAccumulator(channelName = '', shopName = '', shopKey = '') { return { channelName: normalizeText(channelName), shopName: normalizeShopDisplayName(shopName), shopKey: normalizeShopKey(shopKey || shopName), skuSet: new Set(), itemSet: new Set(), maySkuSet: new Set(), mayItemSet: new Set(), }; } function materializeShopCoverageSummary(summary = {}) { return { channelName: normalizeText(summary.channelName), shopName: normalizeText(summary.shopName), shopKey: normalizeShopKey(summary.shopKey || summary.shopName), skuCount: summary.skuSet?.size || 0, itemIdCount: summary.itemSet?.size || 0, maySkuCount: summary.maySkuSet?.size || 0, mayItemIdCount: summary.mayItemSet?.size || 0, maySkuSet: summary.maySkuSet || new Set(), mayItemSet: summary.mayItemSet || new Set(), }; } async function queryDominantShopNameMap(shopCodes = []) { const shopCodeMap = new Map(); const uniqueShopCodes = Array.from(new Set((shopCodes || []).map(normalizeText).filter(Boolean))); if (uniqueShopCodes.length === 0) { return shopCodeMap; } for (const shopCodeChunk of chunkArray(uniqueShopCodes, 200)) { const params = {}; const placeholders = shopCodeChunk.map((shopCode, index) => { const key = `shopCode${index}`; params[key] = shopCode; return `@${key}`; }); const rows = await cloudDb.query( ` WITH named AS ( SELECT LTRIM(RTRIM(CAST(undefined9 AS NVARCHAR(100)))) AS shopCode, LTRIM(RTRIM(CAST(undefined8 AS NVARCHAR(255)))) AS shopName, COUNT(*) AS nameCount FROM dbo.jst_goods_profit_kpi_items_afs WHERE LTRIM(RTRIM(CAST(undefined9 AS NVARCHAR(100)))) IN (${placeholders.join(', ')}) AND LTRIM(RTRIM(CAST(undefined8 AS NVARCHAR(255)))) <> N'' GROUP BY LTRIM(RTRIM(CAST(undefined9 AS NVARCHAR(100)))), LTRIM(RTRIM(CAST(undefined8 AS NVARCHAR(255)))) ), ranked AS ( SELECT shopCode, shopName, ROW_NUMBER() OVER (PARTITION BY shopCode ORDER BY nameCount DESC, shopName ASC) AS rn FROM named ) SELECT shopCode, shopName FROM ranked WHERE rn = 1 `, params, { requestTimeout: 600000, maxAttempts: 2 } ); rows.forEach((row) => { const shopCode = normalizeText(row.shopCode); const shopName = normalizeText(row.shopName); if (shopCode && shopName && !shopCodeMap.has(shopCode)) { shopCodeMap.set(shopCode, shopName); } }); } return shopCodeMap; } function buildShopNameVariants(shopName = '') { const normalized = normalizeText(shopName); if (!normalized) { return []; } const baseVariants = [ normalized, normalized.replace(/_/g, '-'), normalized.replace(/-/g, '_'), ].filter(Boolean); const withDeprecatedSuffix = baseVariants.flatMap((value) => [ value, `${value}(作废)`, `${value}(作废)`, ]); return Array.from(new Set(withDeprecatedSuffix.filter(Boolean))); } async function queryRelevantShopInfoMap(shopNames = []) { const shopCodeNameMap = new Map(); const candidateNames = Array.from(new Set( (shopNames || []) .flatMap((shopName) => buildShopNameVariants(shopName)) .map(normalizeText) .filter(Boolean) )); if (candidateNames.length === 0) { return shopCodeNameMap; } for (const shopNameChunk of chunkArray(candidateNames, 200)) { const params = {}; const placeholders = shopNameChunk.map((shopName, index) => { const key = `shopName${index}`; params[key] = shopName; return `@${key}`; }); const rows = await cloudDb.query( ` SELECT DISTINCT LTRIM(RTRIM(CAST(shop_id AS NVARCHAR(100)))) AS shopCode, LTRIM(RTRIM(CAST(shop_name AS NVARCHAR(255)))) AS shopName FROM dbo.${SHOP_INFO_TABLE} WITH (NOLOCK) WHERE LTRIM(RTRIM(CAST(shop_name AS NVARCHAR(255)))) IN (${placeholders.join(', ')}) AND LTRIM(RTRIM(CAST(shop_id AS NVARCHAR(100)))) <> N'' `, params, { requestTimeout: 120000, maxAttempts: 2 } ); rows.forEach((row) => { const shopCode = normalizeText(row.shopCode); const shopName = normalizeText(row.shopName); if (shopCode && shopName && !shopCodeNameMap.has(shopCode)) { shopCodeNameMap.set(shopCode, shopName); } }); } return shopCodeNameMap; } async function queryShopOnlineItemSetMap(shopNames = []) { const resultMap = new Map(); const candidateNames = Array.from(new Set( (shopNames || []) .flatMap((shopName) => buildShopNameVariants(shopName)) .map(normalizeText) .filter(Boolean) )); if (candidateNames.length === 0) { return resultMap; } for (const shopNameChunk of chunkArray(candidateNames, 200)) { const params = {}; const placeholders = shopNameChunk.map((shopName, index) => { const key = `targetShopName${index}`; params[key] = shopName; return `@${key}`; }); const rows = await cloudDb.query( ` SELECT DISTINCT LTRIM(RTRIM(CAST(shopname AS NVARCHAR(255)))) AS shopName, LTRIM(RTRIM(CAST(shopproid AS NVARCHAR(100)))) AS itemId FROM dbo.${TARGET_MANAGEMENT_TABLE} WITH (NOLOCK) WHERE LTRIM(RTRIM(CAST(shopname AS NVARCHAR(255)))) IN (${placeholders.join(', ')}) AND LTRIM(RTRIM(CAST(shopproid AS NVARCHAR(100)))) NOT IN (N'', N'未上架') AND COALESCE(NULLIF(LTRIM(RTRIM(CAST(linkStatus AS NVARCHAR(40)))), N''), N'当前在线') NOT LIKE N'%下架%' `, params, { requestTimeout: 120000, maxAttempts: 2 } ); rows.forEach((row) => { const shopKey = normalizeComparableShopKey(row.shopName); const itemId = normalizeText(row.itemId); if (!shopKey || !itemId) { return; } const itemSet = resultMap.get(shopKey) || new Set(); itemSet.add(itemId); resultMap.set(shopKey, itemSet); }); } return resultMap; } async function queryFallbackShopCoverageSummaryMap(snapshotMeta, coverageMatcher) { const summaryMap = new Map(); if (!snapshotMeta?.sourceLatestBizDate || !coverageMatcher) { return summaryMap; } const channelLabelExpression = buildChannelLabelExpression('d'); const rows = await planningDatabase.query( ` SELECT DISTINCT d.channel_label AS channelName, COALESCE(NULLIF(TRIM(d.shop_name), ''), '未识别店铺') AS shopName, COALESCE(NULLIF(TRIM(d.shop_key), ''), NULLIF(TRIM(d.shop_name), '')) AS shopKey, UPPER(TRIM(d.primary_sku_code)) AS skuCode, NULLIF(TRIM(d.item_id), '') AS itemId FROM ( SELECT d.*, ${channelLabelExpression} AS channel_label FROM ${escapeIdentifier(CHANNEL_ID_READ_TABLE)} d WHERE d.stat_date BETWEEN DATE_SUB(?, INTERVAL 29 DAY) AND ? AND NULLIF(TRIM(d.primary_sku_code), '') IS NOT NULL ) d `, [snapshotMeta.sourceLatestBizDate, snapshotMeta.sourceLatestBizDate] ); rows.forEach((row) => { const channelName = normalizeText(row.channelName); const shopName = normalizeText(row.shopName); const shopKey = normalizeShopKey(row.shopKey || row.shopName); if (!channelName || !shopKey) { return; } const key = `${channelName}__${shopKey}`; const summary = summaryMap.get(key) || createShopCoverageAccumulator(channelName, shopName, shopKey); const skuCode = normalizeText(row.skuCode).toUpperCase(); const itemId = normalizeText(row.itemId); if (skuCode) { summary.skuSet.add(skuCode); if (coverageMatcher.matchesSkuCode(skuCode)) { summary.maySkuSet.add(skuCode); if (itemId) { summary.mayItemSet.add(itemId); } } } if (itemId) { summary.itemSet.add(itemId); } summaryMap.set(key, summary); }); return summaryMap; } async function queryHistoricalShopBaseCountMap(snapshotMeta) { const resultMap = new Map(); if (!snapshotMeta?.snapshotDate || !snapshotMeta?.snapshotBatchId) { return resultMap; } const rows = await planningDatabase.query( ` SELECT channel_name AS channelName, shop_name AS shopName, shop_key AS shopKey, sku_count AS skuCount, item_id_count AS itemIdCount, generated_at AS generatedAt FROM ${escapeIdentifier(MAY_CHANNEL_SHOP_SNAPSHOT_TABLE)} WHERE stat_date = ? AND snapshot_batch_id = ? AND calc_version <> ? ORDER BY generated_at DESC, id DESC `, [snapshotMeta.snapshotDate, snapshotMeta.snapshotBatchId, MAY_CHANNEL_INSIGHT_CALC_VERSION] ); rows.forEach((row) => { const channelName = normalizeText(row.channelName); const shopKey = normalizeShopKey(row.shopKey || row.shopName); const key = `${channelName}__${shopKey}`; if (!channelName || !shopKey || resultMap.has(key)) { return; } resultMap.set(key, { skuCount: toNumber(row.skuCount, 0), itemIdCount: toNumber(row.itemIdCount, 0), generatedAt: row.generatedAt || null, }); }); return resultMap; } function normalizeShopCoverageFilterOptions(options = {}) { const channels = new Set( (options.channels || []) .map((value) => normalizeText(value)) .filter(Boolean) ); const shopKeys = new Set( (options.shopKeys || []) .map((value) => normalizeShopKey(value)) .filter(Boolean) ); return { channels, shopKeys }; } function matchesShopCoverageFilters(summary = {}, filters = {}) { const channelName = normalizeText(summary.channelName); const shopKey = normalizeShopKey(summary.shopKey || summary.shopName); if (filters.channels?.size && !filters.channels.has(channelName)) { return false; } if (filters.shopKeys?.size && !filters.shopKeys.has(shopKey)) { return false; } return true; } function filterShopCoverageSummaryMap(summaryMap = new Map(), filters = {}) { if (!(summaryMap instanceof Map)) { return new Map(); } if (!filters.channels?.size && !filters.shopKeys?.size) { return summaryMap; } const filteredMap = new Map(); summaryMap.forEach((summary, key) => { if (matchesShopCoverageFilters(summary, filters)) { filteredMap.set(key, summary); } }); return filteredMap; } async function queryShopListingSummaryMap(snapshotMeta, options = {}) { const resultMap = new Map(); if (!snapshotMeta?.snapshotDate || !snapshotMeta?.snapshotBatchId) { return resultMap; } const coverageFilters = normalizeShopCoverageFilterOptions(options); const mayRows = await planningDatabase.query( `SELECT DISTINCT UPPER(TRIM(s.may_sku_code)) AS maySkuCode, UPPER(TRIM(COALESCE(s.product_code, ''))) AS productCode, UPPER(TRIM(COALESCE(s.spu_code, ''))) AS spuCode FROM ${escapeIdentifier(MAY_PRODUCT_SNAPSHOT_TABLE)} s WHERE s.stat_date = ? AND s.snapshot_batch_id = ? AND NULLIF(TRIM(s.may_sku_code), '') IS NOT NULL AND ${buildNotDiscontinuedCondition('s.may_sales_status')}`, [snapshotMeta.snapshotDate, snapshotMeta.snapshotBatchId] ); const coverageMatcher = buildMayCoverageMatcher(mayRows); const fallbackSummaryMap = filterShopCoverageSummaryMap( await queryFallbackShopCoverageSummaryMap(snapshotMeta, coverageMatcher), coverageFilters ); const relevantShopCodeNameMap = await queryRelevantShopInfoMap( Array.from(fallbackSummaryMap.values()).map((summary) => summary.shopName) ); const shopOnlineItemSetMap = await queryShopOnlineItemSetMap( Array.from(fallbackSummaryMap.values()).map((summary) => summary.shopName) ); const relevantShopCodes = Array.from(relevantShopCodeNameMap.keys()); const fallbackChannelByShopKey = new Map( Array.from(fallbackSummaryMap.values()).map((summary) => [ normalizeShopKey(summary.shopName || summary.shopKey), normalizeText(summary.channelName), ]) ); const relevantNumericShopCodes = relevantShopCodes .map((shopCode) => { const shopName = relevantShopCodeNameMap.get(shopCode) || ''; const fallbackChannelName = fallbackChannelByShopKey.get(normalizeShopKey(shopName)) || ''; return { shopCode, numericShopCode: Number.parseInt(shopCode, 10), channelName: fallbackChannelName || resolveChannelLabelFromText('', shopName), shopName, }; }) .filter((item) => Number.isFinite(item.numericShopCode)); const tmallNumericShopCodes = relevantNumericShopCodes .filter((item) => resolveListingSourceBucket(item.channelName, item.shopName) === 'qmcust_listing') .map((item) => item.numericShopCode); const pddNumericShopCodes = relevantNumericShopCodes .filter((item) => resolveListingSourceBucket(item.channelName, item.shopName) === 'pdd_listing') .map((item) => item.numericShopCode); const genericNumericShopCodes = relevantNumericShopCodes .filter((item) => resolveListingSourceBucket(item.channelName, item.shopName) === 'generic_listing') .map((item) => item.numericShopCode); if (relevantShopCodes.length === 0) { const historicalBaseMap = await queryHistoricalShopBaseCountMap(snapshotMeta); fallbackSummaryMap.forEach((summary, key) => { const materialized = materializeShopCoverageSummary(summary); const historical = historicalBaseMap.get(key); if (historical) { materialized.skuCount = historical.skuCount; materialized.itemIdCount = historical.itemIdCount; } resultMap.set(key, materialized); }); return resultMap; } let accumulatedListingRowCount = 0; try { const matchedShopKeys = new Set( Array.from(relevantShopCodeNameMap.values()) .map((shopName) => normalizeShopKey(shopName)) .filter(Boolean) ); fallbackSummaryMap.forEach((summary, key) => { if (!matchedShopKeys.has(normalizeShopKey(summary.shopName || summary.shopKey))) { return; } if (!resultMap.has(key)) { resultMap.set( key, createShopCoverageAccumulator(summary.channelName, summary.shopName, summary.shopKey) ); } }); const accumulateChunkRows = (chunkRows = []) => { chunkRows.forEach((row) => { const shopCode = normalizeText(row.shopCode); const rawShopName = relevantShopCodeNameMap.get(shopCode) || ''; const channelName = resolveChannelLabelFromText(row.channelName, rawShopName); const normalizedShopName = normalizeShopDisplayName(rawShopName); const shopKey = normalizeShopKey(rawShopName || shopCode); if (!channelName || !shopKey) { return; } const shopItemId = normalizeText(row.shopItemId); const onlineItemSet = shopOnlineItemSetMap.get(normalizeComparableShopKey(rawShopName)); if (onlineItemSet?.size && (!shopItemId || !onlineItemSet.has(shopItemId))) { return; } const key = `${channelName}__${shopKey}`; const summary = resultMap.get(key) || createShopCoverageAccumulator(channelName, normalizedShopName, shopKey); const skuIdentity = getPreferredListingSkuIdentity(row); const matchesMayCoverage = coverageMatcher.matchesAnySkuCode(getListingSkuCandidates(row)); if (skuIdentity) { summary.skuSet.add(skuIdentity); if (matchesMayCoverage) { summary.maySkuSet.add(skuIdentity); } } if (shopItemId) { summary.itemSet.add(shopItemId); if (matchesMayCoverage) { summary.mayItemSet.add(shopItemId); } } resultMap.set(key, summary); }); }; if (tmallNumericShopCodes.length > 0) { for (const shopCodeChunk of chunkArray(tmallNumericShopCodes, TMALL_LISTING_SHOP_CHUNK_SIZE)) { const params = {}; const placeholders = shopCodeChunk.map((shopCode, index) => { const key = `tmallShopCode${index}`; params[key] = shopCode; return `@${key}`; }); console.log(`[PlanningMayChannelInsight] listingChunk start source=qmcust_listing shops=${shopCodeChunk.length} accumulatedRows=${accumulatedListingRowCount}`); const tmallRows = await cloudDb.query( ` SELECT CAST(m.shop_id AS NVARCHAR(100)) AS shopCode, CAST(m.channel AS NVARCHAR(100)) AS channelName, CAST(m.shop_i_id AS NVARCHAR(255)) AS shopItemId, CAST(m.shop_sku_id AS NVARCHAR(255)) AS shopSkuId, UPPER(CAST(m.sku_id AS NVARCHAR(255))) AS skuCode, UPPER(CAST(m.raw_sku_id AS NVARCHAR(255))) AS rawSkuCode, UPPER(CAST(m.outer_sku_code AS NVARCHAR(255))) AS outerSkuCode, UPPER(CAST(m.link_sku_id AS NVARCHAR(255))) AS linkSkuCode FROM dbo.${TMALL_LISTING_MAP_TABLE} m WITH (NOLOCK) WHERE m.shop_id IN (${placeholders.join(', ')}) AND m.channel IN (N'Taobao', N'Tmall', N'淘宝', N'天猫', N'Alibaba', N'1688', N'C2M', N'淘工厂', N'Tfx', N'淘分销') AND ISNULL(m.enabled, 0) = 1 `, params, { requestTimeout: 600000, maxAttempts: 2 } ); accumulateChunkRows(tmallRows); accumulatedListingRowCount += tmallRows.length; console.log(`[PlanningMayChannelInsight] listingChunk done source=qmcust_listing shops=${shopCodeChunk.length} chunkRows=${tmallRows.length} accumulatedRows=${accumulatedListingRowCount}`); } } const indexedSourceQueries = [ { name: 'pdd_listing', shopCodes: pddNumericShopCodes, chunkSize: PDD_LISTING_SHOP_CHUNK_SIZE, sql: (placeholderSql) => ` SELECT CAST(m.shop_id AS NVARCHAR(100)) AS shopCode, CAST(COALESCE(m.channel, N'拼多多') AS NVARCHAR(100)) AS channelName, CAST(m.shop_i_id AS NVARCHAR(255)) AS shopItemId, CAST(m.shop_sku_id AS NVARCHAR(255)) AS shopSkuId, UPPER(CAST(m.sku_id AS NVARCHAR(255))) AS skuCode, UPPER(CAST(m.raw_sku_id AS NVARCHAR(255))) AS rawSkuCode, UPPER(CAST(m.outer_sku_code AS NVARCHAR(255))) AS outerSkuCode, UPPER(CAST(m.link_sku_id AS NVARCHAR(255))) AS linkSkuCode FROM dbo.${PDD_LISTING_MAP_TABLE} m WITH (NOLOCK) WHERE m.shop_id IN (${placeholderSql}) AND ( ISNULL(m.enabled, 0) = 1 OR (m.enabled IS NULL AND m.pull_off_time IS NULL AND COALESCE(m.shop_created, m.created) IS NOT NULL) ) `, }, { name: 'generic_listing', shopCodes: genericNumericShopCodes, chunkSize: GENERIC_LISTING_SHOP_CHUNK_SIZE, sql: (placeholderSql) => ` SELECT CAST(m.shop_id AS NVARCHAR(100)) AS shopCode, CAST(m.channel AS NVARCHAR(100)) AS channelName, CAST(m.shop_i_id AS NVARCHAR(255)) AS shopItemId, CAST(m.shop_sku_id AS NVARCHAR(255)) AS shopSkuId, UPPER(CAST(m.sku_id AS NVARCHAR(255))) AS skuCode, UPPER(CAST(m.raw_sku_id AS NVARCHAR(255))) AS rawSkuCode, UPPER(CAST(m.outer_sku_code AS NVARCHAR(255))) AS outerSkuCode, UPPER(CAST(m.link_sku_id AS NVARCHAR(255))) AS linkSkuCode FROM dbo.${GENERIC_LISTING_MAP_TABLE} m WITH (NOLOCK) WHERE m.shop_id IN (${placeholderSql}) AND ( ISNULL(m.enabled, 0) = 1 OR (m.enabled IS NULL AND m.pull_off_time IS NULL AND COALESCE(m.shop_created, m.created) IS NOT NULL) ) AND ISNULL(m.channel, N'') NOT IN (N'Taobao', N'Tmall', N'淘宝', N'天猫', N'Pinduoduo', N'拼多多') `, }, ]; for (const sourceQuery of indexedSourceQueries) { for (const shopCodeChunk of chunkArray(sourceQuery.shopCodes, sourceQuery.chunkSize)) { const params = {}; const placeholders = shopCodeChunk.map((shopCode, index) => { const key = `shopCode${index}`; params[key] = shopCode; return `@${key}`; }); console.log(`[PlanningMayChannelInsight] listingChunk start source=${sourceQuery.name} shops=${shopCodeChunk.length} accumulatedRows=${accumulatedListingRowCount}`); const chunkRows = await cloudDb.query( sourceQuery.sql(placeholders.join(', ')), params, { requestTimeout: 600000, maxAttempts: 2 } ); accumulateChunkRows(chunkRows); accumulatedListingRowCount += chunkRows.length; console.log(`[PlanningMayChannelInsight] listingChunk done source=${sourceQuery.name} shops=${shopCodeChunk.length} chunkRows=${chunkRows.length} accumulatedRows=${accumulatedListingRowCount}`); } } } catch (error) { console.warn('[PlanningMayChannelInsight] 读取当前铺货映射失败,回退近30天渠道活跃SKU覆盖:', error.message || error); const historicalBaseMap = await queryHistoricalShopBaseCountMap(snapshotMeta); fallbackSummaryMap.forEach((summary, key) => { const materialized = materializeShopCoverageSummary(summary); const historical = historicalBaseMap.get(key); if (historical) { materialized.skuCount = historical.skuCount; materialized.itemIdCount = historical.itemIdCount; } resultMap.set(key, materialized); }); return resultMap; } const materializedResultMap = new Map(); resultMap.forEach((summary, key) => { materializedResultMap.set(key, materializeShopCoverageSummary(summary)); }); fallbackSummaryMap.forEach((summary, key) => { if (!materializedResultMap.has(key)) { materializedResultMap.set(key, materializeShopCoverageSummary(summary)); } }); return materializedResultMap; } function createShopMetricAccumulator() { return { skuSet: new Set(), activeSkuSet: new Set(), itemSet: new Set(), activeItemSet: new Set(), salesAmount30d: 0, transactionAmount30d: 0, salesQty30d: 0, afterSaleAmount30d: 0, refundCount30d: 0, orderCount30d: 0, promotionFee30d: 0, grossCostAmount30d: 0, }; } async function queryShopCurrentMetricMaps(snapshotMeta, listingSummaryMap = new Map()) { const shopMetricMap = new Map(); const mayMetricMap = new Map(); if (!snapshotMeta?.sourceLatestBizDate || !(listingSummaryMap instanceof Map) || listingSummaryMap.size === 0) { return { shopMetricMap, mayMetricMap, }; } const channelLabelExpression = buildChannelLabelExpression('d'); const rows = await planningDatabase.query( ` SELECT d.channel_label AS channelName, COALESCE(NULLIF(TRIM(d.shop_name), ''), '未识别店铺') AS shopName, COALESCE(NULLIF(TRIM(d.shop_key), ''), NULLIF(TRIM(d.shop_name), '')) AS shopKey, NULLIF(TRIM(d.item_id), '') AS itemId, UPPER(TRIM(d.primary_sku_code)) AS skuCode, SUM(COALESCE(d.transaction_amount, 0)) AS salesAmount30d, SUM(COALESCE(d.transaction_amount, 0)) AS transactionAmount30d, SUM(COALESCE(d.goods_count, 0)) AS salesQty30d, SUM(COALESCE(d.after_sale_amount, 0)) AS afterSaleAmount30d, SUM(COALESCE(d.refund_count, 0)) AS refundCount30d, SUM(COALESCE(d.order_count, 0)) AS orderCount30d, SUM(COALESCE(d.gross_cost_amount, 0)) AS grossCostAmount30d, SUM( CASE WHEN COALESCE(d.push_fee_amount, 0) + COALESCE(d.marketing_fee_amount, 0) > 0 THEN COALESCE(d.push_fee_amount, 0) + COALESCE(d.marketing_fee_amount, 0) ELSE COALESCE(d.fee_amount, 0) END ) AS promotionFee30d FROM ( SELECT d.*, ${channelLabelExpression} AS channel_label FROM ${escapeIdentifier(CHANNEL_ID_READ_TABLE)} d WHERE d.stat_date BETWEEN DATE_SUB(?, INTERVAL 29 DAY) AND ? ) d WHERE NULLIF(TRIM(d.item_id), '') IS NOT NULL GROUP BY d.channel_label, COALESCE(NULLIF(TRIM(d.shop_name), ''), '未识别店铺'), COALESCE(NULLIF(TRIM(d.shop_key), ''), NULLIF(TRIM(d.shop_name), '')), NULLIF(TRIM(d.item_id), ''), UPPER(TRIM(d.primary_sku_code)) `, [snapshotMeta.sourceLatestBizDate, snapshotMeta.sourceLatestBizDate] ); rows.forEach((row) => { const key = `${normalizeText(row.channelName)}__${normalizeShopKey(row.shopKey || row.shopName)}`; const listingSummary = listingSummaryMap.get(key); if (!listingSummary) { return; } const itemId = normalizeText(row.itemId); const skuCode = normalizeText(row.skuCode).toUpperCase(); const hasActivity = toNumber(row.salesAmount30d, 0) > 0 || toNumber(row.salesQty30d, 0) > 0; const metric = shopMetricMap.get(key) || createShopMetricAccumulator(); metric.salesAmount30d += toNumber(row.salesAmount30d, 0); metric.transactionAmount30d += toNumber(row.transactionAmount30d, 0); metric.salesQty30d += toNumber(row.salesQty30d, 0); metric.afterSaleAmount30d += toNumber(row.afterSaleAmount30d, 0); metric.refundCount30d += toNumber(row.refundCount30d, 0); metric.orderCount30d += toNumber(row.orderCount30d, 0); metric.promotionFee30d += toNumber(row.promotionFee30d, 0); metric.grossCostAmount30d += toNumber(row.grossCostAmount30d, 0); if (itemId) { metric.itemSet.add(itemId); if (hasActivity) { metric.activeItemSet.add(itemId); } } if (skuCode) { metric.skuSet.add(skuCode); if (hasActivity) { metric.activeSkuSet.add(skuCode); } } shopMetricMap.set(key, metric); if (!listingSummary.mayItemSet?.has(itemId)) { return; } const mayMetric = mayMetricMap.get(key) || createShopMetricAccumulator(); mayMetric.salesAmount30d += toNumber(row.salesAmount30d, 0); mayMetric.transactionAmount30d += toNumber(row.transactionAmount30d, 0); mayMetric.salesQty30d += toNumber(row.salesQty30d, 0); mayMetric.afterSaleAmount30d += toNumber(row.afterSaleAmount30d, 0); mayMetric.refundCount30d += toNumber(row.refundCount30d, 0); mayMetric.orderCount30d += toNumber(row.orderCount30d, 0); mayMetric.promotionFee30d += toNumber(row.promotionFee30d, 0); mayMetric.grossCostAmount30d += toNumber(row.grossCostAmount30d, 0); if (itemId) { mayMetric.itemSet.add(itemId); if (hasActivity) { mayMetric.activeItemSet.add(itemId); } } if (skuCode) { mayMetric.skuSet.add(skuCode); if (hasActivity) { mayMetric.activeSkuSet.add(skuCode); } } mayMetricMap.set(key, mayMetric); }); return { shopMetricMap, mayMetricMap, }; } async function applyShopListingSummary(snapshotMeta, listingSummaryMap = new Map(), shopCurrentMetricMap = new Map(), shopCurrentMayMetricMap = new Map()) { if (!snapshotMeta?.snapshotDate || !snapshotMeta?.snapshotBatchId || !(listingSummaryMap instanceof Map) || listingSummaryMap.size === 0) { return 0; } const snapshotRows = await planningDatabase.query( `SELECT id, channel_name AS channelName, shop_name AS shopName, shop_key AS shopKey FROM ${escapeIdentifier(MAY_CHANNEL_SHOP_SNAPSHOT_TABLE)} WHERE stat_date = ? AND snapshot_batch_id = ? AND calc_version = ?`, [snapshotMeta.snapshotDate, snapshotMeta.snapshotBatchId, MAY_CHANNEL_INSIGHT_CALC_VERSION] ); const updates = []; snapshotRows.forEach((row) => { const channelName = normalizeText(row.channelName); const normalizedKey = normalizeShopKey(row.shopKey || row.shopName); const shopMetricKey = `${channelName}__${normalizedKey}`; const summary = listingSummaryMap.get(shopMetricKey); if (!summary) { return; } const currentMetric = shopCurrentMetricMap.get(shopMetricKey) || createShopMetricAccumulator(); const currentMayMetric = shopCurrentMayMetricMap.get(shopMetricKey) || createShopMetricAccumulator(); updates.push([ toNumber(summary.skuCount, 0), toNumber(summary.itemIdCount, 0), currentMetric.activeSkuSet.size, currentMetric.activeItemSet.size, toNumber(currentMetric.salesAmount30d, 0), toNumber(currentMetric.transactionAmount30d, 0), toNumber(currentMetric.afterSaleAmount30d, 0), toNumber(currentMetric.salesQty30d, 0), toNumber(currentMetric.refundCount30d, 0), toNumber(currentMetric.orderCount30d, 0), toNumber(currentMetric.promotionFee30d, 0), toNumber(currentMetric.grossCostAmount30d, 0), toNumber(summary.maySkuCount, 0), toNumber(summary.mayItemIdCount, 0), currentMayMetric.activeSkuSet.size, currentMayMetric.activeItemSet.size, toNumber(currentMayMetric.salesAmount30d, 0), toNumber(currentMayMetric.salesQty30d, 0), toNumber(currentMayMetric.promotionFee30d, 0), toNumber(currentMayMetric.afterSaleAmount30d, 0), row.id, ]); }); if (updates.length === 0) { return 0; } await planningDatabase.withTransaction(async (connection) => { for (const [ skuCount, itemIdCount, activeSkuCount, activeItemIdCount, salesAmount30d, transactionAmount30d, afterSaleAmount30d, salesQty30d, refundCount30d, orderCount30d, promotionFee30d, grossCostAmount30d, maySkuCount, mayItemIdCount, mayActiveSkuCount, mayActiveItemIdCount, maySalesAmount30d, maySalesQty30d, mayPromotionFee30d, mayAfterSaleAmount30d, id, ] of updates) { await connection.query( `UPDATE ${escapeIdentifier(MAY_CHANNEL_SHOP_SNAPSHOT_TABLE)} SET sku_count = ?, item_id_count = ?, active_sku_count = ?, active_item_id_count = ?, sales_amount_30d = ?, transaction_amount_30d = ?, after_sale_amount_30d = ?, sales_qty_30d = ?, refund_count_30d = ?, order_count_30d = ?, promotion_fee_30d = ?, gross_cost_amount_30d = ?, may_sku_count = ?, may_item_id_count = ?, may_active_sku_count = ?, may_active_item_id_count = ?, may_sales_amount_30d = ?, may_sales_qty_30d = ?, may_promotion_fee_30d = ?, may_after_sale_amount_30d = ? WHERE id = ?`, [ skuCount, itemIdCount, activeSkuCount, activeItemIdCount, salesAmount30d, transactionAmount30d, afterSaleAmount30d, salesQty30d, refundCount30d, orderCount30d, promotionFee30d, grossCostAmount30d, maySkuCount, mayItemIdCount, mayActiveSkuCount, mayActiveItemIdCount, maySalesAmount30d, maySalesQty30d, mayPromotionFee30d, mayAfterSaleAmount30d, id, ] ); } }); return updates.length; } async function repairPlanningMayChannelInsightShopCoverage(options = {}) { await ensurePlanningMayChannelInsightSchema(); const requestedSnapshotMeta = await resolveMaySnapshotMeta(); const snapshotMeta = requestedSnapshotMeta ? (await getExistingInsightMeta(requestedSnapshotMeta)) || (await getLatestAvailableInsightMeta()) || requestedSnapshotMeta : await getLatestAvailableInsightMeta(); if (!snapshotMeta) { return { success: false, message: 'MAY商品快照尚未生成,无法修复店铺覆盖数据。', updatedRows: 0, }; } const shopListingSummaryMap = await queryShopListingSummaryMap(snapshotMeta, options); const { shopMetricMap, mayMetricMap } = await queryShopCurrentMetricMaps(snapshotMeta, shopListingSummaryMap); const updatedRows = await applyShopListingSummary(snapshotMeta, shopListingSummaryMap, shopMetricMap, mayMetricMap); return { success: true, statDate: snapshotMeta.snapshotDate, snapshotBatchId: snapshotMeta.snapshotBatchId, sourceLatestBizDate: snapshotMeta.sourceLatestBizDate, requestedSnapshotDate: requestedSnapshotMeta?.snapshotDate || '', requestedSnapshotBatchId: requestedSnapshotMeta?.snapshotBatchId || '', calcVersion: MAY_CHANNEL_INSIGHT_CALC_VERSION, updatedRows, filteredChannels: (options.channels || []).map((value) => normalizeText(value)).filter(Boolean), filteredShopKeys: (options.shopKeys || []).map((value) => normalizeShopKey(value)).filter(Boolean), }; } async function ensureColumnCollation(tableName, columnName, columnDefinition, expectedCollation) { const [column] = await planningDatabase.query( `SELECT COLLATION_NAME AS collationName FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? LIMIT 1`, [tableName, columnName] ); if (column?.collationName && column.collationName !== expectedCollation) { await planningDatabase.execute( `ALTER TABLE ${escapeIdentifier(tableName)} MODIFY ${escapeIdentifier(columnName)} ${columnDefinition}` ); } } async function ensurePlanningTableColumn(tableName, columnName, columnDefinition) { const rows = await planningDatabase.query( `SHOW COLUMNS FROM ${escapeIdentifier(tableName)} LIKE ?`, [columnName] ); if (Array.isArray(rows) && rows.length > 0) { return; } await planningDatabase.execute( `ALTER TABLE ${escapeIdentifier(tableName)} ADD COLUMN ${escapeIdentifier(columnName)} ${columnDefinition}` ); } async function ensurePlanningMayChannelInsightSchema() { await planningDatabase.execute(` CREATE TABLE IF NOT EXISTS ${escapeIdentifier(MAY_CHANNEL_ACTIVE_SKU_TABLE)} ( stat_date DATE NOT NULL, snapshot_batch_id VARCHAR(80) NOT NULL, calc_version VARCHAR(80) NOT NULL, sku_key VARCHAR(160) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL, may_sku_code VARCHAR(120) NOT NULL, category_name VARCHAR(255) NULL, may_sku_image_url TEXT NULL, may_sales_status VARCHAR(80) NULL, review_action VARCHAR(80) NULL, may_inventory_quantity DECIMAL(18, 4) NOT NULL DEFAULT 0, may_goods_id_count INT NOT NULL DEFAULT 0, may_performance_amount DECIMAL(18, 2) NOT NULL DEFAULT 0, may_promotion_fee_30d DECIMAL(18, 2) NOT NULL DEFAULT 0, generated_at DATETIME NOT NULL, CONSTRAINT pk_may_channel_active_sku PRIMARY KEY (stat_date, snapshot_batch_id, calc_version, sku_key), INDEX idx_may_channel_active_sku_code (sku_key), INDEX idx_may_channel_active_sku_status (stat_date, snapshot_batch_id, calc_version, may_sales_status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); await ensureColumnCollation( MAY_CHANNEL_ACTIVE_SKU_TABLE, 'sku_key', 'VARCHAR(160) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL', 'utf8mb4_general_ci' ); await planningDatabase.execute(` CREATE TABLE IF NOT EXISTS ${escapeIdentifier(MAY_CHANNEL_SHOP_SNAPSHOT_TABLE)} ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, stat_date DATE NOT NULL, snapshot_batch_id VARCHAR(80) NOT NULL, source_latest_biz_date DATE NULL, channel_name VARCHAR(120) NOT NULL, shop_name VARCHAR(255) NOT NULL, shop_key VARCHAR(255) NULL, sku_count INT NOT NULL DEFAULT 0, item_id_count INT NOT NULL DEFAULT 0, active_sku_count INT NOT NULL DEFAULT 0, active_item_id_count INT NOT NULL DEFAULT 0, sales_amount_30d DECIMAL(18, 2) NOT NULL DEFAULT 0, transaction_amount_30d DECIMAL(18, 2) NOT NULL DEFAULT 0, after_sale_amount_30d DECIMAL(18, 2) NOT NULL DEFAULT 0, sales_qty_30d DECIMAL(18, 4) NOT NULL DEFAULT 0, refund_count_30d DECIMAL(18, 4) NOT NULL DEFAULT 0, order_count_30d DECIMAL(18, 4) NOT NULL DEFAULT 0, promotion_fee_30d DECIMAL(18, 2) NOT NULL DEFAULT 0, gross_cost_amount_30d DECIMAL(18, 2) NOT NULL DEFAULT 0, may_sku_count INT NOT NULL DEFAULT 0, may_item_id_count INT NOT NULL DEFAULT 0, may_active_sku_count INT NOT NULL DEFAULT 0, may_active_item_id_count INT NOT NULL DEFAULT 0, may_sales_amount_30d DECIMAL(18, 2) NOT NULL DEFAULT 0, may_sales_qty_30d DECIMAL(18, 4) NOT NULL DEFAULT 0, may_promotion_fee_30d DECIMAL(18, 2) NOT NULL DEFAULT 0, may_after_sale_amount_30d DECIMAL(18, 2) NOT NULL DEFAULT 0, generated_at DATETIME NOT NULL, calc_version VARCHAR(80) NOT NULL, UNIQUE KEY uk_may_channel_shop_snapshot (stat_date, snapshot_batch_id, calc_version, channel_name, shop_name), INDEX idx_may_channel_shop_sales (stat_date, snapshot_batch_id, sales_amount_30d), INDEX idx_may_channel_shop_channel (stat_date, snapshot_batch_id, channel_name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); await Promise.all([ ensurePlanningTableColumn(MAY_CHANNEL_SHOP_SNAPSHOT_TABLE, 'may_sku_count', 'INT NOT NULL DEFAULT 0 COMMENT \'MAY覆盖SKU数\''), ensurePlanningTableColumn(MAY_CHANNEL_SHOP_SNAPSHOT_TABLE, 'may_item_id_count', 'INT NOT NULL DEFAULT 0 COMMENT \'MAY覆盖ID数\''), ensurePlanningTableColumn(MAY_CHANNEL_SHOP_SNAPSHOT_TABLE, 'may_active_sku_count', 'INT NOT NULL DEFAULT 0 COMMENT \'MAY动销SKU数\''), ensurePlanningTableColumn(MAY_CHANNEL_SHOP_SNAPSHOT_TABLE, 'may_active_item_id_count', 'INT NOT NULL DEFAULT 0 COMMENT \'MAY动销ID数\''), ensurePlanningTableColumn(MAY_CHANNEL_SHOP_SNAPSHOT_TABLE, 'may_sales_amount_30d', 'DECIMAL(18, 2) NOT NULL DEFAULT 0 COMMENT \'MAY覆盖30天销售额\''), ensurePlanningTableColumn(MAY_CHANNEL_SHOP_SNAPSHOT_TABLE, 'may_sales_qty_30d', 'DECIMAL(18, 4) NOT NULL DEFAULT 0 COMMENT \'MAY覆盖30天销量\''), ensurePlanningTableColumn(MAY_CHANNEL_SHOP_SNAPSHOT_TABLE, 'may_promotion_fee_30d', 'DECIMAL(18, 2) NOT NULL DEFAULT 0 COMMENT \'MAY覆盖30天推广费\''), ensurePlanningTableColumn(MAY_CHANNEL_SHOP_SNAPSHOT_TABLE, 'may_after_sale_amount_30d', 'DECIMAL(18, 2) NOT NULL DEFAULT 0 COMMENT \'MAY覆盖30天售后金额\''), ]); await planningDatabase.execute(` CREATE TABLE IF NOT EXISTS ${escapeIdentifier(MAY_CHANNEL_LAYOUT_SNAPSHOT_TABLE)} ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, stat_date DATE NOT NULL, snapshot_batch_id VARCHAR(80) NOT NULL, source_latest_biz_date DATE NULL, may_sku_code VARCHAR(120) NOT NULL, category_name VARCHAR(255) NULL, may_sku_image_url TEXT NULL, may_sales_status VARCHAR(80) NULL, review_action VARCHAR(80) NULL, may_inventory_quantity DECIMAL(18, 4) NOT NULL DEFAULT 0, may_goods_id_count INT NOT NULL DEFAULT 0, may_performance_amount DECIMAL(18, 2) NOT NULL DEFAULT 0, may_promotion_fee_30d DECIMAL(18, 2) NOT NULL DEFAULT 0, promotion_roi DECIMAL(18, 4) NULL, covered_channel_count INT NOT NULL DEFAULT 0, covered_shop_count INT NOT NULL DEFAULT 0, covered_item_id_count INT NOT NULL DEFAULT 0, covered_channel_names TEXT NULL, covered_shop_names MEDIUMTEXT NULL, missing_channel_names TEXT NULL, missing_channel_count INT NOT NULL DEFAULT 0, layout_label VARCHAR(80) NOT NULL DEFAULT '布局健康', layout_reason TEXT NULL, risk_score DECIMAL(10, 2) NOT NULL DEFAULT 0, sales_amount_30d DECIMAL(18, 2) NOT NULL DEFAULT 0, sales_qty_30d DECIMAL(18, 4) NOT NULL DEFAULT 0, promotion_fee_30d DECIMAL(18, 2) NOT NULL DEFAULT 0, after_sale_amount_30d DECIMAL(18, 2) NOT NULL DEFAULT 0, generated_at DATETIME NOT NULL, calc_version VARCHAR(80) NOT NULL, UNIQUE KEY uk_may_channel_layout_snapshot (stat_date, snapshot_batch_id, calc_version, may_sku_code), INDEX idx_may_channel_layout_label (stat_date, snapshot_batch_id, layout_label), INDEX idx_may_channel_layout_risk (stat_date, snapshot_batch_id, risk_score), INDEX idx_may_channel_layout_sku (may_sku_code) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); } async function resolveMaySnapshotMeta() { const summary = await getPlanningMayProductDataSummary(); if (!summary?.snapshotBatchId || !summary?.snapshotDate) { return null; } return { snapshotDate: formatDateOnly(summary.snapshotDate), snapshotBatchId: summary.snapshotBatchId, sourceLatestBizDate: formatDateOnly(summary.sourceLatestBizDate || summary.snapshotDate), totalRows: toNumber(summary.totalRows, 0), }; } async function getExistingInsightMeta(snapshotMeta) { if (!snapshotMeta?.snapshotDate || !snapshotMeta?.snapshotBatchId) { return null; } const [row] = await planningDatabase.query( `SELECT COUNT(*) AS totalRows, MAX(generated_at) AS generatedAt FROM ${escapeIdentifier(MAY_CHANNEL_LAYOUT_SNAPSHOT_TABLE)} WHERE stat_date = ? AND snapshot_batch_id = ? AND calc_version = ?`, [snapshotMeta.snapshotDate, snapshotMeta.snapshotBatchId, MAY_CHANNEL_INSIGHT_CALC_VERSION] ); const totalRows = toNumber(row?.totalRows, 0); if (totalRows <= 0) { return null; } return { snapshotDate: formatDateOnly(snapshotMeta.snapshotDate), snapshotBatchId: snapshotMeta.snapshotBatchId, sourceLatestBizDate: formatDateOnly(snapshotMeta.sourceLatestBizDate || snapshotMeta.snapshotDate), totalRows, generatedAt: row?.generatedAt || null, calcVersion: MAY_CHANNEL_INSIGHT_CALC_VERSION, }; } async function getLatestAvailableInsightMeta() { const [row] = await planningDatabase.query( `SELECT stat_date AS snapshotDate, snapshot_batch_id AS snapshotBatchId, MAX(source_latest_biz_date) AS sourceLatestBizDate, COUNT(*) AS totalRows, MAX(generated_at) AS generatedAt, MAX(calc_version) AS calcVersion FROM ${escapeIdentifier(MAY_CHANNEL_LAYOUT_SNAPSHOT_TABLE)} WHERE calc_version = ? GROUP BY stat_date, snapshot_batch_id HAVING COUNT(*) > 0 ORDER BY generatedAt DESC LIMIT 1`, [MAY_CHANNEL_INSIGHT_CALC_VERSION] ); const totalRows = toNumber(row?.totalRows, 0); if (totalRows <= 0) { return null; } return { snapshotDate: formatDateOnly(row.snapshotDate), snapshotBatchId: normalizeText(row.snapshotBatchId), sourceLatestBizDate: formatDateOnly(row.sourceLatestBizDate || row.snapshotDate), totalRows, generatedAt: row.generatedAt || null, calcVersion: normalizeText(row.calcVersion) || MAY_CHANNEL_INSIGHT_CALC_VERSION, }; } function schedulePlanningMayChannelInsightRefresh(snapshotMeta) { const refreshKey = buildInsightSnapshotKey(snapshotMeta); if (!refreshKey) { return false; } if (mayChannelInsightRefreshPromise) { return mayChannelInsightRefreshKey === refreshKey; } mayChannelInsightRefreshKey = refreshKey; mayChannelInsightRefreshPromise = (async () => { try { const result = await refreshPlanningMayChannelInsightSnapshot(); console.log('[PlanningMayChannelInsight] 后台刷新完成:', JSON.stringify(result)); } catch (error) { console.error('[PlanningMayChannelInsight] 后台刷新失败:', error); } finally { mayChannelInsightRefreshPromise = null; mayChannelInsightRefreshKey = ''; } })(); return true; } async function refreshPlanningMayChannelInsightSnapshot() { await ensurePlanningMayChannelInsightSchema(); const snapshotMeta = await resolveMaySnapshotMeta(); if (!snapshotMeta) { return { success: false, message: 'MAY商品快照尚未生成,无法构建渠道布局洞察。', totalRows: 0, }; } const channelLabelExpression = buildChannelLabelExpression('d'); const missingChannelNamesExpression = buildMissingChannelNamesExpression('c'); const missingChannelCountExpression = buildMissingChannelCountExpression('c'); const generatedAt = new Date(); const shopListingSummaryMap = await queryShopListingSummaryMap(snapshotMeta); const { shopMetricMap, mayMetricMap } = await queryShopCurrentMetricMaps(snapshotMeta, shopListingSummaryMap); await planningDatabase.execute( `DELETE FROM ${escapeIdentifier(MAY_CHANNEL_ACTIVE_SKU_TABLE)} WHERE stat_date = ? AND snapshot_batch_id = ? AND calc_version = ?`, [snapshotMeta.snapshotDate, snapshotMeta.snapshotBatchId, MAY_CHANNEL_INSIGHT_CALC_VERSION] ); await planningDatabase.execute( `DELETE FROM ${escapeIdentifier(MAY_CHANNEL_SHOP_SNAPSHOT_TABLE)} WHERE stat_date = ? AND snapshot_batch_id = ? AND calc_version = ?`, [snapshotMeta.snapshotDate, snapshotMeta.snapshotBatchId, MAY_CHANNEL_INSIGHT_CALC_VERSION] ); await planningDatabase.execute( `DELETE FROM ${escapeIdentifier(MAY_CHANNEL_LAYOUT_SNAPSHOT_TABLE)} WHERE stat_date = ? AND snapshot_batch_id = ? AND calc_version = ?`, [snapshotMeta.snapshotDate, snapshotMeta.snapshotBatchId, MAY_CHANNEL_INSIGHT_CALC_VERSION] ); await planningDatabase.execute( ` INSERT INTO ${escapeIdentifier(MAY_CHANNEL_ACTIVE_SKU_TABLE)} ( stat_date, snapshot_batch_id, calc_version, sku_key, may_sku_code, category_name, may_sku_image_url, may_sales_status, review_action, may_inventory_quantity, may_goods_id_count, may_performance_amount, may_promotion_fee_30d, generated_at ) SELECT ? AS stat_date, ? AS snapshot_batch_id, ? AS calc_version, UPPER(TRIM(s.may_sku_code)) AS sku_key, MAX(s.may_sku_code) AS may_sku_code, MAX(s.category_name) AS category_name, MAX(s.may_sku_image_url) AS may_sku_image_url, MAX(s.may_sales_status) AS may_sales_status, MAX(r.review_action) AS review_action, MAX(COALESCE(s.may_inventory_quantity, 0)) AS may_inventory_quantity, MAX(COALESCE(s.may_goods_id_count, 0)) AS may_goods_id_count, MAX(COALESCE(s.may_performance_amount, 0)) AS may_performance_amount, MAX(COALESCE(s.may_promotion_fee_30d, 0)) AS may_promotion_fee_30d, ? AS generated_at FROM ${escapeIdentifier(MAY_PRODUCT_SNAPSHOT_TABLE)} s LEFT JOIN ${escapeIdentifier(MAY_PRODUCT_REVIEW_SNAPSHOT_TABLE)} r ON r.stat_date = s.stat_date AND r.snapshot_batch_id = s.snapshot_batch_id AND r.may_sku_code = s.may_sku_code WHERE s.stat_date = ? AND s.snapshot_batch_id = ? AND NULLIF(TRIM(s.may_sku_code), '') IS NOT NULL AND ${buildNotDiscontinuedCondition('s.may_sales_status')} GROUP BY UPPER(TRIM(s.may_sku_code)) `, [ snapshotMeta.snapshotDate, snapshotMeta.snapshotBatchId, MAY_CHANNEL_INSIGHT_CALC_VERSION, generatedAt, snapshotMeta.snapshotDate, snapshotMeta.snapshotBatchId, ] ); await planningDatabase.execute( ` INSERT INTO ${escapeIdentifier(MAY_CHANNEL_SHOP_SNAPSHOT_TABLE)} ( stat_date, snapshot_batch_id, source_latest_biz_date, channel_name, shop_name, shop_key, sku_count, item_id_count, active_sku_count, active_item_id_count, sales_amount_30d, transaction_amount_30d, after_sale_amount_30d, sales_qty_30d, refund_count_30d, order_count_30d, promotion_fee_30d, gross_cost_amount_30d, may_sku_count, may_item_id_count, may_active_sku_count, may_active_item_id_count, may_sales_amount_30d, may_sales_qty_30d, may_promotion_fee_30d, may_after_sale_amount_30d, generated_at, calc_version ) SELECT ? AS stat_date, ? AS snapshot_batch_id, ? AS source_latest_biz_date, raw.channel_label AS channel_name, raw.shop_name, raw.shop_key, raw.sku_count, raw.item_id_count, raw.active_sku_count, raw.active_item_id_count, raw.sales_amount_30d, raw.transaction_amount_30d, raw.after_sale_amount_30d, raw.sales_qty_30d, raw.refund_count_30d, raw.order_count_30d, raw.promotion_fee_30d, raw.gross_cost_amount_30d, COALESCE(may.sku_count, 0) AS may_sku_count, COALESCE(may.item_id_count, 0) AS may_item_id_count, COALESCE(may.active_sku_count, 0) AS may_active_sku_count, COALESCE(may.active_item_id_count, 0) AS may_active_item_id_count, COALESCE(may.sales_amount_30d, 0) AS may_sales_amount_30d, COALESCE(may.sales_qty_30d, 0) AS may_sales_qty_30d, COALESCE(may.promotion_fee_30d, 0) AS may_promotion_fee_30d, COALESCE(may.after_sale_amount_30d, 0) AS may_after_sale_amount_30d, ? AS generated_at, ? AS calc_version FROM ( SELECT d.channel_label, COALESCE(NULLIF(TRIM(d.shop_name), ''), '未识别店铺') AS shop_name, COALESCE(NULLIF(TRIM(d.shop_key), ''), NULLIF(TRIM(d.shop_name), '')) AS shop_key, COUNT(DISTINCT UPPER(TRIM(d.primary_sku_code))) AS sku_count, COUNT(DISTINCT NULLIF(TRIM(d.item_id), '')) AS item_id_count, COUNT(DISTINCT CASE WHEN COALESCE(d.performance_amount, 0) > 0 OR COALESCE(d.goods_count, 0) > 0 THEN UPPER(TRIM(d.primary_sku_code)) END) AS active_sku_count, COUNT(DISTINCT CASE WHEN COALESCE(d.performance_amount, 0) > 0 OR COALESCE(d.goods_count, 0) > 0 THEN NULLIF(TRIM(d.item_id), '') END) AS active_item_id_count, SUM(COALESCE(d.transaction_amount, 0)) AS sales_amount_30d, SUM(COALESCE(d.transaction_amount, 0)) AS transaction_amount_30d, SUM(COALESCE(d.after_sale_amount, 0)) AS after_sale_amount_30d, SUM(COALESCE(d.goods_count, 0)) AS sales_qty_30d, SUM(COALESCE(d.refund_count, 0)) AS refund_count_30d, SUM(COALESCE(d.order_count, 0)) AS order_count_30d, SUM( CASE WHEN COALESCE(d.push_fee_amount, 0) + COALESCE(d.marketing_fee_amount, 0) > 0 THEN COALESCE(d.push_fee_amount, 0) + COALESCE(d.marketing_fee_amount, 0) ELSE COALESCE(d.fee_amount, 0) END ) AS promotion_fee_30d, SUM(COALESCE(d.gross_cost_amount, 0)) AS gross_cost_amount_30d FROM ( SELECT d.*, ${channelLabelExpression} AS channel_label FROM ${escapeIdentifier(CHANNEL_ID_READ_TABLE)} d WHERE d.stat_date BETWEEN DATE_SUB(?, INTERVAL 29 DAY) AND ? AND NULLIF(TRIM(d.primary_sku_code), '') IS NOT NULL ) d GROUP BY d.channel_label, COALESCE(NULLIF(TRIM(d.shop_name), ''), '未识别店铺'), COALESCE(NULLIF(TRIM(d.shop_key), ''), NULLIF(TRIM(d.shop_name), '')) ) raw LEFT JOIN ( SELECT d.channel_label, COALESCE(NULLIF(TRIM(d.shop_name), ''), '未识别店铺') AS shop_name, COALESCE(NULLIF(TRIM(d.shop_key), ''), NULLIF(TRIM(d.shop_name), '')) AS shop_key, COUNT(DISTINCT UPPER(TRIM(d.primary_sku_code))) AS sku_count, COUNT(DISTINCT NULLIF(TRIM(d.item_id), '')) AS item_id_count, COUNT(DISTINCT CASE WHEN COALESCE(d.performance_amount, 0) > 0 OR COALESCE(d.goods_count, 0) > 0 THEN UPPER(TRIM(d.primary_sku_code)) END) AS active_sku_count, COUNT(DISTINCT CASE WHEN COALESCE(d.performance_amount, 0) > 0 OR COALESCE(d.goods_count, 0) > 0 THEN NULLIF(TRIM(d.item_id), '') END) AS active_item_id_count, SUM(COALESCE(d.transaction_amount, 0)) AS sales_amount_30d, SUM(COALESCE(d.after_sale_amount, 0)) AS after_sale_amount_30d, SUM(COALESCE(d.goods_count, 0)) AS sales_qty_30d, SUM( CASE WHEN COALESCE(d.push_fee_amount, 0) + COALESCE(d.marketing_fee_amount, 0) > 0 THEN COALESCE(d.push_fee_amount, 0) + COALESCE(d.marketing_fee_amount, 0) ELSE COALESCE(d.fee_amount, 0) END ) AS promotion_fee_30d FROM ( SELECT d.*, ${channelLabelExpression} AS channel_label FROM ${escapeIdentifier(CHANNEL_ID_READ_TABLE)} d WHERE d.stat_date BETWEEN DATE_SUB(?, INTERVAL 29 DAY) AND ? AND NULLIF(TRIM(d.primary_sku_code), '') IS NOT NULL ) d STRAIGHT_JOIN ${escapeIdentifier(MAY_CHANNEL_ACTIVE_SKU_TABLE)} s ON s.stat_date = ? AND s.snapshot_batch_id = ? AND s.calc_version = ? AND s.sku_key = UPPER(TRIM(d.primary_sku_code)) GROUP BY d.channel_label, COALESCE(NULLIF(TRIM(d.shop_name), ''), '未识别店铺'), COALESCE(NULLIF(TRIM(d.shop_key), ''), NULLIF(TRIM(d.shop_name), '')) ) may ON may.channel_label = raw.channel_label AND may.shop_name = raw.shop_name AND COALESCE(may.shop_key, '') = COALESCE(raw.shop_key, '') `, [ snapshotMeta.snapshotDate, snapshotMeta.snapshotBatchId, snapshotMeta.sourceLatestBizDate, generatedAt, MAY_CHANNEL_INSIGHT_CALC_VERSION, snapshotMeta.sourceLatestBizDate, snapshotMeta.sourceLatestBizDate, snapshotMeta.sourceLatestBizDate, snapshotMeta.sourceLatestBizDate, snapshotMeta.snapshotDate, snapshotMeta.snapshotBatchId, MAY_CHANNEL_INSIGHT_CALC_VERSION, ] ); await applyShopListingSummary(snapshotMeta, shopListingSummaryMap, shopMetricMap, mayMetricMap); await planningDatabase.execute( ` INSERT INTO ${escapeIdentifier(MAY_CHANNEL_LAYOUT_SNAPSHOT_TABLE)} ( stat_date, snapshot_batch_id, source_latest_biz_date, may_sku_code, category_name, may_sku_image_url, may_sales_status, review_action, may_inventory_quantity, may_goods_id_count, may_performance_amount, may_promotion_fee_30d, promotion_roi, covered_channel_count, covered_shop_count, covered_item_id_count, covered_channel_names, covered_shop_names, missing_channel_names, missing_channel_count, layout_label, layout_reason, risk_score, sales_amount_30d, sales_qty_30d, promotion_fee_30d, after_sale_amount_30d, generated_at, calc_version ) SELECT ? AS stat_date, ? AS snapshot_batch_id, ? AS source_latest_biz_date, s.may_sku_code, s.category_name, s.may_sku_image_url, s.may_sales_status, s.review_action, COALESCE(s.may_inventory_quantity, 0) AS may_inventory_quantity, COALESCE(s.may_goods_id_count, 0) AS may_goods_id_count, COALESCE(s.may_performance_amount, 0) AS may_performance_amount, COALESCE(s.may_promotion_fee_30d, 0) AS may_promotion_fee_30d, CASE WHEN COALESCE(c.promotion_fee_30d, s.may_promotion_fee_30d, 0) > 0 THEN COALESCE(c.sales_amount_30d, s.may_performance_amount, 0) / NULLIF(COALESCE(c.promotion_fee_30d, s.may_promotion_fee_30d, 0), 0) ELSE NULL END AS promotion_roi, COALESCE(c.covered_channel_count, 0) AS covered_channel_count, COALESCE(c.covered_shop_count, 0) AS covered_shop_count, COALESCE(c.covered_item_id_count, 0) AS covered_item_id_count, c.covered_channel_names, c.covered_shop_names, ${missingChannelNamesExpression} AS missing_channel_names, ${missingChannelCountExpression} AS missing_channel_count, CASE WHEN COALESCE(s.may_inventory_quantity, 0) >= ? AND COALESCE(c.covered_item_id_count, COALESCE(s.may_goods_id_count, 0), 0) <= ? THEN '库存多ID少' WHEN COALESCE(s.may_inventory_quantity, 0) > 0 AND (${missingChannelCountExpression}) >= 3 THEN 'ID布局缺失' WHEN COALESCE(c.promotion_fee_30d, s.may_promotion_fee_30d, 0) > 0 AND ( COALESCE(c.sales_amount_30d, s.may_performance_amount, 0) <= 0 OR COALESCE(c.sales_amount_30d, s.may_performance_amount, 0) / NULLIF(COALESCE(c.promotion_fee_30d, s.may_promotion_fee_30d, 0), 0) < ? ) THEN '推广低效' WHEN COALESCE(c.covered_shop_count, 0) <= 1 AND COALESCE(s.may_inventory_quantity, 0) >= 20 THEN '店铺覆盖不足' ELSE '布局健康' END AS layout_label, CONCAT_WS(';', IF(COALESCE(s.may_inventory_quantity, 0) >= ? AND COALESCE(c.covered_item_id_count, COALESCE(s.may_goods_id_count, 0), 0) <= ?, CONCAT('库存 ', ROUND(COALESCE(s.may_inventory_quantity, 0), 0), ',ID仅 ', COALESCE(c.covered_item_id_count, COALESCE(s.may_goods_id_count, 0), 0)), NULL), IF(COALESCE(s.may_inventory_quantity, 0) > 0 AND (${missingChannelCountExpression}) >= 3, CONCAT('缺少渠道:', ${missingChannelNamesExpression}), NULL), IF(COALESCE(c.promotion_fee_30d, s.may_promotion_fee_30d, 0) > 0 AND ( COALESCE(c.sales_amount_30d, s.may_performance_amount, 0) <= 0 OR COALESCE(c.sales_amount_30d, s.may_performance_amount, 0) / NULLIF(COALESCE(c.promotion_fee_30d, s.may_promotion_fee_30d, 0), 0) < ? ), CONCAT('推广ROI ', ROUND(COALESCE(c.sales_amount_30d, s.may_performance_amount, 0) / NULLIF(COALESCE(c.promotion_fee_30d, s.may_promotion_fee_30d, 0), 0), 2)), NULL), IF(COALESCE(c.covered_shop_count, 0) <= 1 AND COALESCE(s.may_inventory_quantity, 0) >= 20, CONCAT('覆盖店铺仅 ', COALESCE(c.covered_shop_count, 0), ' 个'), NULL) ) AS layout_reason, LEAST(100, IF(COALESCE(s.may_inventory_quantity, 0) >= ? AND COALESCE(c.covered_item_id_count, COALESCE(s.may_goods_id_count, 0), 0) <= ?, 35, 0) + IF(COALESCE(s.may_inventory_quantity, 0) > 0 AND (${missingChannelCountExpression}) >= 3, 25, 0) + IF(COALESCE(c.promotion_fee_30d, s.may_promotion_fee_30d, 0) > 0 AND ( COALESCE(c.sales_amount_30d, s.may_performance_amount, 0) <= 0 OR COALESCE(c.sales_amount_30d, s.may_performance_amount, 0) / NULLIF(COALESCE(c.promotion_fee_30d, s.may_promotion_fee_30d, 0), 0) < ? ), 20, 0) + IF(COALESCE(c.sales_amount_30d, s.may_performance_amount, 0) <= 0 AND COALESCE(s.may_inventory_quantity, 0) > 0, 20, 0) ) AS risk_score, COALESCE(c.sales_amount_30d, 0) AS sales_amount_30d, COALESCE(c.sales_qty_30d, 0) AS sales_qty_30d, COALESCE(c.promotion_fee_30d, s.may_promotion_fee_30d, 0) AS promotion_fee_30d, COALESCE(c.after_sale_amount_30d, 0) AS after_sale_amount_30d, ? AS generated_at, ? AS calc_version FROM ${escapeIdentifier(MAY_CHANNEL_ACTIVE_SKU_TABLE)} s LEFT JOIN ( SELECT UPPER(TRIM(c.primary_sku_code)) AS sku_key, COUNT(DISTINCT c.channel_label) AS covered_channel_count, COUNT(DISTINCT COALESCE(NULLIF(TRIM(c.shop_name), ''), '未识别店铺')) AS covered_shop_count, COUNT(DISTINCT NULLIF(TRIM(c.item_id), '')) AS covered_item_id_count, GROUP_CONCAT(DISTINCT c.channel_label ORDER BY c.channel_label SEPARATOR '、') AS covered_channel_names, GROUP_CONCAT(DISTINCT COALESCE(NULLIF(TRIM(c.shop_name), ''), '未识别店铺') ORDER BY COALESCE(NULLIF(TRIM(c.shop_name), ''), '未识别店铺') SEPARATOR '、') AS covered_shop_names, SUM(COALESCE(c.transaction_amount, 0)) AS sales_amount_30d, SUM(COALESCE(c.goods_count, 0)) AS sales_qty_30d, SUM(COALESCE(c.after_sale_amount, 0)) AS after_sale_amount_30d, SUM( CASE WHEN COALESCE(c.push_fee_amount, 0) + COALESCE(c.marketing_fee_amount, 0) > 0 THEN COALESCE(c.push_fee_amount, 0) + COALESCE(c.marketing_fee_amount, 0) ELSE COALESCE(c.fee_amount, 0) END ) AS promotion_fee_30d, ${buildMajorChannelCountSelects()} FROM ( SELECT d.*, ${channelLabelExpression} AS channel_label FROM ${escapeIdentifier(CHANNEL_ID_READ_TABLE)} d WHERE d.stat_date BETWEEN DATE_SUB(?, INTERVAL 29 DAY) AND ? AND NULLIF(TRIM(d.primary_sku_code), '') IS NOT NULL ) c STRAIGHT_JOIN ${escapeIdentifier(MAY_CHANNEL_ACTIVE_SKU_TABLE)} m ON m.stat_date = ? AND m.snapshot_batch_id = ? AND m.calc_version = ? AND m.sku_key = UPPER(TRIM(c.primary_sku_code)) GROUP BY UPPER(TRIM(c.primary_sku_code)) ) c ON c.sku_key = s.sku_key WHERE s.stat_date = ? AND s.snapshot_batch_id = ? AND s.calc_version = ? `, [ snapshotMeta.snapshotDate, snapshotMeta.snapshotBatchId, snapshotMeta.sourceLatestBizDate, HIGH_INVENTORY_THRESHOLD, LOW_ITEM_ID_THRESHOLD, LOW_PROMOTION_ROI_THRESHOLD, HIGH_INVENTORY_THRESHOLD, LOW_ITEM_ID_THRESHOLD, LOW_PROMOTION_ROI_THRESHOLD, HIGH_INVENTORY_THRESHOLD, LOW_ITEM_ID_THRESHOLD, LOW_PROMOTION_ROI_THRESHOLD, generatedAt, MAY_CHANNEL_INSIGHT_CALC_VERSION, snapshotMeta.sourceLatestBizDate, snapshotMeta.sourceLatestBizDate, snapshotMeta.snapshotDate, snapshotMeta.snapshotBatchId, MAY_CHANNEL_INSIGHT_CALC_VERSION, snapshotMeta.snapshotDate, snapshotMeta.snapshotBatchId, MAY_CHANNEL_INSIGHT_CALC_VERSION, ] ); const [row] = await planningDatabase.query( `SELECT COUNT(*) AS totalRows FROM ${escapeIdentifier(MAY_CHANNEL_LAYOUT_SNAPSHOT_TABLE)} WHERE stat_date = ? AND snapshot_batch_id = ? AND calc_version = ?`, [snapshotMeta.snapshotDate, snapshotMeta.snapshotBatchId, MAY_CHANNEL_INSIGHT_CALC_VERSION] ); return { success: true, statDate: snapshotMeta.snapshotDate, snapshotBatchId: snapshotMeta.snapshotBatchId, sourceLatestBizDate: snapshotMeta.sourceLatestBizDate, tableNames: [MAY_CHANNEL_SHOP_SNAPSHOT_TABLE, MAY_CHANNEL_LAYOUT_SNAPSHOT_TABLE], totalRows: toNumber(row?.totalRows, 0), generatedAt, calcVersion: MAY_CHANNEL_INSIGHT_CALC_VERSION, }; } async function ensurePlanningMayChannelInsightFresh(options = {}) { await ensurePlanningMayChannelInsightSchema(); const snapshotMeta = await resolveMaySnapshotMeta(); if (!snapshotMeta) { return null; } const existingMeta = await getExistingInsightMeta(snapshotMeta); if (existingMeta) { return { ...existingMeta, isStale: false, refreshPending: false, currentSnapshotDate: existingMeta.snapshotDate, currentSnapshotBatchId: existingMeta.snapshotBatchId, requestedSnapshotDate: existingMeta.snapshotDate, requestedSnapshotBatchId: existingMeta.snapshotBatchId, }; } if (options.force) { const refreshResult = await refreshPlanningMayChannelInsightSnapshot(); return { snapshotDate: formatDateOnly(refreshResult.statDate || snapshotMeta.snapshotDate), snapshotBatchId: normalizeText(refreshResult.snapshotBatchId || snapshotMeta.snapshotBatchId), sourceLatestBizDate: formatDateOnly(refreshResult.sourceLatestBizDate || snapshotMeta.sourceLatestBizDate), totalRows: refreshResult.totalRows || 0, generatedAt: refreshResult.generatedAt || new Date(), calcVersion: MAY_CHANNEL_INSIGHT_CALC_VERSION, isStale: false, refreshPending: false, currentSnapshotDate: formatDateOnly(refreshResult.statDate || snapshotMeta.snapshotDate), currentSnapshotBatchId: normalizeText(refreshResult.snapshotBatchId || snapshotMeta.snapshotBatchId), requestedSnapshotDate: snapshotMeta.snapshotDate, requestedSnapshotBatchId: snapshotMeta.snapshotBatchId, }; } const fallbackMeta = await getLatestAvailableInsightMeta(); const refreshPending = schedulePlanningMayChannelInsightRefresh(snapshotMeta) || Boolean(mayChannelInsightRefreshPromise); if (fallbackMeta) { return { ...fallbackMeta, isStale: true, refreshPending, currentSnapshotDate: fallbackMeta.snapshotDate, currentSnapshotBatchId: fallbackMeta.snapshotBatchId, requestedSnapshotDate: snapshotMeta.snapshotDate, requestedSnapshotBatchId: snapshotMeta.snapshotBatchId, requestedSourceLatestBizDate: snapshotMeta.sourceLatestBizDate, }; } return { snapshotDate: snapshotMeta.snapshotDate, snapshotBatchId: snapshotMeta.snapshotBatchId, sourceLatestBizDate: snapshotMeta.sourceLatestBizDate, totalRows: 0, generatedAt: null, calcVersion: MAY_CHANNEL_INSIGHT_CALC_VERSION, isStale: true, refreshPending, currentSnapshotDate: snapshotMeta.snapshotDate, currentSnapshotBatchId: snapshotMeta.snapshotBatchId, requestedSnapshotDate: snapshotMeta.snapshotDate, requestedSnapshotBatchId: snapshotMeta.snapshotBatchId, }; } function buildLayoutWhere(view) { const normalizedView = normalizeText(view || 'all'); if (normalizedView === 'missing') { return { sql: 'missing_channel_count > 0 AND may_inventory_quantity > 0', params: [], }; } if (normalizedView === 'high_inventory_low_id') { return { sql: 'layout_label = ?', params: ['库存多ID少'], }; } if (normalizedView === 'promotion') { return { sql: 'promotion_fee_30d > 0', params: [], }; } if (normalizedView === 'single_shop') { return { sql: 'covered_shop_count <= 1 AND may_inventory_quantity > 0', params: [], }; } return { sql: '1 = 1', params: [], }; } async function getPlanningMayChannelInsight(options = {}) { const page = Math.max(Number(options.page) || 1, 1); const pageSize = Math.min(Math.max(Number(options.pageSize) || 12, 1), 50); const view = normalizeText(options.view || 'shops') || 'shops'; const meta = await ensurePlanningMayChannelInsightFresh({ force: ['1', 'true'].includes(String(options.refresh || '').toLowerCase()) }); if (!meta) { return { meta: null, summary: {}, rows: [], total: 0, page, pageSize, view, message: 'MAY商品快照尚未生成。', }; } const [summaryRow] = await planningDatabase.query( `SELECT COUNT(*) AS totalSku, SUM(COALESCE(may_inventory_quantity, 0)) AS totalInventory, SUM(COALESCE(sales_amount_30d, 0)) AS totalSalesAmount, SUM(COALESCE(promotion_fee_30d, 0)) AS totalPromotionFee, SUM(CASE WHEN missing_channel_count > 0 AND may_inventory_quantity > 0 THEN 1 ELSE 0 END) AS missingChannelSkuCount, SUM(CASE WHEN layout_label = '库存多ID少' THEN 1 ELSE 0 END) AS highInventoryLowIdSkuCount, SUM(CASE WHEN promotion_fee_30d > 0 AND (sales_amount_30d <= 0 OR sales_amount_30d / NULLIF(promotion_fee_30d, 0) < ?) THEN 1 ELSE 0 END) AS promotionInefficientSkuCount, SUM(CASE WHEN covered_shop_count <= 1 AND may_inventory_quantity > 0 THEN 1 ELSE 0 END) AS singleShopSkuCount, AVG(NULLIF(covered_channel_count, 0)) AS avgCoveredChannelCount, AVG(NULLIF(covered_shop_count, 0)) AS avgCoveredShopCount FROM ${escapeIdentifier(MAY_CHANNEL_LAYOUT_SNAPSHOT_TABLE)} WHERE stat_date = ? AND snapshot_batch_id = ? AND calc_version = ?`, [LOW_PROMOTION_ROI_THRESHOLD, meta.snapshotDate, meta.snapshotBatchId, MAY_CHANNEL_INSIGHT_CALC_VERSION] ); const [shopSummaryRow] = await planningDatabase.query( `SELECT COUNT(DISTINCT channel_name) AS channelCount, COUNT(DISTINCT shop_name) AS shopCount, COUNT(*) AS channelShopCount, SUM(COALESCE(sku_count, 0)) AS rawSkuCount, SUM(COALESCE(item_id_count, 0)) AS rawItemIdCount, SUM(COALESCE(sales_amount_30d, 0)) AS rawSalesAmount, SUM(COALESCE(sales_qty_30d, 0)) AS rawSalesQty, SUM(COALESCE(promotion_fee_30d, 0)) AS rawPromotionFee, SUM(COALESCE(may_sku_count, 0)) AS maySkuCount, SUM(COALESCE(may_item_id_count, 0)) AS mayItemIdCount, SUM(COALESCE(may_sales_amount_30d, 0)) AS maySalesAmount, SUM(COALESCE(may_sales_qty_30d, 0)) AS maySalesQty, SUM(COALESCE(may_promotion_fee_30d, 0)) AS mayPromotionFee FROM ${escapeIdentifier(MAY_CHANNEL_SHOP_SNAPSHOT_TABLE)} WHERE stat_date = ? AND snapshot_batch_id = ? AND calc_version = ?`, [meta.snapshotDate, meta.snapshotBatchId, MAY_CHANNEL_INSIGHT_CALC_VERSION] ); const summary = { totalSku: toNumber(summaryRow?.totalSku, 0), totalInventory: toNumber(summaryRow?.totalInventory, 0), totalSalesAmount: toNumber(summaryRow?.totalSalesAmount, 0), totalPromotionFee: toNumber(summaryRow?.totalPromotionFee, 0), channelCount: toNumber(shopSummaryRow?.channelCount, 0), shopCount: toNumber(shopSummaryRow?.shopCount, 0), channelShopCount: toNumber(shopSummaryRow?.channelShopCount, 0), missingChannelSkuCount: toNumber(summaryRow?.missingChannelSkuCount, 0), highInventoryLowIdSkuCount: toNumber(summaryRow?.highInventoryLowIdSkuCount, 0), promotionInefficientSkuCount: toNumber(summaryRow?.promotionInefficientSkuCount, 0), singleShopSkuCount: toNumber(summaryRow?.singleShopSkuCount, 0), avgCoveredChannelCount: toNumber(summaryRow?.avgCoveredChannelCount, 0), avgCoveredShopCount: toNumber(summaryRow?.avgCoveredShopCount, 0), rawSkuCount: toNumber(shopSummaryRow?.rawSkuCount, 0), rawItemIdCount: toNumber(shopSummaryRow?.rawItemIdCount, 0), rawSalesAmount: toNumber(shopSummaryRow?.rawSalesAmount, 0), rawSalesQty: toNumber(shopSummaryRow?.rawSalesQty, 0), rawPromotionFee: toNumber(shopSummaryRow?.rawPromotionFee, 0), maySkuCount: toNumber(shopSummaryRow?.maySkuCount, 0), mayItemIdCount: toNumber(shopSummaryRow?.mayItemIdCount, 0), maySalesAmount: toNumber(shopSummaryRow?.maySalesAmount, 0), maySalesQty: toNumber(shopSummaryRow?.maySalesQty, 0), mayPromotionFee: toNumber(shopSummaryRow?.mayPromotionFee, 0), }; if (view === 'shops') { summary.totalSalesAmount = summary.rawSalesAmount; summary.totalPromotionFee = summary.rawPromotionFee; } const offset = (page - 1) * pageSize; if (view === 'shops') { const [countRow] = await planningDatabase.query( `SELECT COUNT(*) AS total FROM ${escapeIdentifier(MAY_CHANNEL_SHOP_SNAPSHOT_TABLE)} WHERE stat_date = ? AND snapshot_batch_id = ? AND calc_version = ?`, [meta.snapshotDate, meta.snapshotBatchId, MAY_CHANNEL_INSIGHT_CALC_VERSION] ); const rows = await planningDatabase.query( `SELECT channel_name AS channelName, shop_name AS shopName, shop_key AS shopKey, sku_count AS skuCount, item_id_count AS itemIdCount, active_sku_count AS activeSkuCount, active_item_id_count AS activeItemIdCount, sales_amount_30d AS salesAmount30d, transaction_amount_30d AS transactionAmount30d, after_sale_amount_30d AS afterSaleAmount30d, sales_qty_30d AS salesQty30d, refund_count_30d AS refundCount30d, order_count_30d AS orderCount30d, promotion_fee_30d AS promotionFee30d, gross_cost_amount_30d AS grossCostAmount30d, may_sku_count AS maySkuCount, may_item_id_count AS mayItemIdCount, may_active_sku_count AS mayActiveSkuCount, may_active_item_id_count AS mayActiveItemIdCount, may_sales_amount_30d AS maySalesAmount30d, may_sales_qty_30d AS maySalesQty30d, may_promotion_fee_30d AS mayPromotionFee30d, may_after_sale_amount_30d AS mayAfterSaleAmount30d FROM ${escapeIdentifier(MAY_CHANNEL_SHOP_SNAPSHOT_TABLE)} WHERE stat_date = ? AND snapshot_batch_id = ? AND calc_version = ? ORDER BY sales_amount_30d DESC, sku_count DESC LIMIT ? OFFSET ?`, [meta.snapshotDate, meta.snapshotBatchId, MAY_CHANNEL_INSIGHT_CALC_VERSION, pageSize, offset] ); return { meta, summary, rows, total: toNumber(countRow?.total, 0), page, pageSize, view, }; } const layoutWhere = buildLayoutWhere(view); const baseParams = [meta.snapshotDate, meta.snapshotBatchId, MAY_CHANNEL_INSIGHT_CALC_VERSION, ...layoutWhere.params]; const [countRow] = await planningDatabase.query( `SELECT COUNT(*) AS total FROM ${escapeIdentifier(MAY_CHANNEL_LAYOUT_SNAPSHOT_TABLE)} WHERE stat_date = ? AND snapshot_batch_id = ? AND calc_version = ? AND ${layoutWhere.sql}`, baseParams ); const rows = await planningDatabase.query( `SELECT may_sku_code AS maySkuCode, category_name AS categoryName, may_sku_image_url AS maySkuImageUrl, may_sales_status AS maySalesStatus, review_action AS reviewAction, may_inventory_quantity AS mayInventoryQuantity, may_goods_id_count AS mayGoodsIdCount, may_performance_amount AS mayPerformanceAmount, may_promotion_fee_30d AS mayPromotionFee30d, promotion_roi AS promotionRoi, covered_channel_count AS coveredChannelCount, covered_shop_count AS coveredShopCount, covered_item_id_count AS coveredItemIdCount, covered_channel_names AS coveredChannelNames, covered_shop_names AS coveredShopNames, missing_channel_names AS missingChannelNames, missing_channel_count AS missingChannelCount, layout_label AS layoutLabel, layout_reason AS layoutReason, risk_score AS riskScore, sales_amount_30d AS salesAmount30d, sales_qty_30d AS salesQty30d, promotion_fee_30d AS promotionFee30d, after_sale_amount_30d AS afterSaleAmount30d FROM ${escapeIdentifier(MAY_CHANNEL_LAYOUT_SNAPSHOT_TABLE)} WHERE stat_date = ? AND snapshot_batch_id = ? AND calc_version = ? AND ${layoutWhere.sql} ORDER BY risk_score DESC, may_inventory_quantity DESC, may_performance_amount DESC LIMIT ? OFFSET ?`, [...baseParams, pageSize, offset] ); return { meta, summary, rows, total: toNumber(countRow?.total, 0), page, pageSize, view, }; } module.exports = { MAY_CHANNEL_SHOP_SNAPSHOT_TABLE, MAY_CHANNEL_LAYOUT_SNAPSHOT_TABLE, MAY_CHANNEL_INSIGHT_CALC_VERSION, ensurePlanningMayChannelInsightSchema, refreshPlanningMayChannelInsightSnapshot, repairPlanningMayChannelInsightShopCoverage, getPlanningMayChannelInsight, };