forked from lunanting/cangku
2151 lines
83 KiB
JavaScript
2151 lines
83 KiB
JavaScript
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,
|
||
};
|