cangku/backend/src/services/planningMayChannelInsightService.js

2151 lines
83 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

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,
};