forked from lunanting/cangku
2690 lines
105 KiB
JavaScript
2690 lines
105 KiB
JavaScript
const fs = require('fs');
|
||
const fsp = require('fs/promises');
|
||
const path = require('path');
|
||
const cloudDb = require('../config/cloudDatabase');
|
||
const planningDatabase = require('./planningDatabase');
|
||
const { runHeavyDatabaseTask } = require('./databaseLoadGuard');
|
||
const { ensurePlanningChannelDailyReadSchema } = require('./planningSupportSchema');
|
||
|
||
const DATASET_CACHE_TTL_MS = Math.max(
|
||
Number.parseInt(process.env.PLANNING_GOODS_ANALYSIS_CACHE_TTL_MS || '300000', 10),
|
||
60 * 1000
|
||
);
|
||
const DEFAULT_PAGE_SIZE = 20;
|
||
const MAX_PAGE_SIZE = 100;
|
||
const SKU_ANALYSIS_LIMIT = Math.max(
|
||
Number.parseInt(process.env.PLANNING_GOODS_ANALYSIS_SKU_LIMIT || '80000', 10),
|
||
1000
|
||
);
|
||
const PROFILE_QUERY_CHUNK_SIZE = Math.max(
|
||
Number.parseInt(process.env.PLANNING_GOODS_PROFILE_CHUNK_SIZE || '250', 10),
|
||
50
|
||
);
|
||
const MARKET_SKU_DETAIL_LIMIT = Math.max(
|
||
Number.parseInt(process.env.PLANNING_GOODS_MARKET_SKU_DETAIL_LIMIT || '60', 10),
|
||
20
|
||
);
|
||
const MARKET_SKU_DETAIL_PER_ITEM_LIMIT = Math.max(
|
||
Number.parseInt(process.env.PLANNING_GOODS_MARKET_SKU_DETAIL_PER_ITEM_LIMIT || '12', 10),
|
||
4
|
||
);
|
||
const GOODS_ANALYSIS_DEFAULT_QUERY = Object.freeze({
|
||
dimension: 'level2',
|
||
view: 'all',
|
||
page: 1,
|
||
pageSize: 20,
|
||
keyword: '',
|
||
decisionKeys: [],
|
||
saleStatuses: [],
|
||
purchasers: [],
|
||
categoryLevel2: '',
|
||
categoryLevel3: '',
|
||
sortField: 'marketRank',
|
||
sortOrder: 'ascend',
|
||
});
|
||
const DATA_SOURCE_LABEL = '市场基础对比数据 + 业务数据库 + 渠道日汇总';
|
||
const GOODS_ANALYSIS_SNAPSHOT_DIR = path.resolve(__dirname, '../runtime-cache');
|
||
const GOODS_ANALYSIS_DEFAULT_SNAPSHOT_PATH = path.join(
|
||
GOODS_ANALYSIS_SNAPSHOT_DIR,
|
||
'planning_goods_operation_default.json'
|
||
);
|
||
const GOODS_ANALYSIS_DATASET_SNAPSHOT_PATH = path.join(
|
||
GOODS_ANALYSIS_SNAPSHOT_DIR,
|
||
'planning_goods_operation_dataset.json'
|
||
);
|
||
const GOODS_ANALYSIS_SNAPSHOT_SCHEMA_VERSION = 'goods-operation-market-compare-v15-non-virtual-sales';
|
||
const GOODS_ANALYSIS_COMPATIBLE_DATASET_SNAPSHOT_SCHEMA_VERSIONS = new Set([
|
||
GOODS_ANALYSIS_SNAPSHOT_SCHEMA_VERSION,
|
||
]);
|
||
const GOODS_ANALYSIS_COMPATIBLE_DEFAULT_SNAPSHOT_SCHEMA_VERSIONS = new Set([
|
||
GOODS_ANALYSIS_SNAPSHOT_SCHEMA_VERSION,
|
||
'goods-operation-market-compare-v12-market-sku-detail',
|
||
]);
|
||
const EXCLUDED_SKU_CODES = new Set(['X00005', 'X00006', 'FUWU', 'TCXJ']);
|
||
const MARKET_PRODUCT_RECORDS_TABLE = 'taobao_product_records';
|
||
const OWN_PRODUCT_STATUS_FILTER = process.env.PLANNING_GOODS_OWN_PRODUCT_STATUS || '销售';
|
||
const OWN_PRODUCT_EXCLUDED_CATEGORY_TERMS = Object.freeze(['配件']);
|
||
const OWN_STOCK_WAREHOUSE_NAME_PATTERN = process.env.PLANNING_GOODS_OWN_WAREHOUSE_NAME_PATTERN || '简尚-%';
|
||
const MARKET_COLUMN_CANDIDATES = Object.freeze([
|
||
'title',
|
||
'fulfillment_category_level1',
|
||
'fulfillment_category_level2',
|
||
'fulfillment_category_level3',
|
||
'category_level1',
|
||
'category_level2',
|
||
'category_level3',
|
||
'market_common_category_name',
|
||
'item_id',
|
||
'main_image_url',
|
||
'detail_url',
|
||
'source_url',
|
||
'shop_name',
|
||
'shop_type',
|
||
'shop_tag',
|
||
'brand_name',
|
||
'is_brand_shop',
|
||
'normalized_item_json',
|
||
'raw_data_json',
|
||
'price',
|
||
'original_price',
|
||
'analysis_sales',
|
||
'analysis_price_range',
|
||
'search_users',
|
||
'browse_users',
|
||
'visitors',
|
||
'paying_buyers',
|
||
'purchase_users',
|
||
'sales_amount',
|
||
'sku_price_range',
|
||
'lowest_list_price',
|
||
'lowest_after_discount_price',
|
||
'actual_hand_price',
|
||
'price_band',
|
||
'price_band_rank',
|
||
'industry_rank',
|
||
'may_sku_code',
|
||
'may_sku_image_url',
|
||
'may_min_limit_price',
|
||
'may_size',
|
||
'may_core_material',
|
||
'may_color_name',
|
||
'may_goods_id_count',
|
||
'may_performance_amount',
|
||
'may_inventory_quantity',
|
||
'may_avg_daily_sales_30d',
|
||
'may_gross_margin_rate',
|
||
]);
|
||
const MARKET_SUMMARY_COLUMN_CANDIDATES = Object.freeze(
|
||
MARKET_COLUMN_CANDIDATES.filter((columnName) => ![
|
||
'normalized_item_json',
|
||
'raw_data_json',
|
||
].includes(columnName))
|
||
);
|
||
const MARKET_VIEW_DEFINITIONS = Object.freeze([
|
||
{
|
||
key: 'all',
|
||
label: '全部市场机会',
|
||
description: '按市场销售额排名,先看大盘机会,再拆我方覆盖。',
|
||
},
|
||
{
|
||
key: 'priority_supply',
|
||
label: '优先补布局',
|
||
description: '市场需求强、供给相对少,但我方覆盖或成交偏弱。',
|
||
},
|
||
{
|
||
key: 'boost',
|
||
label: '重点放大',
|
||
description: '市场空间好且我方已有基础,适合加码运营。',
|
||
},
|
||
{
|
||
key: 'optimize',
|
||
label: '优化提效',
|
||
description: '市场还可以,但我方效率、库存或覆盖需要优化。',
|
||
},
|
||
{
|
||
key: 'observe',
|
||
label: '观察验证',
|
||
description: '机会存在但信号不够强,适合小步测试。',
|
||
},
|
||
{
|
||
key: 'cautious',
|
||
label: '谨慎进入',
|
||
description: '供给拥挤或品牌集中,进入前需要更细验证。',
|
||
},
|
||
{
|
||
key: 'data_gap',
|
||
label: '关键数据不足',
|
||
description: '市场或我方关键字段不足,先补数据再决策。',
|
||
},
|
||
]);
|
||
const SKU_DECISION_DEFINITIONS = Object.freeze([
|
||
{ value: 'stop', label: '直接停售清单' },
|
||
{ value: 'keep', label: '保留/重点运营' },
|
||
{ value: 'boom', label: '爆款重点扶持' },
|
||
{ value: 'watch', label: '中间观察清单' },
|
||
{ value: 'missing', label: '关键数据不足' },
|
||
]);
|
||
const INVALID_BRAND_VALUES = new Set([
|
||
'-',
|
||
'--',
|
||
'无',
|
||
'无品牌',
|
||
'其它',
|
||
'其他',
|
||
'other',
|
||
'others',
|
||
'未知',
|
||
'未识别',
|
||
'未填写',
|
||
'未配置',
|
||
]);
|
||
|
||
let datasetCache = {
|
||
expiresAt: 0,
|
||
value: null,
|
||
pending: null,
|
||
};
|
||
let goodsAnalysisPrewarmPending = null;
|
||
let persistedDefaultResponseSnapshot = loadPersistedDefaultResponseSnapshot();
|
||
let persistedDatasetSnapshot = loadPersistedDatasetSnapshot();
|
||
|
||
function clonePlainData(value) {
|
||
if (typeof structuredClone === 'function') {
|
||
return structuredClone(value);
|
||
}
|
||
return JSON.parse(JSON.stringify(value));
|
||
}
|
||
|
||
function normalizeText(value) {
|
||
return String(value || '').trim();
|
||
}
|
||
|
||
function normalizeKey(value) {
|
||
return normalizeText(value).toUpperCase();
|
||
}
|
||
|
||
function escapeMysqlIdentifier(identifier) {
|
||
return `\`${String(identifier || '').replace(/`/g, '``')}\``;
|
||
}
|
||
|
||
function marketCategoryKey(level2, level3 = '') {
|
||
return `${normalizeText(level2) || '未分类'}\u0000${normalizeText(level3) || ''}`;
|
||
}
|
||
|
||
function pickFirstText(row = {}, fields = []) {
|
||
for (const field of fields) {
|
||
const value = normalizeText(row[field]);
|
||
if (value && value !== '-' && value !== '--' && value !== '未配置' && value !== '未上榜') {
|
||
return value;
|
||
}
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function parseMarketNumber(value, fallback = null) {
|
||
if (value === null || value === undefined || value === '') {
|
||
return fallback;
|
||
}
|
||
|
||
const rawText = normalizeText(value);
|
||
if (!rawText || ['-', '--', '—', '未配置', '未上榜', '无'].includes(rawText)) {
|
||
return fallback;
|
||
}
|
||
|
||
const multiplier = rawText.includes('亿') ? 100000000 : rawText.includes('万') ? 10000 : 1;
|
||
const text = rawText
|
||
.replace(/,/g, '')
|
||
.replace(/,/g, '')
|
||
.replace(/\s/g, '')
|
||
.replace(/[¥¥元人件个+]/g, '')
|
||
.replace(/[万亿]/g, '');
|
||
const rangeMatch = text.match(/(-?\d+(?:\.\d+)?)\s*[-~至]\s*(-?\d+(?:\.\d+)?)/);
|
||
if (rangeMatch) {
|
||
return ((Number(rangeMatch[1]) + Number(rangeMatch[2])) / 2) * multiplier;
|
||
}
|
||
|
||
const match = text.match(/-?\d+(?:\.\d+)?/);
|
||
if (!match) {
|
||
return fallback;
|
||
}
|
||
|
||
const numeric = Number(match[0]);
|
||
return Number.isFinite(numeric) ? numeric * multiplier : fallback;
|
||
}
|
||
|
||
function parseMarketRate(value, fallback = null) {
|
||
const numeric = parseMarketNumber(value, fallback);
|
||
if (numeric === null || numeric === undefined) {
|
||
return fallback;
|
||
}
|
||
return normalizeText(value).includes('%') || numeric > 1 ? numeric / 100 : numeric;
|
||
}
|
||
|
||
function parseJsonObject(value) {
|
||
if (!value) {
|
||
return null;
|
||
}
|
||
if (typeof value === 'object') {
|
||
return value;
|
||
}
|
||
try {
|
||
return JSON.parse(String(value));
|
||
} catch (_error) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function parseMarketSkuPrice(value, fallback = null) {
|
||
const numeric = parseMarketNumber(value, fallback);
|
||
if (numeric === null || numeric === undefined) {
|
||
return fallback;
|
||
}
|
||
|
||
const rawText = normalizeText(value);
|
||
if (!rawText.includes('.') && numeric >= 10000) {
|
||
return numeric / 100;
|
||
}
|
||
return numeric;
|
||
}
|
||
|
||
function parseMarketSkuProperties(propertiesName = '') {
|
||
const props = [];
|
||
normalizeText(propertiesName)
|
||
.split(';')
|
||
.map((part) => part.trim())
|
||
.filter(Boolean)
|
||
.forEach((part) => {
|
||
const pieces = part.split(':');
|
||
if (pieces.length >= 4) {
|
||
props.push({
|
||
name: normalizeText(pieces[2]),
|
||
value: normalizeText(pieces.slice(3).join(':')),
|
||
});
|
||
}
|
||
});
|
||
|
||
const sizeProp = props.find((prop) => /尺寸|规格|大小|长|宽|高/u.test(prop.name));
|
||
const specText = props
|
||
.map((prop) => `${prop.name}:${prop.value}`)
|
||
.filter((text) => !/:$/u.test(text))
|
||
.join(' / ');
|
||
|
||
return {
|
||
marketSkuSize: sizeProp?.value || '',
|
||
marketSkuSpec: specText || normalizeText(propertiesName),
|
||
};
|
||
}
|
||
|
||
function extractSizeFromText(text = '') {
|
||
const normalized = normalizeText(text);
|
||
if (!normalized) {
|
||
return '';
|
||
}
|
||
const explicit = normalized.match(/(?:尺寸|规格|大小)[::]?\s*([^;;/,,]+)/u);
|
||
if (explicit?.[1]) {
|
||
return explicit[1].trim();
|
||
}
|
||
const sizeMatch = normalized.match(/\d+(?:\.\d+)?\s*(?:cm|CM|厘米)?\s*[xX*×/]\s*\d+(?:\.\d+)?(?:\s*(?:cm|CM|厘米)?\s*[xX*×/]\s*\d+(?:\.\d+)?)?\s*(?:cm|CM|厘米)?/u);
|
||
return sizeMatch?.[0]?.trim() || '';
|
||
}
|
||
|
||
function normalizeMarketSkuArray(value) {
|
||
if (Array.isArray(value)) {
|
||
return value;
|
||
}
|
||
if (Array.isArray(value?.sku)) {
|
||
return value.sku;
|
||
}
|
||
if (Array.isArray(value?.skus)) {
|
||
return value.skus;
|
||
}
|
||
if (Array.isArray(value?.list)) {
|
||
return value.list;
|
||
}
|
||
return [];
|
||
}
|
||
|
||
function buildMarketSkuDetailFromJsonSku(row = {}, sku = {}, index = 0) {
|
||
const skuId = pickFirstText(sku, ['sku_id', 'skuId', 'skuid', 'id']) || `${pickFirstText(row, ['item_id']) || 'market'}-${index}`;
|
||
const propertiesName = pickFirstText(sku, ['properties_name', 'propertiesName', 'prop_name', 'props_name', 'name']);
|
||
const parsedProps = parseMarketSkuProperties(propertiesName);
|
||
const marketSkuSpec = parsedProps.marketSkuSpec || pickFirstText(sku, ['title', 'name', 'spec', 'sku_name']);
|
||
const marketSkuSize = parsedProps.marketSkuSize || pickFirstText(sku, ['size', '尺寸']) || extractSizeFromText(marketSkuSpec);
|
||
const marketSkuPrice = parseMarketSkuPrice(
|
||
sku.price ?? sku.sale_price ?? sku.sku_price ?? sku.actual_price,
|
||
null
|
||
);
|
||
const marketSkuOriginalPrice = parseMarketSkuPrice(sku.orginal_price ?? sku.original_price ?? sku.orign_price, null);
|
||
const mayMinLimitPrice = parseMarketNumber(row.may_min_limit_price, null);
|
||
|
||
return {
|
||
id: `${pickFirstText(row, ['item_id']) || 'market'}:${skuId}`,
|
||
itemId: pickFirstText(row, ['item_id']),
|
||
title: pickFirstText(row, ['title']),
|
||
shopName: pickFirstText(row, ['shop_name']),
|
||
detailUrl: pickFirstText(row, ['detail_url', 'source_url']),
|
||
imageUrl: pickFirstText(row, ['main_image_url', 'may_sku_image_url']),
|
||
marketSkuId: normalizeText(skuId),
|
||
marketSkuSpec,
|
||
marketSkuSize,
|
||
marketSkuPrice: roundNullableNumber(marketSkuPrice, 2),
|
||
marketSkuOriginalPrice: roundNullableNumber(marketSkuOriginalPrice, 2),
|
||
marketSkuQuantity: roundNullableNumber(parseMarketNumber(sku.quantity, null), 0),
|
||
marketSkuPriceRange: pickFirstText(row, ['sku_price_range', 'price', 'analysis_price_range']),
|
||
marketActualHandPrice: roundNullableNumber(parseMarketNumber(row.actual_hand_price, null), 2),
|
||
marketLowestListPrice: roundNullableNumber(parseMarketNumber(row.lowest_list_price, null), 2),
|
||
marketLowestAfterDiscountPrice: roundNullableNumber(parseMarketNumber(row.lowest_after_discount_price, null), 2),
|
||
marketSalesQty: roundNullableNumber(parseMarketNumber(row.analysis_sales, 0), 0) ?? 0,
|
||
marketSalesAmount: roundNullableNumber(parseMarketNumber(row.sales_amount, 0), 2) ?? 0,
|
||
maySkuCode: normalizeKey(row.may_sku_code),
|
||
maySkuImageUrl: pickFirstText(row, ['may_sku_image_url']),
|
||
maySize: pickFirstText(row, ['may_size']),
|
||
mayCoreMaterial: pickFirstText(row, ['may_core_material']),
|
||
mayColorName: pickFirstText(row, ['may_color_name']),
|
||
mayMinLimitPrice: roundNullableNumber(mayMinLimitPrice, 2),
|
||
priceGapToMayLimit: Number.isFinite(Number(marketSkuPrice)) && Number.isFinite(Number(mayMinLimitPrice))
|
||
? roundNullableNumber(Number(marketSkuPrice) - Number(mayMinLimitPrice), 2)
|
||
: null,
|
||
};
|
||
}
|
||
|
||
function extractMarketSkuDetails(row = {}, rowIndex = 0) {
|
||
const normalized = parseJsonObject(row.normalized_item_json);
|
||
const normalizedSkus = normalizeMarketSkuArray(normalized?.skus);
|
||
const skuList = normalizedSkus.length > 0 ? normalizedSkus : normalizeMarketSkuArray(normalized?.sku);
|
||
|
||
if (skuList.length > 0) {
|
||
return skuList
|
||
.slice(0, MARKET_SKU_DETAIL_PER_ITEM_LIMIT)
|
||
.map((sku, index) => buildMarketSkuDetailFromJsonSku(row, sku, index))
|
||
.filter((sku) => sku.marketSkuPrice !== null || sku.marketSkuSpec || sku.marketSkuSize);
|
||
}
|
||
|
||
const rawData = parseJsonObject(row.raw_data_json);
|
||
const rawSkuList = normalizeMarketSkuArray(rawData?.skus);
|
||
if (rawSkuList.length > 0) {
|
||
return rawSkuList
|
||
.slice(0, MARKET_SKU_DETAIL_PER_ITEM_LIMIT)
|
||
.map((sku, index) => buildMarketSkuDetailFromJsonSku(row, sku, index))
|
||
.filter((sku) => sku.marketSkuPrice !== null || sku.marketSkuSpec || sku.marketSkuSize);
|
||
}
|
||
|
||
const fallbackPrice = parseMarketNumber(row.actual_hand_price, null)
|
||
?? parseMarketNumber(row.lowest_after_discount_price, null)
|
||
?? parseMarketNumber(row.lowest_list_price, null)
|
||
?? parseMarketNumber(row.price, null);
|
||
if (fallbackPrice === null && !pickFirstText(row, ['sku_price_range'])) {
|
||
return [];
|
||
}
|
||
|
||
const mayMinLimitPrice = parseMarketNumber(row.may_min_limit_price, null);
|
||
return [{
|
||
id: `${pickFirstText(row, ['item_id']) || 'market'}:fallback:${rowIndex}`,
|
||
itemId: pickFirstText(row, ['item_id']),
|
||
title: pickFirstText(row, ['title']),
|
||
shopName: pickFirstText(row, ['shop_name']),
|
||
detailUrl: pickFirstText(row, ['detail_url', 'source_url']),
|
||
imageUrl: pickFirstText(row, ['main_image_url', 'may_sku_image_url']),
|
||
marketSkuId: '',
|
||
marketSkuSpec: pickFirstText(row, ['market_common_category_name', 'title']),
|
||
marketSkuSize: extractSizeFromText(pickFirstText(row, ['title'])),
|
||
marketSkuPrice: roundNullableNumber(fallbackPrice, 2),
|
||
marketSkuOriginalPrice: roundNullableNumber(parseMarketNumber(row.original_price, null), 2),
|
||
marketSkuQuantity: null,
|
||
marketSkuPriceRange: pickFirstText(row, ['sku_price_range', 'price', 'analysis_price_range']),
|
||
marketActualHandPrice: roundNullableNumber(parseMarketNumber(row.actual_hand_price, null), 2),
|
||
marketLowestListPrice: roundNullableNumber(parseMarketNumber(row.lowest_list_price, null), 2),
|
||
marketLowestAfterDiscountPrice: roundNullableNumber(parseMarketNumber(row.lowest_after_discount_price, null), 2),
|
||
marketSalesQty: roundNullableNumber(parseMarketNumber(row.analysis_sales, 0), 0) ?? 0,
|
||
marketSalesAmount: roundNullableNumber(parseMarketNumber(row.sales_amount, 0), 2) ?? 0,
|
||
maySkuCode: normalizeKey(row.may_sku_code),
|
||
maySkuImageUrl: pickFirstText(row, ['may_sku_image_url']),
|
||
maySize: pickFirstText(row, ['may_size']),
|
||
mayCoreMaterial: pickFirstText(row, ['may_core_material']),
|
||
mayColorName: pickFirstText(row, ['may_color_name']),
|
||
mayMinLimitPrice: roundNullableNumber(mayMinLimitPrice, 2),
|
||
priceGapToMayLimit: Number.isFinite(Number(fallbackPrice)) && Number.isFinite(Number(mayMinLimitPrice))
|
||
? roundNullableNumber(Number(fallbackPrice) - Number(mayMinLimitPrice), 2)
|
||
: null,
|
||
}];
|
||
}
|
||
|
||
function sortMarketSkuDetails(details = []) {
|
||
return [...details].sort((left, right) => (
|
||
Number(right.marketSalesAmount || 0) - Number(left.marketSalesAmount || 0)
|
||
|| Number(right.marketSalesQty || 0) - Number(left.marketSalesQty || 0)
|
||
|| Number(left.marketSkuPrice || 999999999) - Number(right.marketSkuPrice || 999999999)
|
||
|| normalizeText(left.marketSkuSpec).localeCompare(normalizeText(right.marketSkuSpec), 'zh-CN')
|
||
));
|
||
}
|
||
|
||
function appendMarketSkuDetails(acc, details = []) {
|
||
if (!Array.isArray(details) || details.length === 0) {
|
||
return;
|
||
}
|
||
acc.marketSkuDetails.push(...details);
|
||
const maxBuffered = MARKET_SKU_DETAIL_LIMIT * 4;
|
||
if (acc.marketSkuDetails.length > maxBuffered) {
|
||
acc.marketSkuDetails = sortMarketSkuDetails(acc.marketSkuDetails).slice(0, MARKET_SKU_DETAIL_LIMIT);
|
||
}
|
||
}
|
||
|
||
function sanitizeMarketBrand(value) {
|
||
const brand = normalizeText(value)
|
||
.replace(/^品牌[::]\s*/, '')
|
||
.replace(/\s+/g, ' ');
|
||
if (!brand || INVALID_BRAND_VALUES.has(brand.toLowerCase()) || INVALID_BRAND_VALUES.has(brand)) {
|
||
return '';
|
||
}
|
||
return brand;
|
||
}
|
||
|
||
function parseMarketBoolean(value) {
|
||
if (value === true || value === 1) {
|
||
return true;
|
||
}
|
||
if (value === false || value === 0) {
|
||
return false;
|
||
}
|
||
|
||
const text = normalizeText(value).toLowerCase();
|
||
if (!text || ['-', '--', '未配置', '未上榜', '未知'].includes(text)) {
|
||
return null;
|
||
}
|
||
if (['true', '1', '是', 'y', 'yes', '品牌', '品牌店铺'].includes(text)) {
|
||
return true;
|
||
}
|
||
if (['false', '0', '否', 'n', 'no', '非品牌', '普通店铺'].includes(text)) {
|
||
return false;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function extractMarketBrandName(row = {}) {
|
||
const directBrand = sanitizeMarketBrand(row.brand_name);
|
||
if (directBrand) {
|
||
return directBrand;
|
||
}
|
||
|
||
const normalized = parseJsonObject(row.normalized_item_json);
|
||
const props = Array.isArray(normalized?.props) ? normalized.props : [];
|
||
for (const prop of props) {
|
||
const propName = normalizeText(prop?.name || prop?.key || prop?.label);
|
||
if (!propName || !propName.includes('品牌')) {
|
||
continue;
|
||
}
|
||
const brand = sanitizeMarketBrand(prop?.value || prop?.text || prop?.desc);
|
||
if (brand) {
|
||
return brand;
|
||
}
|
||
}
|
||
|
||
return sanitizeMarketBrand(row.shop_name);
|
||
}
|
||
|
||
function isMarketBrandSeller(row = {}, brandName = '') {
|
||
const explicitFlag = parseMarketBoolean(row.is_brand_shop);
|
||
if (explicitFlag !== null) {
|
||
return explicitFlag;
|
||
}
|
||
if (sanitizeMarketBrand(row.brand_name)) {
|
||
return true;
|
||
}
|
||
|
||
const sellerText = [
|
||
row.shop_type,
|
||
row.shop_tag,
|
||
row.shop_name,
|
||
brandName,
|
||
].map((item) => normalizeText(item)).filter(Boolean).join(' ');
|
||
|
||
return /品牌|旗舰店|官方|直营/u.test(sellerText);
|
||
}
|
||
|
||
function getMarketCategoryFromRow(row = {}) {
|
||
const level1 = pickFirstText(row, ['fulfillment_category_level1', 'category_level1']);
|
||
const level2 = pickFirstText(row, [
|
||
'fulfillment_category_level2',
|
||
'category_level2',
|
||
'fulfillment_category_level1',
|
||
'category_level1',
|
||
'market_common_category_name',
|
||
]) || '未分类';
|
||
const level3 = pickFirstText(row, [
|
||
'fulfillment_category_level3',
|
||
'category_level3',
|
||
'market_common_category_name',
|
||
'fulfillment_category_level2',
|
||
'category_level2',
|
||
]) || level2;
|
||
|
||
return {
|
||
categoryLevel1: level1,
|
||
categoryLevel2: level2,
|
||
categoryLevel3: level3,
|
||
};
|
||
}
|
||
|
||
function parseListParam(value) {
|
||
if (!value) {
|
||
return [];
|
||
}
|
||
if (Array.isArray(value)) {
|
||
return value.map((item) => normalizeText(item)).filter(Boolean);
|
||
}
|
||
return String(value)
|
||
.split(',')
|
||
.map((item) => normalizeText(item))
|
||
.filter(Boolean);
|
||
}
|
||
|
||
function safePage(value, fallback = 1) {
|
||
const parsed = Number.parseInt(value, 10);
|
||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||
}
|
||
|
||
function safePageSize(value, fallback = DEFAULT_PAGE_SIZE) {
|
||
const parsed = Number.parseInt(value, 10);
|
||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||
return fallback;
|
||
}
|
||
return Math.min(parsed, MAX_PAGE_SIZE);
|
||
}
|
||
|
||
function normalizeDimension(value) {
|
||
const dimension = normalizeText(value);
|
||
return ['level2', 'level3', 'item'].includes(dimension) ? dimension : 'level2';
|
||
}
|
||
|
||
function toNumber(value, digits = 2) {
|
||
const numeric = Number(value);
|
||
if (!Number.isFinite(numeric)) {
|
||
return 0;
|
||
}
|
||
return Number(numeric.toFixed(digits));
|
||
}
|
||
|
||
function roundNullableNumber(value, digits = 2) {
|
||
if (value === null || value === undefined || value === '') {
|
||
return null;
|
||
}
|
||
const numeric = Number(value);
|
||
if (!Number.isFinite(numeric)) {
|
||
return null;
|
||
}
|
||
return Number(numeric.toFixed(digits));
|
||
}
|
||
|
||
function safeDivide(numerator, denominator, digits = 4) {
|
||
const n = Number(numerator);
|
||
const d = Number(denominator);
|
||
if (!Number.isFinite(n) || !Number.isFinite(d) || d === 0) {
|
||
return null;
|
||
}
|
||
return roundNullableNumber(n / d, digits);
|
||
}
|
||
|
||
function clampNumber(value, min = 0, max = 100) {
|
||
const numeric = Number(value);
|
||
if (!Number.isFinite(numeric)) {
|
||
return min;
|
||
}
|
||
return Math.min(max, Math.max(min, numeric));
|
||
}
|
||
|
||
function formatDate(value) {
|
||
if (!value) {
|
||
return '';
|
||
}
|
||
if (value instanceof Date) {
|
||
return value.toISOString().slice(0, 10);
|
||
}
|
||
const text = normalizeText(value);
|
||
if (/^\d{4}-\d{2}-\d{2}/.test(text)) {
|
||
return text.slice(0, 10);
|
||
}
|
||
const date = new Date(text);
|
||
return Number.isNaN(date.getTime()) ? '' : date.toISOString().slice(0, 10);
|
||
}
|
||
|
||
function formatCompactSummaryNumber(value, digits = 1) {
|
||
if (value === null || value === undefined || value === '') {
|
||
return '--';
|
||
}
|
||
const numeric = Number(value);
|
||
if (!Number.isFinite(numeric)) {
|
||
return '--';
|
||
}
|
||
const absValue = Math.abs(numeric);
|
||
if (absValue >= 100000000) {
|
||
return `${roundNullableNumber(numeric / 100000000, digits)}亿`;
|
||
}
|
||
if (absValue >= 10000) {
|
||
return `${roundNullableNumber(numeric / 10000, digits)}万`;
|
||
}
|
||
return String(roundNullableNumber(numeric, digits) ?? 0);
|
||
}
|
||
|
||
function formatCompactSummaryCurrency(value, digits = 1) {
|
||
const formatted = formatCompactSummaryNumber(value, digits);
|
||
return formatted === '--' ? formatted : `¥${formatted}`;
|
||
}
|
||
|
||
function dateDiffDays(startDate, endDate) {
|
||
const start = new Date(`${formatDate(startDate)}T00:00:00Z`);
|
||
const end = new Date(`${formatDate(endDate)}T00:00:00Z`);
|
||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
||
return null;
|
||
}
|
||
return Math.max(0, Math.floor((end.getTime() - start.getTime()) / 86400000));
|
||
}
|
||
|
||
function chunkArray(values = [], size = PROFILE_QUERY_CHUNK_SIZE) {
|
||
const chunks = [];
|
||
for (let index = 0; index < values.length; index += size) {
|
||
chunks.push(values.slice(index, index + size));
|
||
}
|
||
return chunks;
|
||
}
|
||
|
||
function uniqueSorted(values = []) {
|
||
return Array.from(new Set(values.map((value) => normalizeText(value)).filter(Boolean)))
|
||
.sort((left, right) => left.localeCompare(right, 'zh-CN'));
|
||
}
|
||
|
||
function average(values = [], digits = 2) {
|
||
const numericValues = values.map(Number).filter((value) => Number.isFinite(value));
|
||
if (!numericValues.length) {
|
||
return null;
|
||
}
|
||
return roundNullableNumber(
|
||
numericValues.reduce((sum, value) => sum + value, 0) / numericValues.length,
|
||
digits
|
||
);
|
||
}
|
||
|
||
function sumRows(rows = [], field, digits = 2) {
|
||
return roundNullableNumber(
|
||
rows.reduce((sum, row) => sum + toNumber(row?.[field], digits), 0),
|
||
digits
|
||
) ?? 0;
|
||
}
|
||
|
||
function writeDefaultResponseSnapshot(snapshot) {
|
||
const versionedSnapshot = {
|
||
...snapshot,
|
||
snapshotSchemaVersion: GOODS_ANALYSIS_SNAPSHOT_SCHEMA_VERSION,
|
||
};
|
||
persistedDefaultResponseSnapshot = versionedSnapshot;
|
||
return fsp.mkdir(GOODS_ANALYSIS_SNAPSHOT_DIR, { recursive: true })
|
||
.then(() => fsp.writeFile(
|
||
GOODS_ANALYSIS_DEFAULT_SNAPSHOT_PATH,
|
||
JSON.stringify(versionedSnapshot),
|
||
'utf8'
|
||
))
|
||
.catch((error) => {
|
||
console.warn('[PlanningGoodsAnalysis] 写入货品分析快照失败:', error.message || error);
|
||
});
|
||
}
|
||
|
||
function isCompatibleGoodsAnalysisSnapshot(snapshot, compatibleVersions = GOODS_ANALYSIS_COMPATIBLE_DATASET_SNAPSHOT_SCHEMA_VERSIONS) {
|
||
return snapshot
|
||
&& snapshot.source === 'goods_operation'
|
||
&& compatibleVersions.has(snapshot.snapshotSchemaVersion);
|
||
}
|
||
|
||
function loadPersistedDefaultResponseSnapshot() {
|
||
try {
|
||
if (!fs.existsSync(GOODS_ANALYSIS_DEFAULT_SNAPSHOT_PATH)) {
|
||
return null;
|
||
}
|
||
|
||
const raw = fs.readFileSync(GOODS_ANALYSIS_DEFAULT_SNAPSHOT_PATH, 'utf8');
|
||
const parsed = JSON.parse(raw);
|
||
return isCompatibleGoodsAnalysisSnapshot(
|
||
parsed,
|
||
GOODS_ANALYSIS_COMPATIBLE_DEFAULT_SNAPSHOT_SCHEMA_VERSIONS
|
||
) ? parsed : null;
|
||
} catch (error) {
|
||
console.warn('[PlanningGoodsAnalysis] 读取货品分析快照失败:', error.message || error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function writeDatasetSnapshot(dataset) {
|
||
const versionedDataset = {
|
||
...dataset,
|
||
snapshotSchemaVersion: GOODS_ANALYSIS_SNAPSHOT_SCHEMA_VERSION,
|
||
};
|
||
persistedDatasetSnapshot = versionedDataset;
|
||
return fsp.mkdir(GOODS_ANALYSIS_SNAPSHOT_DIR, { recursive: true })
|
||
.then(() => fsp.writeFile(
|
||
GOODS_ANALYSIS_DATASET_SNAPSHOT_PATH,
|
||
JSON.stringify(versionedDataset),
|
||
'utf8'
|
||
))
|
||
.catch((error) => {
|
||
console.warn('[PlanningGoodsAnalysis] 写入货品分析数据集快照失败:', error.message || error);
|
||
});
|
||
}
|
||
|
||
function loadPersistedDatasetSnapshot() {
|
||
try {
|
||
if (!fs.existsSync(GOODS_ANALYSIS_DATASET_SNAPSHOT_PATH)) {
|
||
return null;
|
||
}
|
||
|
||
const raw = fs.readFileSync(GOODS_ANALYSIS_DATASET_SNAPSHOT_PATH, 'utf8');
|
||
const parsed = JSON.parse(raw);
|
||
return isCompatibleGoodsAnalysisSnapshot(parsed)
|
||
&& Array.isArray(parsed.rows)
|
||
? parsed
|
||
: null;
|
||
} catch (error) {
|
||
console.warn('[PlanningGoodsAnalysis] 读取货品分析数据集快照失败:', error.message || error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function buildMovementScore(row) {
|
||
const avgDaily = Number(row.avgDailySales30d);
|
||
const activeRate = Number(row.activeSalesDayRatio30d);
|
||
const sellThrough = Number(row.sellThroughRate);
|
||
const avgDailyScore = avgDaily >= 1 ? 100 : avgDaily >= 0.2 ? 55 + ((avgDaily - 0.2) / 0.8) * 45 : Math.max(avgDaily, 0) / 0.2 * 45;
|
||
const activeScore = Number.isFinite(activeRate) ? Math.min(Math.max(activeRate, 0), 1) * 100 : 0;
|
||
const sellThroughScore = Number.isFinite(sellThrough) ? Math.min(Math.max(sellThrough, 0), 1) * 100 : 0;
|
||
return roundNullableNumber(avgDailyScore * 0.55 + activeScore * 0.30 + sellThroughScore * 0.15, 1) ?? 0;
|
||
}
|
||
|
||
function buildGrowthScore(row) {
|
||
const growth15 = Number(row.salesGrowthRate15d);
|
||
const sevenVs30 = Number(row.sevenDayVsThirtyDayAvg);
|
||
const growthScore = Number.isFinite(growth15)
|
||
? Math.max(0, Math.min(100, 50 + growth15 * 120))
|
||
: 0;
|
||
const sevenScore = Number.isFinite(sevenVs30)
|
||
? Math.max(0, Math.min(100, sevenVs30 / 1.5 * 100))
|
||
: 0;
|
||
return roundNullableNumber(Math.max(growthScore, sevenScore), 1) ?? 0;
|
||
}
|
||
|
||
function buildInventoryScore(row) {
|
||
const turnover = Number(row.inventoryTurnoverDays);
|
||
const stock = Number(row.stockQty);
|
||
if (!Number.isFinite(stock) || stock <= 0) {
|
||
return Number(row.salesQty30d) > 0 ? 70 : 20;
|
||
}
|
||
if (!Number.isFinite(turnover)) {
|
||
return 20;
|
||
}
|
||
if (turnover <= 30) return 100;
|
||
if (turnover <= 60) return roundNullableNumber(80 - (turnover - 30) / 30 * 20, 1) ?? 80;
|
||
if (turnover <= 90) return roundNullableNumber(60 - (turnover - 60) / 30 * 25, 1) ?? 60;
|
||
return Math.max(0, roundNullableNumber(35 - Math.min((turnover - 90) / 180 * 35, 35), 1) ?? 0);
|
||
}
|
||
|
||
function buildProfitScore(row) {
|
||
const grossMargin = Number(row.grossMarginRate);
|
||
const grossProfit = Number(row.grossProfit30d);
|
||
if (!Number.isFinite(grossMargin)) {
|
||
return 0;
|
||
}
|
||
const marginScore = grossMargin >= 0.4 ? 100 : grossMargin >= 0.2 ? 55 + ((grossMargin - 0.2) / 0.2) * 45 : Math.max(grossMargin, 0) / 0.2 * 55;
|
||
const profitScore = Number.isFinite(grossProfit) && grossProfit > 0
|
||
? Math.min(Math.log1p(grossProfit) / Math.log1p(5000), 1) * 100
|
||
: 0;
|
||
return roundNullableNumber(marginScore * 0.65 + profitScore * 0.35, 1) ?? 0;
|
||
}
|
||
|
||
function buildDecision(row) {
|
||
const avgDaily = Number(row.avgDailySales30d);
|
||
const growth15 = Number(row.salesGrowthRate15d);
|
||
const sevenVs30 = Number(row.sevenDayVsThirtyDayAvg);
|
||
const turnover = Number(row.inventoryTurnoverDays);
|
||
const listingDays = Number(row.listingDurationDays);
|
||
const grossMargin = Number(row.grossMarginRate);
|
||
const activeRate = Number(row.activeSalesDayRatio30d);
|
||
const industryAvgDaily = Number(row.industryAvgDailySales30d);
|
||
|
||
const directStopConditions = [
|
||
avgDaily < 0.2,
|
||
Number.isFinite(growth15) && growth15 < -0.1,
|
||
Number.isFinite(turnover) && turnover > 90,
|
||
Number.isFinite(listingDays) && listingDays > 365,
|
||
Number.isFinite(grossMargin) && grossMargin < 0.2,
|
||
];
|
||
|
||
const keepConditions = [
|
||
Number.isFinite(growth15) && growth15 >= 0.3,
|
||
Number.isFinite(sevenVs30) && sevenVs30 >= 1.5,
|
||
Number.isFinite(turnover) && turnover <= 60,
|
||
Number.isFinite(grossMargin) && grossMargin >= 0.4,
|
||
];
|
||
|
||
const boomConditions = [
|
||
Number.isFinite(industryAvgDaily) && industryAvgDaily > 0 && avgDaily >= industryAvgDaily * 2,
|
||
Number.isFinite(growth15) && growth15 >= 0.2,
|
||
Number.isFinite(turnover) && turnover <= 30,
|
||
Number.isFinite(grossMargin) && grossMargin >= 0.4,
|
||
Number.isFinite(activeRate) && activeRate >= 0.6,
|
||
];
|
||
|
||
const missingFields = [];
|
||
if (!row.firstListingDate) missingFields.push('铺货日期');
|
||
if (!Number.isFinite(Number(row.unitCost)) || Number(row.unitCost) <= 0) missingFields.push('成本');
|
||
if (!Number.isFinite(Number(row.stockQty))) missingFields.push('库存');
|
||
if (!Number.isFinite(Number(row.grossMarginRate))) missingFields.push('毛利率');
|
||
|
||
if (directStopConditions.every(Boolean)) {
|
||
return {
|
||
decisionKey: 'stop',
|
||
decisionLabel: '直接停售清单',
|
||
decisionReason: '近30天日销低、近15日持续下滑、库存周转超过90天、铺货超过365天且毛利率低于20%。',
|
||
stopConditionCount: directStopConditions.filter(Boolean).length,
|
||
keepConditionCount: keepConditions.filter(Boolean).length,
|
||
boomConditionCount: boomConditions.filter(Boolean).length,
|
||
missingFields,
|
||
};
|
||
}
|
||
|
||
if (boomConditions.filter(Boolean).length >= 3) {
|
||
return {
|
||
decisionKey: 'boom',
|
||
decisionLabel: '爆款重点扶持',
|
||
decisionReason: `满足 ${boomConditions.filter(Boolean).length} 个爆款条件:高于品类均值、增长、库存、毛利或动销率表现较好。`,
|
||
stopConditionCount: directStopConditions.filter(Boolean).length,
|
||
keepConditionCount: keepConditions.filter(Boolean).length,
|
||
boomConditionCount: boomConditions.filter(Boolean).length,
|
||
missingFields,
|
||
};
|
||
}
|
||
|
||
if (keepConditions.filter(Boolean).length >= 2) {
|
||
return {
|
||
decisionKey: 'keep',
|
||
decisionLabel: '保留/重点运营',
|
||
decisionReason: `满足 ${keepConditions.filter(Boolean).length} 个保留条件:增长、7日强度、库存周转或毛利具备继续运营价值。`,
|
||
stopConditionCount: directStopConditions.filter(Boolean).length,
|
||
keepConditionCount: keepConditions.filter(Boolean).length,
|
||
boomConditionCount: boomConditions.filter(Boolean).length,
|
||
missingFields,
|
||
};
|
||
}
|
||
|
||
if (missingFields.length >= 3 && Number(row.salesQty30d) <= 0) {
|
||
return {
|
||
decisionKey: 'missing',
|
||
decisionLabel: '关键数据不足',
|
||
decisionReason: `缺少${missingFields.join('、')},且近30天无销量,暂不做强结论。`,
|
||
stopConditionCount: directStopConditions.filter(Boolean).length,
|
||
keepConditionCount: keepConditions.filter(Boolean).length,
|
||
boomConditionCount: boomConditions.filter(Boolean).length,
|
||
missingFields,
|
||
};
|
||
}
|
||
|
||
return {
|
||
decisionKey: 'watch',
|
||
decisionLabel: '中间观察清单',
|
||
decisionReason: '未同时满足直接停售条件,也未达到重点保留/爆款扶持阈值,建议先做调价、详情页、搭配或清仓促销,观察30天。',
|
||
stopConditionCount: directStopConditions.filter(Boolean).length,
|
||
keepConditionCount: keepConditions.filter(Boolean).length,
|
||
boomConditionCount: boomConditions.filter(Boolean).length,
|
||
missingFields,
|
||
};
|
||
}
|
||
|
||
function deriveFallbackSpu(skuCode) {
|
||
const text = normalizeText(skuCode).replace(/-\d+$/u, '');
|
||
const match = text.match(/^(.+?)N\d/u);
|
||
return match ? match[1] : text;
|
||
}
|
||
|
||
function createMarketAccumulator(category) {
|
||
return {
|
||
categoryLevel1: category.categoryLevel1 || '',
|
||
categoryLevel2: category.categoryLevel2 || '未分类',
|
||
categoryLevel3: category.categoryLevel3 || category.categoryLevel2 || '未分类',
|
||
marketItemIds: new Set(),
|
||
marketItemCount: 0,
|
||
marketSalesQty: 0,
|
||
marketSearchUsers: 0,
|
||
marketBrowseUsers: 0,
|
||
marketVisitorCount: 0,
|
||
marketBuyerCount: 0,
|
||
marketSalesAmount: 0,
|
||
marketPriceSum: 0,
|
||
marketPriceCount: 0,
|
||
marketBestIndustryRank: null,
|
||
marketBestPriceBandRank: null,
|
||
brandSalesAmountMap: new Map(),
|
||
brandItemIdsMap: new Map(),
|
||
brandSalesAmount: 0,
|
||
brandDataRows: 0,
|
||
brandSellerRows: 0,
|
||
maySkuCodes: new Set(),
|
||
mayMatchedSkuCount: 0,
|
||
mayGoodsIdCount: 0,
|
||
mayPerformanceAmount: 0,
|
||
mayInventoryQuantity: 0,
|
||
mayAvgDailySales30d: 0,
|
||
mayGrossMarginRates: [],
|
||
marketSkuDetails: [],
|
||
rowCount: 0,
|
||
};
|
||
}
|
||
|
||
function addMarketRow(acc, row = {}, rowIndex = 0) {
|
||
acc.rowCount += 1;
|
||
|
||
const goodsId = pickFirstText(row, ['item_id']) || `row-${rowIndex}`;
|
||
acc.marketItemIds.add(goodsId);
|
||
|
||
const marketSalesQty = parseMarketNumber(row.analysis_sales, 0) || 0;
|
||
const marketSearchUsers = parseMarketNumber(row.search_users, 0) || 0;
|
||
const marketBrowseUsers = parseMarketNumber(row.browse_users, 0) || 0;
|
||
const marketVisitorCount = parseMarketNumber(row.visitors, 0) || 0;
|
||
const marketBuyerCount = Math.max(
|
||
parseMarketNumber(row.paying_buyers, 0) || 0,
|
||
parseMarketNumber(row.purchase_users, 0) || 0
|
||
);
|
||
const marketSalesAmount = parseMarketNumber(row.sales_amount, 0) || 0;
|
||
const price = parseMarketNumber(row.actual_hand_price, null)
|
||
?? parseMarketNumber(row.lowest_list_price, null);
|
||
const industryRank = parseMarketNumber(row.industry_rank, null);
|
||
const priceBandRank = parseMarketNumber(row.price_band_rank, null);
|
||
const marketBrandName = extractMarketBrandName(row);
|
||
const isBrandSeller = isMarketBrandSeller(row, marketBrandName);
|
||
const maySkuCode = normalizeKey(row.may_sku_code);
|
||
const mayGrossMarginRate = parseMarketRate(row.may_gross_margin_rate, null);
|
||
|
||
acc.marketSalesQty += marketSalesQty;
|
||
acc.marketSearchUsers += marketSearchUsers;
|
||
acc.marketBrowseUsers += marketBrowseUsers;
|
||
acc.marketVisitorCount += marketVisitorCount;
|
||
acc.marketBuyerCount += marketBuyerCount;
|
||
acc.marketSalesAmount += marketSalesAmount;
|
||
if (Number.isFinite(Number(price)) && Number(price) > 0) {
|
||
acc.marketPriceSum += Number(price);
|
||
acc.marketPriceCount += 1;
|
||
}
|
||
if (Number.isFinite(Number(industryRank)) && Number(industryRank) > 0) {
|
||
acc.marketBestIndustryRank = acc.marketBestIndustryRank === null
|
||
? Number(industryRank)
|
||
: Math.min(acc.marketBestIndustryRank, Number(industryRank));
|
||
}
|
||
if (Number.isFinite(Number(priceBandRank)) && Number(priceBandRank) > 0) {
|
||
acc.marketBestPriceBandRank = acc.marketBestPriceBandRank === null
|
||
? Number(priceBandRank)
|
||
: Math.min(acc.marketBestPriceBandRank, Number(priceBandRank));
|
||
}
|
||
if (marketBrandName) {
|
||
acc.brandDataRows += 1;
|
||
acc.brandSalesAmountMap.set(
|
||
marketBrandName,
|
||
(acc.brandSalesAmountMap.get(marketBrandName) || 0) + marketSalesAmount
|
||
);
|
||
if (!acc.brandItemIdsMap.has(marketBrandName)) {
|
||
acc.brandItemIdsMap.set(marketBrandName, new Set());
|
||
}
|
||
acc.brandItemIdsMap.get(marketBrandName).add(goodsId);
|
||
}
|
||
if (isBrandSeller) {
|
||
acc.brandSellerRows += 1;
|
||
acc.brandSalesAmount += marketSalesAmount;
|
||
}
|
||
if (maySkuCode) {
|
||
acc.maySkuCodes.add(maySkuCode);
|
||
}
|
||
acc.mayGoodsIdCount += parseMarketNumber(row.may_goods_id_count, 0) || 0;
|
||
acc.mayPerformanceAmount += parseMarketNumber(row.may_performance_amount, 0) || 0;
|
||
acc.mayInventoryQuantity += parseMarketNumber(row.may_inventory_quantity, 0) || 0;
|
||
acc.mayAvgDailySales30d += parseMarketNumber(row.may_avg_daily_sales_30d, 0) || 0;
|
||
if (Number.isFinite(Number(mayGrossMarginRate))) {
|
||
acc.mayGrossMarginRates.push(Number(mayGrossMarginRate));
|
||
}
|
||
appendMarketSkuDetails(acc, extractMarketSkuDetails(row, rowIndex));
|
||
}
|
||
|
||
function finalizeMarketAccumulator(acc) {
|
||
const marketItemCount = acc.marketItemIds.size || acc.rowCount;
|
||
const mayMatchedSkuCount = acc.maySkuCodes.size;
|
||
const marketDemandQty = acc.marketSalesQty || acc.marketBuyerCount;
|
||
const demandCandidates = [
|
||
{ source: 'search_users', value: acc.marketSearchUsers },
|
||
{ source: 'sales_fallback', value: acc.marketSalesQty },
|
||
{ source: 'buyer_fallback', value: acc.marketBuyerCount },
|
||
].filter((item) => Number(item.value) > 0);
|
||
const bestDemandSignal = demandCandidates.sort((left, right) => Number(right.value) - Number(left.value))[0];
|
||
const keywordSearchVolume = bestDemandSignal?.value
|
||
|| acc.marketBrowseUsers
|
||
|| acc.marketVisitorCount
|
||
|| 0;
|
||
const keywordSearchVolumeSource = bestDemandSignal?.source
|
||
|| (acc.marketBrowseUsers > 0 || acc.marketVisitorCount > 0 ? 'traffic_fallback' : 'missing');
|
||
const brandEntries = Array.from(acc.brandSalesAmountMap.entries())
|
||
.sort((left, right) => Number(right[1] || 0) - Number(left[1] || 0));
|
||
const [topBrandName = '', topBrandSalesAmount = 0] = brandEntries[0] || [];
|
||
const topBrandSalesShare = acc.marketSalesAmount > 0 && topBrandSalesAmount > 0
|
||
? topBrandSalesAmount / acc.marketSalesAmount
|
||
: null;
|
||
const brandSalesShareBase = acc.brandSalesAmount > 0 ? acc.brandSalesAmount : topBrandSalesAmount;
|
||
const brandSalesShareSource = acc.brandSalesAmount > 0
|
||
? 'brand_sales_amount'
|
||
: (topBrandSalesShare !== null ? 'top_brand_sales_amount_fallback' : 'missing');
|
||
const brandSalesShare = acc.marketSalesAmount > 0 && brandSalesShareBase > 0
|
||
? brandSalesShareBase / acc.marketSalesAmount
|
||
: null;
|
||
const brandCompetitionValue = brandSalesShare > 0
|
||
? acc.marketSalesAmount / brandSalesShare
|
||
: null;
|
||
const brandDataCoverageRate = Math.max(
|
||
safeDivide(acc.brandDataRows, acc.rowCount, 4) ?? 0,
|
||
safeDivide(acc.brandSellerRows, acc.rowCount, 4) ?? 0
|
||
);
|
||
const supplyDemandRatio = keywordSearchVolume > 0
|
||
? marketItemCount / keywordSearchVolume
|
||
: null;
|
||
const marketAvgPrice = marketDemandQty > 0 && acc.marketSalesAmount > 0
|
||
? acc.marketSalesAmount / marketDemandQty
|
||
: (acc.marketPriceCount > 0 ? acc.marketPriceSum / acc.marketPriceCount : null);
|
||
|
||
return {
|
||
categoryLevel1: acc.categoryLevel1,
|
||
categoryLevel2: acc.categoryLevel2 || '未分类',
|
||
categoryLevel3: acc.categoryLevel3 || acc.categoryLevel2 || '未分类',
|
||
marketRank: null,
|
||
marketItemCount,
|
||
marketSalesQty: roundNullableNumber(acc.marketSalesQty, 0) ?? 0,
|
||
marketSearchUsers: roundNullableNumber(acc.marketSearchUsers, 0) ?? 0,
|
||
marketBrowseUsers: roundNullableNumber(acc.marketBrowseUsers, 0) ?? 0,
|
||
marketVisitorCount: roundNullableNumber(acc.marketVisitorCount, 0) ?? 0,
|
||
marketBuyerCount: roundNullableNumber(acc.marketBuyerCount, 0) ?? 0,
|
||
marketDemandQty: roundNullableNumber(marketDemandQty, 0) ?? 0,
|
||
keywordSearchVolume: roundNullableNumber(keywordSearchVolume, 0) ?? 0,
|
||
keywordSearchVolumeSource,
|
||
supplyDemandRatio: roundNullableNumber(supplyDemandRatio, 8),
|
||
supplyDemandPer10k: roundNullableNumber(
|
||
supplyDemandRatio === null ? null : supplyDemandRatio * 10000,
|
||
2
|
||
),
|
||
idEfficiency: safeDivide(acc.marketSalesAmount, marketItemCount, 2) ?? 0,
|
||
marketSalesAmount: roundNullableNumber(acc.marketSalesAmount, 2) ?? 0,
|
||
marketAvgPrice: roundNullableNumber(marketAvgPrice, 2),
|
||
marketBestIndustryRank: roundNullableNumber(acc.marketBestIndustryRank, 0),
|
||
marketBestPriceBandRank: roundNullableNumber(acc.marketBestPriceBandRank, 0),
|
||
brandCompetitorCount: acc.brandSalesAmountMap.size,
|
||
topBrandName,
|
||
topBrandSalesAmount: roundNullableNumber(topBrandSalesAmount, 2) ?? 0,
|
||
topBrandSalesShare: roundNullableNumber(topBrandSalesShare, 4),
|
||
brandSalesAmount: roundNullableNumber(acc.brandSalesAmount, 2) ?? 0,
|
||
brandSalesShare: roundNullableNumber(brandSalesShare, 4),
|
||
brandSalesShareSource,
|
||
brandCompetitionValue: roundNullableNumber(brandCompetitionValue, 2),
|
||
brandDataCoverageRate,
|
||
mayMatchedSkuCount,
|
||
mayGoodsIdCount: roundNullableNumber(acc.mayGoodsIdCount, 0) ?? mayMatchedSkuCount,
|
||
mayPerformanceAmount: roundNullableNumber(acc.mayPerformanceAmount, 2) ?? 0,
|
||
mayInventoryQuantity: roundNullableNumber(acc.mayInventoryQuantity, 0) ?? 0,
|
||
mayAvgDailySales30d: roundNullableNumber(acc.mayAvgDailySales30d, 2) ?? 0,
|
||
mayGrossMarginRate: average(acc.mayGrossMarginRates, 4),
|
||
mayCoverageRate: safeDivide(mayMatchedSkuCount, marketItemCount, 4) ?? 0,
|
||
marketSkuDetails: sortMarketSkuDetails(acc.marketSkuDetails).slice(0, MARKET_SKU_DETAIL_LIMIT),
|
||
};
|
||
}
|
||
|
||
function rankMarketSummaries(summaries = []) {
|
||
return [...summaries]
|
||
.sort((left, right) => (
|
||
Number(right.marketSalesAmount || 0) - Number(left.marketSalesAmount || 0)
|
||
|| Number(right.marketDemandQty || 0) - Number(left.marketDemandQty || 0)
|
||
|| Number(right.marketItemCount || 0) - Number(left.marketItemCount || 0)
|
||
|| normalizeText(left.categoryLevel2).localeCompare(normalizeText(right.categoryLevel2), 'zh-CN')
|
||
|| normalizeText(left.categoryLevel3).localeCompare(normalizeText(right.categoryLevel3), 'zh-CN')
|
||
))
|
||
.map((row, index) => ({ ...row, marketRank: index + 1 }));
|
||
}
|
||
|
||
function buildMarketSummaryPayload(level2Map, level3Map) {
|
||
const rankedLevel2 = rankMarketSummaries(Array.from(level2Map.values()).map(finalizeMarketAccumulator));
|
||
const rankedLevel3 = rankMarketSummaries(Array.from(level3Map.values()).map(finalizeMarketAccumulator));
|
||
return {
|
||
level2: Object.fromEntries(rankedLevel2.map((row) => [marketCategoryKey(row.categoryLevel2), row])),
|
||
level3: Object.fromEntries(rankedLevel3.map((row) => [marketCategoryKey(row.categoryLevel2, row.categoryLevel3), row])),
|
||
level2Rows: rankedLevel2,
|
||
level3Rows: rankedLevel3,
|
||
};
|
||
}
|
||
|
||
function buildMarketCategoryResolver(marketSummary = {}) {
|
||
const level2Rows = Object.values(marketSummary.level2 || {});
|
||
const level3Rows = Object.values(marketSummary.level3 || {});
|
||
const level2Set = new Set(level2Rows.map((row) => normalizeText(row.categoryLevel2)).filter(Boolean));
|
||
const level3Map = new Map();
|
||
level3Rows
|
||
.sort((left, right) => Number(left.marketRank || 999999) - Number(right.marketRank || 999999))
|
||
.forEach((row) => {
|
||
const level3Name = normalizeText(row.categoryLevel3);
|
||
if (level3Name && !level3Map.has(level3Name)) {
|
||
level3Map.set(level3Name, {
|
||
categoryLevel2: normalizeText(row.categoryLevel2) || '未分类',
|
||
categoryLevel3: level3Name,
|
||
});
|
||
}
|
||
});
|
||
|
||
return (categoryLevel2 = '', categoryLevel3 = '', skuName = '') => {
|
||
const rawLevel2 = normalizeText(categoryLevel2);
|
||
const rawLevel3 = normalizeText(categoryLevel3) || rawLevel2;
|
||
const text = `${rawLevel2} ${rawLevel3} ${normalizeText(skuName)}`;
|
||
|
||
if (level3Map.has(rawLevel3)) {
|
||
return level3Map.get(rawLevel3);
|
||
}
|
||
if (level3Map.has(rawLevel2)) {
|
||
return level3Map.get(rawLevel2);
|
||
}
|
||
if (level2Set.has(rawLevel2)) {
|
||
return {
|
||
categoryLevel2: rawLevel2,
|
||
categoryLevel3: rawLevel3 || rawLevel2,
|
||
};
|
||
}
|
||
|
||
if (/[桌台]/u.test(text) && level2Set.has('桌类')) {
|
||
return { categoryLevel2: '桌类', categoryLevel3: rawLevel3 || rawLevel2 || '桌类' };
|
||
}
|
||
if (/柜/u.test(text) && level2Set.has('柜类')) {
|
||
return { categoryLevel2: '柜类', categoryLevel3: rawLevel3 || rawLevel2 || '柜类' };
|
||
}
|
||
if (/[椅凳坐]/u.test(text) && level2Set.has('坐具类')) {
|
||
return { categoryLevel2: '坐具类', categoryLevel3: rawLevel3 || rawLevel2 || '坐具类' };
|
||
}
|
||
if (/沙发/u.test(text) && level2Set.has('沙发类')) {
|
||
return { categoryLevel2: '沙发类', categoryLevel3: rawLevel3 || rawLevel2 || '沙发类' };
|
||
}
|
||
if (/[架栏杆]/u.test(text) && level2Set.has('架类')) {
|
||
return { categoryLevel2: '架类', categoryLevel3: rawLevel3 || rawLevel2 || '架类' };
|
||
}
|
||
if (/[几]/u.test(text) && level2Set.has('几类')) {
|
||
return { categoryLevel2: '几类', categoryLevel3: rawLevel3 || rawLevel2 || '几类' };
|
||
}
|
||
if (/镜/u.test(text) && level2Set.has('镜子类')) {
|
||
return { categoryLevel2: '镜子类', categoryLevel3: rawLevel3 || rawLevel2 || '镜子类' };
|
||
}
|
||
if (/床垫/u.test(text) && level2Set.has('床垫类')) {
|
||
return { categoryLevel2: '床垫类', categoryLevel3: rawLevel3 || rawLevel2 || '床垫类' };
|
||
}
|
||
if (/床/u.test(text) && level2Set.has('床类')) {
|
||
return { categoryLevel2: '床类', categoryLevel3: rawLevel3 || rawLevel2 || '床类' };
|
||
}
|
||
if (/收纳|整理/u.test(text) && level2Set.has('家庭收纳用具')) {
|
||
return { categoryLevel2: '家庭收纳用具', categoryLevel3: rawLevel3 || rawLevel2 || '家庭收纳用具' };
|
||
}
|
||
|
||
return {
|
||
categoryLevel2: rawLevel2 || '未分类',
|
||
categoryLevel3: rawLevel3 || rawLevel2 || '未分类',
|
||
};
|
||
};
|
||
}
|
||
|
||
function createOwnAccumulator(category) {
|
||
return {
|
||
categoryLevel2: category.categoryLevel2 || '未分类',
|
||
categoryLevel3: category.categoryLevel3 || category.categoryLevel2 || '未分类',
|
||
skuCount: 0,
|
||
stockQty: null,
|
||
categoryLevel3Set: new Set(),
|
||
skuCodeSet: new Set(),
|
||
skuDetails: [],
|
||
};
|
||
}
|
||
|
||
function finalizeOwnAccumulator(acc) {
|
||
return {
|
||
categoryLevel2: acc.categoryLevel2 || '未分类',
|
||
categoryLevel3: acc.categoryLevel3 || acc.categoryLevel2 || '未分类',
|
||
skuCount: roundNullableNumber(acc.skuCount, 0) ?? 0,
|
||
stockQty: roundNullableNumber(acc.stockQty, 0),
|
||
categoryLevel3Count: acc.categoryLevel3Set.size,
|
||
skuDetails: [...acc.skuDetails].sort((left, right) => {
|
||
const stockCompare = (Number(right.stockQty) || 0) - (Number(left.stockQty) || 0);
|
||
if (stockCompare !== 0) return stockCompare;
|
||
return normalizeText(left.skuCode).localeCompare(normalizeText(right.skuCode), 'zh-CN');
|
||
}),
|
||
};
|
||
}
|
||
|
||
function addOwnSkuToAccumulator(acc, sku = {}) {
|
||
const skuCode = normalizeKey(sku.skuCode);
|
||
if (!skuCode || acc.skuCodeSet.has(skuCode)) {
|
||
return;
|
||
}
|
||
|
||
const stockQty = roundNullableNumber(sku.stockQty, 0);
|
||
acc.skuCodeSet.add(skuCode);
|
||
acc.skuCount += 1;
|
||
if (stockQty !== null && stockQty !== undefined) {
|
||
acc.stockQty = (Number(acc.stockQty) || 0) + stockQty;
|
||
}
|
||
acc.skuDetails.push({
|
||
skuCode,
|
||
spu: normalizeText(sku.productCode) || deriveFallbackSpu(skuCode),
|
||
skuName: normalizeText(sku.skuName) || skuCode,
|
||
skuImageUrl: normalizeText(sku.skuImageUrl),
|
||
categoryLevel2: normalizeText(sku.categoryLevel2) || acc.categoryLevel2 || '未分类',
|
||
categoryLevel3: normalizeText(sku.categoryLevel3) || acc.categoryLevel3 || acc.categoryLevel2 || '未分类',
|
||
material: normalizeText(sku.material),
|
||
saleStatus: normalizeText(sku.saleStatus) || '未配置',
|
||
stockQty,
|
||
});
|
||
}
|
||
|
||
function buildOwnSummaryPayload(level2Map, level3Map) {
|
||
const level2Rows = Array.from(level2Map.values()).map(finalizeOwnAccumulator);
|
||
const level3Rows = Array.from(level3Map.values()).map(finalizeOwnAccumulator);
|
||
return {
|
||
level2: Object.fromEntries(level2Rows.map((row) => [marketCategoryKey(row.categoryLevel2), row])),
|
||
level3: Object.fromEntries(level3Rows.map((row) => [marketCategoryKey(row.categoryLevel2, row.categoryLevel3), row])),
|
||
level2Rows,
|
||
level3Rows,
|
||
};
|
||
}
|
||
|
||
async function loadOwnCategorySummaryFromBusinessDb(marketSummary = {}) {
|
||
try {
|
||
const excludedWhere = OWN_PRODUCT_EXCLUDED_CATEGORY_TERMS
|
||
.map((term, index) => `AND COALESCE(TRY_CONVERT(NVARCHAR(100), l.CategoryOne), N'') NOT LIKE @excluded${index}
|
||
AND COALESCE(TRY_CONVERT(NVARCHAR(100), l.CategoryTwo), N'') NOT LIKE @excluded${index}`)
|
||
.join('\n');
|
||
const params = OWN_PRODUCT_EXCLUDED_CATEGORY_TERMS.reduce((acc, term, index) => {
|
||
acc[`excluded${index}`] = `%${term}%`;
|
||
return acc;
|
||
}, { status: OWN_PRODUCT_STATUS_FILTER });
|
||
|
||
const rows = await cloudDb.query(
|
||
`
|
||
WITH active_library AS (
|
||
SELECT
|
||
UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.SkuCode)))) AS skuCode,
|
||
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.ItemNum))), N'') AS productCode,
|
||
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), l.SkuName))), N'') AS skuName,
|
||
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(500), l.Picture))), N'') AS skuImageUrl,
|
||
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.CategoryOne))), N'') AS categoryLevel2,
|
||
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.CategoryTwo))), N'') AS categoryLevel3,
|
||
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.materialcode))), N'') AS material,
|
||
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.ProStatus))), N'') AS saleStatus,
|
||
ROW_NUMBER() OVER (
|
||
PARTITION BY UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.SkuCode))))
|
||
ORDER BY COALESCE(l.CreationTime, l.created) DESC, l.Id DESC
|
||
) AS rn
|
||
FROM dbo.fys_SkuDataLibraryNew l WITH (NOLOCK)
|
||
WHERE l.SkuCode IS NOT NULL
|
||
AND LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.SkuCode))) <> N''
|
||
AND (@status = N'' OR l.ProStatus = @status)
|
||
${excludedWhere}
|
||
),
|
||
active_sku AS (
|
||
SELECT
|
||
skuCode,
|
||
productCode,
|
||
skuName,
|
||
skuImageUrl,
|
||
categoryLevel2,
|
||
categoryLevel3,
|
||
material,
|
||
saleStatus
|
||
FROM active_library
|
||
WHERE rn = 1
|
||
)
|
||
SELECT
|
||
s.skuCode,
|
||
s.productCode,
|
||
s.skuName,
|
||
s.skuImageUrl,
|
||
COALESCE(s.categoryLevel2, N'') AS categoryLevel2,
|
||
COALESCE(s.categoryLevel3, s.categoryLevel2, N'未分类') AS categoryLevel3,
|
||
s.material,
|
||
s.saleStatus
|
||
FROM active_sku s
|
||
`,
|
||
params,
|
||
{ requestTimeout: 180000, maxAttempts: 1 }
|
||
);
|
||
|
||
const resolveCategory = buildMarketCategoryResolver(marketSummary);
|
||
const level2Map = new Map();
|
||
const level3Map = new Map();
|
||
|
||
rows.forEach((row) => {
|
||
const resolved = resolveCategory(row.categoryLevel2, row.categoryLevel3, row.skuName);
|
||
const level2 = resolved.categoryLevel2 || '未分类';
|
||
const level3 = resolved.categoryLevel3 || level2;
|
||
const level2Key = marketCategoryKey(level2);
|
||
const level3Key = marketCategoryKey(level2, level3);
|
||
if (!level2Map.has(level2Key)) {
|
||
level2Map.set(level2Key, createOwnAccumulator({ categoryLevel2: level2, categoryLevel3: '' }));
|
||
}
|
||
if (!level3Map.has(level3Key)) {
|
||
level3Map.set(level3Key, createOwnAccumulator({ categoryLevel2: level2, categoryLevel3: level3 }));
|
||
}
|
||
level2Map.get(level2Key).categoryLevel3Set.add(level3);
|
||
level3Map.get(level3Key).categoryLevel3Set.add(level3);
|
||
addOwnSkuToAccumulator(level2Map.get(level2Key), {
|
||
...row,
|
||
categoryLevel2: level2,
|
||
categoryLevel3: level3,
|
||
});
|
||
addOwnSkuToAccumulator(level3Map.get(level3Key), {
|
||
...row,
|
||
categoryLevel2: level2,
|
||
categoryLevel3: level3,
|
||
});
|
||
});
|
||
|
||
return buildOwnSummaryPayload(level2Map, level3Map);
|
||
} catch (error) {
|
||
console.warn('[PlanningGoodsAnalysis] 我方产品库类目汇总失败,继续使用近60天销售SKU口径:', error.message || error);
|
||
return buildOwnSummaryPayload(new Map(), new Map());
|
||
}
|
||
}
|
||
|
||
async function loadMarketCategorySummaryFromPlanningDb() {
|
||
try {
|
||
const columnRows = await planningDatabase.query(`SHOW COLUMNS FROM ${escapeMysqlIdentifier(MARKET_PRODUCT_RECORDS_TABLE)}`);
|
||
const columnSet = new Set((columnRows || []).map((row) => row.Field));
|
||
const selectColumns = MARKET_SUMMARY_COLUMN_CANDIDATES.filter((column) => columnSet.has(column));
|
||
if (!selectColumns.length) {
|
||
return buildMarketSummaryPayload(new Map(), new Map());
|
||
}
|
||
|
||
const rows = await planningDatabase.query(
|
||
`SELECT ${selectColumns.map(escapeMysqlIdentifier).join(', ')}
|
||
FROM ${escapeMysqlIdentifier(MARKET_PRODUCT_RECORDS_TABLE)}`
|
||
);
|
||
const level2Map = new Map();
|
||
const level3Map = new Map();
|
||
|
||
rows.forEach((row, index) => {
|
||
const category = getMarketCategoryFromRow(row);
|
||
const level2Key = marketCategoryKey(category.categoryLevel2);
|
||
const level3Key = marketCategoryKey(category.categoryLevel2, category.categoryLevel3);
|
||
if (!level2Map.has(level2Key)) {
|
||
level2Map.set(level2Key, createMarketAccumulator({
|
||
...category,
|
||
categoryLevel3: '',
|
||
}));
|
||
}
|
||
if (!level3Map.has(level3Key)) {
|
||
level3Map.set(level3Key, createMarketAccumulator(category));
|
||
}
|
||
addMarketRow(level2Map.get(level2Key), row, index);
|
||
addMarketRow(level3Map.get(level3Key), row, index);
|
||
});
|
||
|
||
return buildMarketSummaryPayload(level2Map, level3Map);
|
||
} catch (error) {
|
||
console.warn('[PlanningGoodsAnalysis] 企划数据库市场基础对比数据汇总失败,继续展示我方货品分析:', error.message || error);
|
||
return buildMarketSummaryPayload(new Map(), new Map());
|
||
}
|
||
}
|
||
|
||
async function loadSalesRowsFromPlanningDb() {
|
||
await ensurePlanningChannelDailyReadSchema();
|
||
const latestRows = await planningDatabase.query('SELECT MAX(stat_date) AS latestBizDate FROM channel_daily_read');
|
||
const latestBizDate = formatDate(latestRows?.[0]?.latestBizDate);
|
||
if (!latestBizDate) {
|
||
return { latestBizDate: '', rows: [] };
|
||
}
|
||
|
||
const rows = await planningDatabase.query(
|
||
`
|
||
SELECT
|
||
UPPER(TRIM(sku_code)) AS skuCode,
|
||
COALESCE(NULLIF(TRIM(spu_key), ''), NULLIF(TRIM(spu), ''), UPPER(TRIM(sku_code))) AS spu,
|
||
MAX(NULLIF(sku_image_url, '')) AS skuImageUrl,
|
||
MAX(NULLIF(sale_status, '')) AS saleStatus,
|
||
MAX(NULLIF(purchaser_name, '')) AS purchaserName,
|
||
MIN(stat_date) AS firstSalesDate,
|
||
MAX(stat_date) AS lastSalesDate,
|
||
ROUND(SUM(CASE WHEN stat_date BETWEEN DATE_SUB(?, INTERVAL 29 DAY) AND ? THEN ship_count ELSE 0 END), 2) AS salesQty30d,
|
||
ROUND(SUM(CASE WHEN stat_date BETWEEN DATE_SUB(?, INTERVAL 29 DAY) AND ? THEN sales_amount ELSE 0 END), 2) AS salesAmount30d,
|
||
ROUND(SUM(CASE WHEN stat_date BETWEEN DATE_SUB(?, INTERVAL 29 DAY) AND ? THEN after_sale_amount ELSE 0 END), 2) AS afterSaleAmount30d,
|
||
COUNT(DISTINCT CASE WHEN stat_date BETWEEN DATE_SUB(?, INTERVAL 29 DAY) AND ? AND ship_count > 0 THEN stat_date END) AS activeSalesDays30d,
|
||
ROUND(SUM(CASE WHEN stat_date BETWEEN DATE_SUB(?, INTERVAL 2 DAY) AND ? THEN ship_count ELSE 0 END), 2) AS salesQty3d,
|
||
ROUND(SUM(CASE WHEN stat_date BETWEEN DATE_SUB(?, INTERVAL 5 DAY) AND DATE_SUB(?, INTERVAL 3 DAY) THEN ship_count ELSE 0 END), 2) AS prevSalesQty3d,
|
||
ROUND(SUM(CASE WHEN stat_date BETWEEN DATE_SUB(?, INTERVAL 6 DAY) AND ? THEN ship_count ELSE 0 END), 2) AS salesQty7d,
|
||
ROUND(SUM(CASE WHEN stat_date BETWEEN DATE_SUB(?, INTERVAL 14 DAY) AND ? THEN ship_count ELSE 0 END), 2) AS salesQty15d,
|
||
ROUND(SUM(CASE WHEN stat_date BETWEEN DATE_SUB(?, INTERVAL 29 DAY) AND DATE_SUB(?, INTERVAL 15 DAY) THEN ship_count ELSE 0 END), 2) AS prevSalesQty15d
|
||
FROM channel_daily_read
|
||
WHERE stat_date BETWEEN DATE_SUB(?, INTERVAL 59 DAY) AND ?
|
||
AND sku_code IS NOT NULL
|
||
AND TRIM(sku_code) <> ''
|
||
GROUP BY UPPER(TRIM(sku_code)), COALESCE(NULLIF(TRIM(spu_key), ''), NULLIF(TRIM(spu), ''), UPPER(TRIM(sku_code)))
|
||
ORDER BY salesQty30d DESC, salesAmount30d DESC
|
||
LIMIT ?
|
||
`,
|
||
[
|
||
latestBizDate, latestBizDate,
|
||
latestBizDate, latestBizDate,
|
||
latestBizDate, latestBizDate,
|
||
latestBizDate, latestBizDate,
|
||
latestBizDate, latestBizDate,
|
||
latestBizDate, latestBizDate,
|
||
latestBizDate, latestBizDate,
|
||
latestBizDate, latestBizDate,
|
||
latestBizDate, latestBizDate,
|
||
latestBizDate, latestBizDate,
|
||
SKU_ANALYSIS_LIMIT,
|
||
]
|
||
);
|
||
|
||
return {
|
||
latestBizDate,
|
||
rows: rows.filter((row) => !EXCLUDED_SKU_CODES.has(normalizeKey(row.skuCode))),
|
||
};
|
||
}
|
||
|
||
async function loadProductProfilesFromBusinessDb(skuCodes = []) {
|
||
const uniqueSkuCodes = Array.from(new Set(skuCodes.map(normalizeKey).filter(Boolean)));
|
||
const profileMap = new Map();
|
||
if (!uniqueSkuCodes.length) {
|
||
return profileMap;
|
||
}
|
||
|
||
try {
|
||
for (const skuChunk of chunkArray(uniqueSkuCodes)) {
|
||
const params = {
|
||
ownWarehouseNamePattern: OWN_STOCK_WAREHOUSE_NAME_PATTERN,
|
||
};
|
||
const inputSql = skuChunk.map((skuCode, index) => {
|
||
const paramName = `sku${index}`;
|
||
params[paramName] = skuCode;
|
||
return `SELECT @${paramName} AS skuCode`;
|
||
}).join('\nUNION ALL\n');
|
||
|
||
const rows = await cloudDb.query(
|
||
`
|
||
WITH sku_input AS (
|
||
${inputSql}
|
||
),
|
||
library_ranked AS (
|
||
SELECT
|
||
UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.SkuCode)))) AS skuCode,
|
||
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.ItemNum))), N'') AS productCode,
|
||
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), l.SkuName))), N'') AS skuName,
|
||
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(500), l.Picture))), N'') AS skuImageUrl,
|
||
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.CategoryOne))), N'') AS categoryLevel2,
|
||
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.CategoryTwo))), N'') AS categoryLevel3,
|
||
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.materialcode))), N'') AS material,
|
||
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.ProStatus))), N'') AS saleStatus,
|
||
COALESCE(NULLIF(TRY_CONVERT(DECIMAL(18, 2), l.CostPrice), 0), NULLIF(TRY_CONVERT(DECIMAL(18, 2), l.ErpPrice), 0), NULLIF(TRY_CONVERT(DECIMAL(18, 2), l.ActualPrice), 0)) AS unitCost,
|
||
COALESCE(NULLIF(TRY_CONVERT(DECIMAL(18, 2), l.ActualPrice), 0), NULLIF(TRY_CONVERT(DECIMAL(18, 2), l.ErpPrice), 0)) AS listPrice,
|
||
COALESCE(TRY_CONVERT(datetime, l.CreationTime), TRY_CONVERT(datetime, l.created)) AS firstListingDate,
|
||
ROW_NUMBER() OVER (
|
||
PARTITION BY UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.SkuCode))))
|
||
ORDER BY CASE WHEN l.ProStatus = N'销售' THEN 0 ELSE 1 END, COALESCE(l.CreationTime, l.created) DESC, l.Id DESC
|
||
) AS rn
|
||
FROM dbo.fys_SkuDataLibraryNew l WITH (NOLOCK)
|
||
INNER JOIN sku_input i ON UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.SkuCode)))) = i.skuCode
|
||
),
|
||
jst_ranked AS (
|
||
SELECT
|
||
UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), j.sku_id)))) AS skuCode,
|
||
CASE
|
||
WHEN j.other_1 IS NULL OR LTRIM(RTRIM(j.other_1)) = N'' THEN NULL
|
||
WHEN CHARINDEX(N'K', j.other_1) > 0 THEN LEFT(j.other_1, CHARINDEX(N'K', j.other_1) - 1)
|
||
ELSE j.other_1
|
||
END AS productCode,
|
||
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), j.name))), N'') AS skuName,
|
||
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(500), COALESCE(j.pic_big, j.pic)))), N'') AS skuImageUrl,
|
||
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), j.category))), N'') AS categoryLevel3,
|
||
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), j.other_10))), N'') AS material,
|
||
CASE WHEN ISNULL(j.enabled, 0) = 1 AND ISNULL(j.stock_disabled, 0) = 0 THEN N'销售' ELSE N'停售/禁用' END AS saleStatus,
|
||
COALESCE(NULLIF(TRY_CONVERT(DECIMAL(18, 2), j.cost_price), 0), NULLIF(TRY_CONVERT(DECIMAL(18, 2), j.purchase_price), 0)) AS unitCost,
|
||
NULLIF(TRY_CONVERT(DECIMAL(18, 2), j.sale_price), 0) AS listPrice,
|
||
COALESCE(TRY_CONVERT(datetime, j.created), TRY_CONVERT(datetime, j.modified)) AS firstListingDate,
|
||
ROW_NUMBER() OVER (
|
||
PARTITION BY UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), j.sku_id))))
|
||
ORDER BY CASE WHEN ISNULL(j.enabled, 0) = 1 AND ISNULL(j.stock_disabled, 0) = 0 THEN 0 ELSE 1 END, COALESCE(j.modified, j.created) DESC
|
||
) AS rn
|
||
FROM dbo.jst_sku j WITH (NOLOCK)
|
||
INNER JOIN sku_input i ON UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), j.sku_id)))) = i.skuCode
|
||
),
|
||
inventory_latest AS (
|
||
SELECT
|
||
skuCode,
|
||
SUM(stockQty) AS stockQty,
|
||
SUM(totalQty) AS totalStockQty,
|
||
MIN(firstInventoryDate) AS firstInventoryDate
|
||
FROM (
|
||
SELECT
|
||
UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), inv.sku_id)))) AS skuCode,
|
||
ISNULL(TRY_CONVERT(DECIMAL(18, 2), inv.qty), 0) AS stockQty,
|
||
ISNULL(TRY_CONVERT(DECIMAL(18, 2), inv.qty), 0) AS totalQty,
|
||
COALESCE(TRY_CONVERT(datetime, inv.createTime), TRY_CONVERT(datetime, inv.updateTime), TRY_CONVERT(datetime, inv.modified)) AS firstInventoryDate,
|
||
ROW_NUMBER() OVER (
|
||
PARTITION BY inv.sku_id, ISNULL(inv.wms_co_id, 0)
|
||
ORDER BY TRY_CONVERT(datetime, inv.modified) DESC, TRY_CONVERT(datetime, inv.updateTime) DESC, inv.Id DESC
|
||
) AS rn
|
||
FROM dbo.jst_inventoryInfo inv WITH (NOLOCK)
|
||
INNER JOIN dbo.jst_warehouseInfo wh WITH (NOLOCK)
|
||
ON wh.wms_co_id = inv.wms_co_id
|
||
AND wh.name LIKE @ownWarehouseNamePattern
|
||
INNER JOIN sku_input i ON inv.sku_id = i.skuCode
|
||
) x
|
||
WHERE rn = 1
|
||
GROUP BY skuCode
|
||
),
|
||
profile_base AS (
|
||
SELECT
|
||
i.skuCode,
|
||
COALESCE(NULLIF(l.productCode, N''), NULLIF(j.productCode, N''), i.skuCode) AS spu,
|
||
COALESCE(l.skuName, j.skuName, i.skuCode) AS skuName,
|
||
COALESCE(l.skuImageUrl, j.skuImageUrl, N'') AS skuImageUrl,
|
||
COALESCE(l.categoryLevel2, N'未分类') AS categoryLevel2,
|
||
COALESCE(l.categoryLevel3, j.categoryLevel3, l.categoryLevel2, N'未分类') AS categoryLevel3,
|
||
COALESCE(l.material, j.material, N'') AS material,
|
||
COALESCE(l.saleStatus, j.saleStatus, N'未配置') AS saleStatus,
|
||
COALESCE(l.unitCost, j.unitCost) AS unitCost,
|
||
COALESCE(l.listPrice, j.listPrice) AS listPrice,
|
||
COALESCE(l.firstListingDate, j.firstListingDate) AS firstListingDate
|
||
FROM sku_input i
|
||
LEFT JOIN library_ranked l ON l.skuCode = i.skuCode AND l.rn = 1
|
||
LEFT JOIN jst_ranked j ON j.skuCode = i.skuCode AND j.rn = 1
|
||
)
|
||
SELECT
|
||
b.*,
|
||
COALESCE(inv.stockQty, 0) AS stockQty,
|
||
COALESCE(inv.totalStockQty, 0) AS totalStockQty,
|
||
inv.firstInventoryDate,
|
||
COALESCE(NULLIF(md.purchaser, N''), NULLIF(vc.purchaser, N''), NULLIF(pl.purchaser, N''), NULLIF(sc.purchaser, N''), N'未配置') AS purchaserName
|
||
FROM profile_base b
|
||
LEFT JOIN inventory_latest inv ON inv.skuCode = b.skuCode
|
||
OUTER APPLY (SELECT TOP 1 purchaser FROM dwd.dim_ope_spu_data_df d WITH (NOLOCK) WHERE d.spu = b.spu) md
|
||
OUTER APPLY (SELECT TOP 1 purchaser FROM dbo.V_caterAndOper v WITH (NOLOCK) WHERE v.spu = b.spu) vc
|
||
OUTER APPLY (SELECT TOP 1 purchaser FROM dbo.op_spuPrincipal_log p WITH (NOLOCK) WHERE p.spu = b.spu ORDER BY p.created DESC) pl
|
||
OUTER APPLY (SELECT TOP 1 purchaser FROM dbo.fys_SkuDataLibrary_SpuConfig c WITH (NOLOCK) WHERE b.spu LIKE c.spu + N'%' ORDER BY LEN(c.spu) DESC) sc
|
||
`,
|
||
params,
|
||
{ requestTimeout: 120000, maxAttempts: 1 }
|
||
);
|
||
|
||
rows.forEach((row) => {
|
||
const skuCode = normalizeKey(row.skuCode);
|
||
if (!skuCode || profileMap.has(skuCode)) {
|
||
return;
|
||
}
|
||
profileMap.set(skuCode, row);
|
||
});
|
||
}
|
||
} catch (error) {
|
||
console.warn('[PlanningGoodsAnalysis] 业务库补充 SKU 资料失败,继续使用渠道读库基础数据:', error.message || error);
|
||
}
|
||
|
||
return profileMap;
|
||
}
|
||
|
||
function buildInitialRows(salesRows = [], profileMap = new Map(), latestBizDate = '', resolveMarketCategory = null) {
|
||
return salesRows.map((row) => {
|
||
const skuCode = normalizeKey(row.skuCode);
|
||
const profile = profileMap.get(skuCode) || {};
|
||
const rawCategoryLevel2 = normalizeText(profile.categoryLevel2) || '未分类';
|
||
const rawCategoryLevel3 = normalizeText(profile.categoryLevel3) || rawCategoryLevel2;
|
||
const rawSkuName = normalizeText(profile.skuName) || skuCode;
|
||
const resolvedCategory = typeof resolveMarketCategory === 'function'
|
||
? resolveMarketCategory(rawCategoryLevel2, rawCategoryLevel3, rawSkuName)
|
||
: {
|
||
categoryLevel2: rawCategoryLevel2,
|
||
categoryLevel3: rawCategoryLevel3,
|
||
};
|
||
const salesQty30d = toNumber(row.salesQty30d, 2);
|
||
const salesAmount30d = toNumber(row.salesAmount30d, 2);
|
||
const avgDailySales30d = roundNullableNumber(salesQty30d / 30, 4) ?? 0;
|
||
const salesQty7d = toNumber(row.salesQty7d, 2);
|
||
const salesQty15d = toNumber(row.salesQty15d, 2);
|
||
const prevSalesQty15d = toNumber(row.prevSalesQty15d, 2);
|
||
const salesGrowthRate3d = Number(row.prevSalesQty3d) > 0
|
||
? safeDivide(toNumber(row.salesQty3d, 2) - toNumber(row.prevSalesQty3d, 2), toNumber(row.prevSalesQty3d, 2), 4)
|
||
: (toNumber(row.salesQty3d, 2) > 0 ? 1 : 0);
|
||
const salesGrowthRate15d = prevSalesQty15d > 0
|
||
? safeDivide(salesQty15d - prevSalesQty15d, prevSalesQty15d, 4)
|
||
: (salesQty15d > 0 ? 1 : 0);
|
||
const sevenDayVsThirtyDayAvg = avgDailySales30d > 0
|
||
? safeDivide(salesQty7d / 7, avgDailySales30d, 4)
|
||
: (salesQty7d > 0 ? 1 : 0);
|
||
const stockQty = roundNullableNumber(profile.stockQty, 0);
|
||
const safeStockQty = Number.isFinite(Number(stockQty)) ? Number(stockQty) : 0;
|
||
const inventoryTurnoverDays = avgDailySales30d > 0
|
||
? roundNullableNumber(safeStockQty / avgDailySales30d, 1)
|
||
: (safeStockQty > 0 ? 9999 : null);
|
||
const initialSupplyQtyEstimate = safeStockQty + salesQty30d;
|
||
const sellThroughRate = initialSupplyQtyEstimate > 0
|
||
? safeDivide(salesQty30d, initialSupplyQtyEstimate, 4)
|
||
: null;
|
||
const unitCost = roundNullableNumber(profile.unitCost, 2);
|
||
const listPrice = roundNullableNumber(profile.listPrice, 2);
|
||
const avgSellPrice = salesQty30d > 0
|
||
? roundNullableNumber(salesAmount30d / salesQty30d, 2)
|
||
: listPrice;
|
||
const hasUnitCost = Number.isFinite(Number(unitCost)) && Number(unitCost) > 0;
|
||
const hasAvgSellPrice = Number.isFinite(Number(avgSellPrice)) && Number(avgSellPrice) > 0;
|
||
const grossProfit30d = hasUnitCost && hasAvgSellPrice
|
||
? roundNullableNumber((Number(avgSellPrice) - Number(unitCost)) * salesQty30d, 2)
|
||
: null;
|
||
const grossMarginRate = hasUnitCost && hasAvgSellPrice
|
||
? safeDivide(Number(avgSellPrice) - Number(unitCost), Number(avgSellPrice), 4)
|
||
: null;
|
||
const firstListingDate = formatDate(profile.firstListingDate) || formatDate(row.firstSalesDate);
|
||
const firstInventoryDate = formatDate(profile.firstInventoryDate);
|
||
|
||
return {
|
||
id: `sku:${skuCode}`,
|
||
rowType: 'item',
|
||
skuCode,
|
||
spu: normalizeText(profile.spu) || normalizeText(row.spu) || deriveFallbackSpu(skuCode),
|
||
skuName: rawSkuName,
|
||
skuImageUrl: normalizeText(profile.skuImageUrl) || normalizeText(row.skuImageUrl),
|
||
rawCategoryLevel2,
|
||
rawCategoryLevel3,
|
||
categoryLevel2: normalizeText(resolvedCategory.categoryLevel2) || rawCategoryLevel2,
|
||
categoryLevel3: normalizeText(resolvedCategory.categoryLevel3) || normalizeText(resolvedCategory.categoryLevel2) || rawCategoryLevel3,
|
||
material: normalizeText(profile.material),
|
||
saleStatus: normalizeText(profile.saleStatus) || normalizeText(row.saleStatus) || '未配置',
|
||
purchaserName: normalizeText(profile.purchaserName) || normalizeText(row.purchaserName) || '未配置',
|
||
firstListingDate,
|
||
firstInventoryDate,
|
||
listingDurationDays: firstListingDate ? dateDiffDays(firstListingDate, latestBizDate) : null,
|
||
inventoryAgingDays: firstInventoryDate ? dateDiffDays(firstInventoryDate, latestBizDate) : null,
|
||
firstSalesDate: formatDate(row.firstSalesDate),
|
||
lastSalesDate: formatDate(row.lastSalesDate),
|
||
salesQty30d,
|
||
salesAmount30d,
|
||
afterSaleAmount30d: toNumber(row.afterSaleAmount30d, 2),
|
||
activeSalesDays30d: toNumber(row.activeSalesDays30d, 0),
|
||
activeSalesDayRatio30d: safeDivide(row.activeSalesDays30d, 30, 4) ?? 0,
|
||
avgDailySales30d,
|
||
salesQty3d: toNumber(row.salesQty3d, 2),
|
||
prevSalesQty3d: toNumber(row.prevSalesQty3d, 2),
|
||
salesGrowthRate3d,
|
||
salesQty7d,
|
||
salesQty15d,
|
||
prevSalesQty15d,
|
||
salesGrowthRate15d,
|
||
sevenDayVsThirtyDayAvg,
|
||
stockQty: safeStockQty,
|
||
totalStockQty: roundNullableNumber(profile.totalStockQty, 0) ?? safeStockQty,
|
||
inventoryTurnoverDays,
|
||
initialSupplyQtyEstimate: roundNullableNumber(initialSupplyQtyEstimate, 0),
|
||
sellThroughRate,
|
||
unitCost,
|
||
listPrice,
|
||
avgSellPrice,
|
||
grossProfit30d,
|
||
grossMarginRate,
|
||
promotionRoi: null,
|
||
};
|
||
});
|
||
}
|
||
|
||
function finalizeRows(preliminaryRows = []) {
|
||
const categoryDailyMap = new Map();
|
||
preliminaryRows.forEach((row) => {
|
||
const key = row.categoryLevel3 || row.categoryLevel2 || '未分类';
|
||
const current = categoryDailyMap.get(key) || [];
|
||
current.push(Number(row.avgDailySales30d) || 0);
|
||
categoryDailyMap.set(key, current);
|
||
});
|
||
|
||
const categoryAvgMap = new Map();
|
||
categoryDailyMap.forEach((values, key) => {
|
||
categoryAvgMap.set(key, values.length > 0 ? values.reduce((sum, value) => sum + value, 0) / values.length : 0);
|
||
});
|
||
|
||
return preliminaryRows.map((row) => {
|
||
const categoryKey = row.categoryLevel3 || row.categoryLevel2 || '未分类';
|
||
const industryAvgDailySales30d = roundNullableNumber(categoryAvgMap.get(categoryKey) || 0, 4) ?? 0;
|
||
const withIndustry = { ...row, industryAvgDailySales30d };
|
||
const movementScore = buildMovementScore(withIndustry);
|
||
const growthScore = buildGrowthScore(withIndustry);
|
||
const inventoryScore = buildInventoryScore(withIndustry);
|
||
const profitScore = buildProfitScore(withIndustry);
|
||
const goodsScore = roundNullableNumber(
|
||
movementScore * 0.4 + growthScore * 0.25 + inventoryScore * 0.2 + profitScore * 0.15,
|
||
1
|
||
) ?? 0;
|
||
const decision = buildDecision(withIndustry);
|
||
|
||
return {
|
||
...withIndustry,
|
||
movementScore,
|
||
growthScore,
|
||
inventoryScore,
|
||
profitScore,
|
||
goodsScore,
|
||
...decision,
|
||
};
|
||
});
|
||
}
|
||
|
||
function getMarketSummaryForCategory(marketSummary = {}, dimension = 'level2', level2 = '', level3 = '') {
|
||
if (dimension === 'level3') {
|
||
return marketSummary.level3?.[marketCategoryKey(level2, level3)] || null;
|
||
}
|
||
return marketSummary.level2?.[marketCategoryKey(level2)] || null;
|
||
}
|
||
|
||
function getOwnSummaryForCategory(ownSummary = {}, dimension = 'level2', level2 = '', level3 = '') {
|
||
if (dimension === 'level3') {
|
||
return ownSummary.level3?.[marketCategoryKey(level2, level3)] || null;
|
||
}
|
||
return ownSummary.level2?.[marketCategoryKey(level2)] || null;
|
||
}
|
||
|
||
function buildEmptyMarketFields() {
|
||
return {
|
||
marketRank: null,
|
||
marketItemCount: 0,
|
||
marketSalesQty: 0,
|
||
marketSearchUsers: 0,
|
||
marketBrowseUsers: 0,
|
||
marketVisitorCount: 0,
|
||
marketBuyerCount: 0,
|
||
marketDemandQty: 0,
|
||
keywordSearchVolume: 0,
|
||
keywordSearchVolumeSource: 'missing',
|
||
supplyDemandRatio: null,
|
||
supplyDemandPer10k: null,
|
||
idEfficiency: 0,
|
||
marketSalesAmount: 0,
|
||
marketAvgPrice: null,
|
||
marketBestIndustryRank: null,
|
||
marketBestPriceBandRank: null,
|
||
brandCompetitorCount: 0,
|
||
topBrandName: '',
|
||
topBrandSalesAmount: 0,
|
||
topBrandSalesShare: null,
|
||
brandSalesAmount: 0,
|
||
brandSalesShare: null,
|
||
brandSalesShareSource: 'missing',
|
||
brandCompetitionValue: null,
|
||
brandDataCoverageRate: 0,
|
||
mayMatchedSkuCount: 0,
|
||
mayGoodsIdCount: 0,
|
||
mayPerformanceAmount: 0,
|
||
mayInventoryQuantity: 0,
|
||
mayAvgDailySales30d: 0,
|
||
mayGrossMarginRate: null,
|
||
mayCoverageRate: 0,
|
||
ownSkuCoverageRate: null,
|
||
ownMarketSalesAmountRatio: null,
|
||
ownMarketSalesQtyRatio: null,
|
||
marketSkuDetails: [],
|
||
};
|
||
}
|
||
|
||
function buildMarketCompareFields(market = null, ownMetrics = {}) {
|
||
const base = market || buildEmptyMarketFields();
|
||
const salesAmount30d = Number(ownMetrics.salesAmount30d || 0);
|
||
const salesQty30d = Number(ownMetrics.salesQty30d || 0);
|
||
return {
|
||
marketRank: base.marketRank ?? null,
|
||
marketItemCount: base.marketItemCount || 0,
|
||
marketSalesQty: base.marketSalesQty || 0,
|
||
marketSearchUsers: base.marketSearchUsers || 0,
|
||
marketBrowseUsers: base.marketBrowseUsers || 0,
|
||
marketVisitorCount: base.marketVisitorCount || 0,
|
||
marketBuyerCount: base.marketBuyerCount || 0,
|
||
marketDemandQty: base.marketDemandQty || 0,
|
||
keywordSearchVolume: base.keywordSearchVolume || 0,
|
||
keywordSearchVolumeSource: base.keywordSearchVolumeSource || 'missing',
|
||
supplyDemandRatio: base.supplyDemandRatio ?? null,
|
||
supplyDemandPer10k: base.supplyDemandPer10k ?? null,
|
||
idEfficiency: base.idEfficiency || 0,
|
||
marketSalesAmount: base.marketSalesAmount || 0,
|
||
marketAvgPrice: base.marketAvgPrice ?? null,
|
||
marketBestIndustryRank: base.marketBestIndustryRank ?? null,
|
||
marketBestPriceBandRank: base.marketBestPriceBandRank ?? null,
|
||
brandCompetitorCount: base.brandCompetitorCount || 0,
|
||
topBrandName: base.topBrandName || '',
|
||
topBrandSalesAmount: base.topBrandSalesAmount || 0,
|
||
topBrandSalesShare: base.topBrandSalesShare ?? null,
|
||
brandSalesAmount: base.brandSalesAmount || 0,
|
||
brandSalesShare: base.brandSalesShare ?? null,
|
||
brandSalesShareSource: base.brandSalesShareSource || 'missing',
|
||
brandCompetitionValue: base.brandCompetitionValue ?? null,
|
||
brandDataCoverageRate: base.brandDataCoverageRate || 0,
|
||
mayMatchedSkuCount: base.mayMatchedSkuCount || 0,
|
||
mayGoodsIdCount: base.mayGoodsIdCount || 0,
|
||
mayPerformanceAmount: base.mayPerformanceAmount || 0,
|
||
mayInventoryQuantity: base.mayInventoryQuantity || 0,
|
||
mayAvgDailySales30d: base.mayAvgDailySales30d || 0,
|
||
mayGrossMarginRate: base.mayGrossMarginRate ?? null,
|
||
mayCoverageRate: base.mayCoverageRate || 0,
|
||
marketSkuDetails: Array.isArray(base.marketSkuDetails) ? base.marketSkuDetails : [],
|
||
ownMarketSalesAmountRatio: safeDivide(salesAmount30d, base.marketSalesAmount, 4),
|
||
ownMarketSalesQtyRatio: safeDivide(salesQty30d, base.marketDemandQty, 4),
|
||
};
|
||
}
|
||
|
||
function buildItemMarketCompareFields(market = null, ownMetrics = {}) {
|
||
const fields = buildMarketCompareFields(market, ownMetrics);
|
||
delete fields.marketSkuDetails;
|
||
return fields;
|
||
}
|
||
|
||
function attachMarketFieldsToItem(row = {}, marketSummary = {}) {
|
||
const market = getMarketSummaryForCategory(
|
||
marketSummary,
|
||
'level3',
|
||
row.categoryLevel2,
|
||
row.categoryLevel3
|
||
) || getMarketSummaryForCategory(marketSummary, 'level2', row.categoryLevel2);
|
||
return {
|
||
...row,
|
||
...buildItemMarketCompareFields(market, row),
|
||
};
|
||
}
|
||
|
||
function buildFilterOptions(rows = [], marketSummary = {}) {
|
||
const marketLevel2 = Object.values(marketSummary.level2 || {}).map((row) => row.categoryLevel2);
|
||
const marketLevel3 = Object.values(marketSummary.level3 || {}).map((row) => row.categoryLevel3);
|
||
return {
|
||
decisionKeys: SKU_DECISION_DEFINITIONS,
|
||
saleStatuses: uniqueSorted(rows.map((row) => row.saleStatus)),
|
||
purchasers: uniqueSorted(rows.map((row) => row.purchaserName)),
|
||
categoryLevel2: uniqueSorted([...rows.map((row) => row.categoryLevel2), ...marketLevel2]),
|
||
categoryLevel3: uniqueSorted([...rows.map((row) => row.categoryLevel3), ...marketLevel3]),
|
||
};
|
||
}
|
||
|
||
function applyFilters(rows = [], filters = {}) {
|
||
const keyword = normalizeText(filters.keyword).toLowerCase();
|
||
const decisionKeys = new Set(parseListParam(filters.decisionKeys));
|
||
const saleStatuses = new Set(parseListParam(filters.saleStatuses));
|
||
const purchasers = new Set(parseListParam(filters.purchasers));
|
||
const categoryLevel2 = normalizeText(filters.categoryLevel2);
|
||
const categoryLevel3 = normalizeText(filters.categoryLevel3);
|
||
|
||
return rows.filter((row) => {
|
||
if (keyword) {
|
||
const haystack = [
|
||
row.skuCode,
|
||
row.spu,
|
||
row.skuName,
|
||
row.categoryLevel2,
|
||
row.categoryLevel3,
|
||
row.purchaserName,
|
||
row.saleStatus,
|
||
row.decisionLabel,
|
||
].map((value) => normalizeText(value).toLowerCase()).join(' ');
|
||
if (!haystack.includes(keyword)) {
|
||
return false;
|
||
}
|
||
}
|
||
if (decisionKeys.size > 0 && !decisionKeys.has(row.decisionKey)) return false;
|
||
if (saleStatuses.size > 0 && !saleStatuses.has(row.saleStatus)) return false;
|
||
if (purchasers.size > 0 && !purchasers.has(row.purchaserName)) return false;
|
||
if (categoryLevel2 && row.categoryLevel2 !== categoryLevel2) return false;
|
||
if (categoryLevel3 && row.categoryLevel3 !== categoryLevel3) return false;
|
||
return true;
|
||
});
|
||
}
|
||
|
||
function applyView(rows = [], activeView = 'all') {
|
||
const view = normalizeText(activeView) || 'all';
|
||
if (view === 'all') {
|
||
return rows;
|
||
}
|
||
return rows.filter((row) => row.decisionKey === view);
|
||
}
|
||
|
||
function sortRows(rows = [], sortField = GOODS_ANALYSIS_DEFAULT_QUERY.sortField, sortOrder = GOODS_ANALYSIS_DEFAULT_QUERY.sortOrder) {
|
||
const field = normalizeText(sortField) || GOODS_ANALYSIS_DEFAULT_QUERY.sortField;
|
||
const order = normalizeText(sortOrder) === 'ascend' ? 'ascend' : 'descend';
|
||
return [...rows].sort((left, right) => {
|
||
const leftValue = left?.[field];
|
||
const rightValue = right?.[field];
|
||
const leftMissing = leftValue === null || leftValue === undefined || leftValue === '';
|
||
const rightMissing = rightValue === null || rightValue === undefined || rightValue === '';
|
||
const leftNumber = leftMissing ? NaN : Number(leftValue);
|
||
const rightNumber = rightMissing ? NaN : Number(rightValue);
|
||
|
||
let compareResult;
|
||
if (leftMissing && rightMissing) {
|
||
compareResult = 0;
|
||
} else if (leftMissing) {
|
||
return 1;
|
||
} else if (rightMissing) {
|
||
return -1;
|
||
} else if (Number.isFinite(leftNumber) && Number.isFinite(rightNumber)) {
|
||
compareResult = leftNumber - rightNumber;
|
||
} else {
|
||
compareResult = normalizeText(leftValue).localeCompare(normalizeText(rightValue), 'zh-CN');
|
||
}
|
||
|
||
return order === 'ascend' ? compareResult : -compareResult;
|
||
});
|
||
}
|
||
|
||
function getDecisionCounts(rows = []) {
|
||
return rows.reduce((acc, row) => {
|
||
const key = normalizeText(row.decisionKey) || 'watch';
|
||
acc[key] = (acc[key] || 0) + 1;
|
||
return acc;
|
||
}, {});
|
||
}
|
||
|
||
function buildGroupSkuDetails(rows = [], own = null) {
|
||
const detailMap = new Map();
|
||
|
||
(Array.isArray(own?.skuDetails) ? own.skuDetails : []).forEach((sku) => {
|
||
const skuCode = normalizeKey(sku.skuCode);
|
||
if (!skuCode) return;
|
||
detailMap.set(skuCode, {
|
||
skuCode,
|
||
spu: normalizeText(sku.spu) || deriveFallbackSpu(skuCode),
|
||
skuName: normalizeText(sku.skuName) || skuCode,
|
||
skuImageUrl: normalizeText(sku.skuImageUrl),
|
||
saleStatus: normalizeText(sku.saleStatus) || '未配置',
|
||
material: normalizeText(sku.material),
|
||
salesQty30d: 0,
|
||
salesAmount30d: 0,
|
||
stockQty: roundNullableNumber(sku.stockQty, 0),
|
||
goodsScore: null,
|
||
decisionKey: 'missing',
|
||
decisionLabel: '无渠道数据',
|
||
isActiveSku: false,
|
||
});
|
||
});
|
||
|
||
rows.forEach((row) => {
|
||
const skuCode = normalizeKey(row.skuCode);
|
||
if (!skuCode) return;
|
||
const base = detailMap.get(skuCode) || {};
|
||
detailMap.set(skuCode, {
|
||
...base,
|
||
skuCode,
|
||
spu: normalizeText(row.spu) || base.spu || deriveFallbackSpu(skuCode),
|
||
skuName: normalizeText(row.skuName) || base.skuName || skuCode,
|
||
skuImageUrl: normalizeText(row.skuImageUrl) || base.skuImageUrl || '',
|
||
saleStatus: normalizeText(row.saleStatus) || base.saleStatus || '未配置',
|
||
material: normalizeText(row.material) || base.material || '',
|
||
salesQty30d: row.salesQty30d,
|
||
salesAmount30d: row.salesAmount30d,
|
||
stockQty: row.stockQty,
|
||
goodsScore: row.goodsScore,
|
||
decisionKey: row.decisionKey,
|
||
decisionLabel: row.decisionLabel,
|
||
isActiveSku: true,
|
||
});
|
||
});
|
||
|
||
return Array.from(detailMap.values()).sort((left, right) => {
|
||
if (Boolean(left.isActiveSku) !== Boolean(right.isActiveSku)) {
|
||
return left.isActiveSku ? -1 : 1;
|
||
}
|
||
const salesCompare = (Number(right.salesQty30d) || 0) - (Number(left.salesQty30d) || 0);
|
||
if (salesCompare !== 0) return salesCompare;
|
||
const stockCompare = (Number(right.stockQty) || 0) - (Number(left.stockQty) || 0);
|
||
if (stockCompare !== 0) return stockCompare;
|
||
return normalizeText(left.skuCode).localeCompare(normalizeText(right.skuCode), 'zh-CN');
|
||
});
|
||
}
|
||
|
||
function resolveGroupDecision(decisionCounts = {}, rows = []) {
|
||
const total = rows.length || 0;
|
||
if ((decisionCounts.stop || 0) > 0) return ['stop', '直接停售清单'];
|
||
if ((decisionCounts.boom || 0) > 0) return ['boom', '爆款重点扶持'];
|
||
if ((decisionCounts.keep || 0) > 0) return ['keep', '保留/重点运营'];
|
||
if (total > 0 && (decisionCounts.missing || 0) === total) return ['missing', '关键数据不足'];
|
||
return ['watch', '中间观察清单'];
|
||
}
|
||
|
||
function scoreLogScale(value, reference, weight) {
|
||
const numeric = Math.max(Number(value) || 0, 0);
|
||
const ref = Math.max(Number(reference) || 1, 1);
|
||
return clampNumber((Math.log1p(numeric) / Math.log1p(ref)) * weight, 0, weight);
|
||
}
|
||
|
||
function buildMarketOpportunitySignals(marketFields = {}, ownFields = {}) {
|
||
const marketSalesAmount = Number(marketFields.marketSalesAmount || 0);
|
||
const marketItemCount = Number(marketFields.marketItemCount || 0);
|
||
const keywordSearchVolume = Number(marketFields.keywordSearchVolume || 0);
|
||
const idEfficiency = Number(marketFields.idEfficiency || 0);
|
||
const supplyDemandPer10k = Number(marketFields.supplyDemandPer10k);
|
||
const brandSalesShare = Number(marketFields.brandSalesShare);
|
||
const brandDataCoverageRate = Number(marketFields.brandDataCoverageRate || 0);
|
||
const ownSkuCoverageRate = Number(ownFields.ownSkuCoverageRate || 0);
|
||
const ownMarketSalesAmountRatio = Number(marketFields.ownMarketSalesAmountRatio || 0);
|
||
|
||
const demandScore = scoreLogScale(marketSalesAmount, 300000000, 28);
|
||
const searchScore = scoreLogScale(keywordSearchVolume, 1000000, 18);
|
||
const efficiencyScore = scoreLogScale(idEfficiency, 500000, 18);
|
||
const supplyScore = Number.isFinite(supplyDemandPer10k)
|
||
? clampNumber(18 - (supplyDemandPer10k / 80) * 18, 0, 18)
|
||
: 6;
|
||
const brandScore = Number.isFinite(brandSalesShare) && brandSalesShare > 0
|
||
? clampNumber((1 - Math.min(brandSalesShare, 0.75) / 0.75) * 10, 0, 10)
|
||
: (brandDataCoverageRate > 0 ? 5 : 3);
|
||
const ownGapScore = clampNumber((1 - Math.min(ownSkuCoverageRate, 1)) * 8, 0, 8);
|
||
const ownWeakSalesScore = clampNumber((1 - Math.min(ownMarketSalesAmountRatio, 0.12) / 0.12) * 8, 0, 8);
|
||
const score = roundNullableNumber(
|
||
demandScore + searchScore + efficiencyScore + supplyScore + brandScore + ownGapScore + ownWeakSalesScore,
|
||
1
|
||
) ?? 0;
|
||
|
||
return {
|
||
marketOpportunityScore: clampNumber(score, 0, 100),
|
||
demandScore: roundNullableNumber(demandScore, 1) ?? 0,
|
||
searchScore: roundNullableNumber(searchScore, 1) ?? 0,
|
||
efficiencyScore: roundNullableNumber(efficiencyScore, 1) ?? 0,
|
||
supplyScore: roundNullableNumber(supplyScore, 1) ?? 0,
|
||
brandScore: roundNullableNumber(brandScore, 1) ?? 0,
|
||
ownGapScore: roundNullableNumber(ownGapScore + ownWeakSalesScore, 1) ?? 0,
|
||
};
|
||
}
|
||
|
||
function resolveMarketDecision(marketFields = {}, ownFields = {}) {
|
||
const marketItemCount = Number(marketFields.marketItemCount || 0);
|
||
const marketSalesAmount = Number(marketFields.marketSalesAmount || 0);
|
||
const keywordSearchVolume = Number(marketFields.keywordSearchVolume || 0);
|
||
const score = Number(marketFields.marketOpportunityScore || 0);
|
||
const ownSkuCoverageRate = Number(ownFields.ownSkuCoverageRate || 0);
|
||
const ownMarketSalesAmountRatio = Number(marketFields.ownMarketSalesAmountRatio || 0);
|
||
const supplyDemandPer10k = Number(marketFields.supplyDemandPer10k);
|
||
const brandSalesShare = Number(marketFields.brandSalesShare);
|
||
|
||
if (marketItemCount <= 0 || marketSalesAmount <= 0 || keywordSearchVolume <= 0) {
|
||
return ['data_gap', '关键数据不足'];
|
||
}
|
||
|
||
if (score >= 72 && (ownSkuCoverageRate < 0.35 || ownMarketSalesAmountRatio < 0.02)) {
|
||
return ['priority_supply', '优先补布局'];
|
||
}
|
||
|
||
if (score >= 68 && ownSkuCoverageRate >= 0.2 && ownMarketSalesAmountRatio >= 0.01) {
|
||
return ['boost', '重点放大'];
|
||
}
|
||
|
||
if (
|
||
(Number.isFinite(supplyDemandPer10k) && supplyDemandPer10k >= 70)
|
||
|| (Number.isFinite(brandSalesShare) && brandSalesShare >= 0.55)
|
||
) {
|
||
return ['cautious', '谨慎进入'];
|
||
}
|
||
|
||
if (score >= 56) {
|
||
return ['optimize', '优化提效'];
|
||
}
|
||
|
||
if (score >= 42) {
|
||
return ['observe', '观察验证'];
|
||
}
|
||
|
||
return ['cautious', '谨慎进入'];
|
||
}
|
||
|
||
function buildGroupSummaryRow({ dimension, rows = [], level2 = '', level3 = '', marketSummary = {}, ownSummary = {} }) {
|
||
const salesSkuCount = rows.length;
|
||
const own = getOwnSummaryForCategory(ownSummary, dimension, level2, level3);
|
||
const skuCount = Math.max(salesSkuCount, Number(own?.skuCount || 0));
|
||
const decisionCounts = getDecisionCounts(rows);
|
||
const [skuDecisionKey, skuDecisionLabel] = resolveGroupDecision(decisionCounts, rows);
|
||
const salesQty30d = sumRows(rows, 'salesQty30d', 2);
|
||
const salesAmount30d = sumRows(rows, 'salesAmount30d', 2);
|
||
const ownStockQty = roundNullableNumber(own?.stockQty, 0);
|
||
const stockQty = ownStockQty !== null && ownStockQty !== undefined
|
||
? ownStockQty
|
||
: sumRows(rows, 'stockQty', 0);
|
||
const grossProfit30d = sumRows(rows, 'grossProfit30d', 2);
|
||
const avgDailySales30d = roundNullableNumber(salesQty30d / 30, 4) ?? 0;
|
||
const inventoryTurnoverDays = avgDailySales30d > 0
|
||
? roundNullableNumber(stockQty / avgDailySales30d, 1)
|
||
: (stockQty > 0 ? 9999 : null);
|
||
const grossMarginRate = salesAmount30d > 0 ? safeDivide(grossProfit30d, salesAmount30d, 4) : null;
|
||
const activeSalesDayRatio30d = average(rows.map((row) => row.activeSalesDayRatio30d), 4) ?? 0;
|
||
const sellThroughRate = safeDivide(salesQty30d, salesQty30d + stockQty, 4);
|
||
const groupName = dimension === 'level3' ? level3 : level2;
|
||
const market = getMarketSummaryForCategory(marketSummary, dimension, level2, level3);
|
||
const marketCompareFields = buildMarketCompareFields(market, { salesQty30d, salesAmount30d });
|
||
const ownSkuCoverageRate = safeDivide(skuCount, marketCompareFields.marketItemCount, 4);
|
||
const opportunitySignals = buildMarketOpportunitySignals(marketCompareFields, { ownSkuCoverageRate });
|
||
const marketFieldsWithSignals = {
|
||
...marketCompareFields,
|
||
...opportunitySignals,
|
||
};
|
||
const [decisionKey, decisionLabel] = resolveMarketDecision(marketFieldsWithSignals, { ownSkuCoverageRate });
|
||
const salesCategoryLevel3Count = new Set(rows.map((row) => row.categoryLevel3).filter(Boolean)).size;
|
||
const categoryLevel3Count = Math.max(salesCategoryLevel3Count, Number(own?.categoryLevel3Count || 0));
|
||
const purchaserCount = new Set(rows.map((row) => row.purchaserName).filter(Boolean)).size;
|
||
const reasonParts = [
|
||
marketFieldsWithSignals.marketRank ? `市场排名第 ${marketFieldsWithSignals.marketRank}` : '暂无市场排名',
|
||
`市场ID ${marketFieldsWithSignals.marketItemCount || 0} 个`,
|
||
`关键词/需求量 ${marketFieldsWithSignals.keywordSearchVolume || 0}`,
|
||
Number.isFinite(Number(marketFieldsWithSignals.supplyDemandPer10k))
|
||
? `供需比:每万搜索需求约 ${marketFieldsWithSignals.supplyDemandPer10k} 个市场ID`
|
||
: '供需比缺失',
|
||
`ID效能 ${marketFieldsWithSignals.idEfficiency || 0}`,
|
||
marketFieldsWithSignals.brandSalesShare !== null && marketFieldsWithSignals.brandSalesShare !== undefined
|
||
? `${marketFieldsWithSignals.brandSalesShareSource === 'brand_sales_amount' ? '品牌销售额占比' : '头部品牌销售额占比'} ${(Number(marketFieldsWithSignals.brandSalesShare) * 100).toFixed(1)}%`
|
||
: '品牌销售额占比缺失',
|
||
`${dimension === 'level2' ? '二级品类' : '三级品类'}我方产品库 ${skuCount} 个SKU`,
|
||
`市场机会分 ${marketFieldsWithSignals.marketOpportunityScore}`,
|
||
`直接停售 ${decisionCounts.stop || 0} 个`,
|
||
`重点运营 ${decisionCounts.keep || 0} 个`,
|
||
`爆款扶持 ${decisionCounts.boom || 0} 个`,
|
||
`观察 ${decisionCounts.watch || 0} 个`,
|
||
];
|
||
|
||
return {
|
||
id: `${dimension}:${level2 || '未分类'}:${level3 || ''}`,
|
||
rowType: dimension,
|
||
groupName,
|
||
groupLevelLabel: dimension === 'level2' ? '二级品类' : '三级品类',
|
||
categoryLevel2: level2 || '未分类',
|
||
categoryLevel3: dimension === 'level3' ? (level3 || '未分类') : '',
|
||
...marketFieldsWithSignals,
|
||
ownSkuCoverageRate,
|
||
categoryLevel3Count,
|
||
purchaserCount,
|
||
skuCount,
|
||
salesSkuCount,
|
||
salesQty30d,
|
||
salesAmount30d,
|
||
afterSaleAmount30d: sumRows(rows, 'afterSaleAmount30d', 2),
|
||
avgDailySales30d,
|
||
activeSalesDayRatio30d,
|
||
salesGrowthRate15d: average(rows.map((row) => row.salesGrowthRate15d), 4),
|
||
sevenDayVsThirtyDayAvg: average(rows.map((row) => row.sevenDayVsThirtyDayAvg), 4),
|
||
stockQty,
|
||
inventoryTurnoverDays,
|
||
sellThroughRate,
|
||
grossProfit30d,
|
||
grossMarginRate,
|
||
movementScore: average(rows.map((row) => row.movementScore), 1) ?? 0,
|
||
growthScore: average(rows.map((row) => row.growthScore), 1) ?? 0,
|
||
inventoryScore: average(rows.map((row) => row.inventoryScore), 1) ?? 0,
|
||
profitScore: average(rows.map((row) => row.profitScore), 1) ?? 0,
|
||
goodsScore: average(rows.map((row) => row.goodsScore), 1) ?? 0,
|
||
stopCount: decisionCounts.stop || 0,
|
||
keepCount: decisionCounts.keep || 0,
|
||
boomCount: decisionCounts.boom || 0,
|
||
watchCount: decisionCounts.watch || 0,
|
||
missingCount: decisionCounts.missing || 0,
|
||
decisionKey,
|
||
decisionLabel,
|
||
skuDecisionKey,
|
||
skuDecisionLabel,
|
||
decisionReason: reasonParts.join(';'),
|
||
topSkus: buildGroupSkuDetails(rows, own),
|
||
};
|
||
}
|
||
|
||
function shouldIncludeMarketOnlyGroup(market = {}, dimension = 'level2', filters = {}) {
|
||
const keyword = normalizeText(filters.keyword).toLowerCase();
|
||
const decisionKeys = new Set(parseListParam(filters.decisionKeys));
|
||
const saleStatuses = parseListParam(filters.saleStatuses);
|
||
const purchasers = parseListParam(filters.purchasers);
|
||
const categoryLevel2 = normalizeText(filters.categoryLevel2);
|
||
const categoryLevel3 = normalizeText(filters.categoryLevel3);
|
||
|
||
if (decisionKeys.size > 0 && !decisionKeys.has('missing')) return false;
|
||
if (saleStatuses.length > 0 || purchasers.length > 0) return false;
|
||
if (categoryLevel2 && market.categoryLevel2 !== categoryLevel2) return false;
|
||
if (categoryLevel3 && market.categoryLevel3 !== categoryLevel3) return false;
|
||
if (keyword) {
|
||
const haystack = [
|
||
market.categoryLevel1,
|
||
market.categoryLevel2,
|
||
market.categoryLevel3,
|
||
].map((value) => normalizeText(value).toLowerCase()).join(' ');
|
||
if (!haystack.includes(keyword)) {
|
||
return false;
|
||
}
|
||
}
|
||
if (dimension === 'level2' && categoryLevel3) return false;
|
||
return true;
|
||
}
|
||
|
||
function shouldIncludeOwnSummaryGroup(own = {}, dimension = 'level2', filters = {}) {
|
||
const keyword = normalizeText(filters.keyword).toLowerCase();
|
||
const decisionKeys = parseListParam(filters.decisionKeys);
|
||
const saleStatuses = parseListParam(filters.saleStatuses);
|
||
const purchasers = parseListParam(filters.purchasers);
|
||
const categoryLevel2 = normalizeText(filters.categoryLevel2);
|
||
const categoryLevel3 = normalizeText(filters.categoryLevel3);
|
||
|
||
if (decisionKeys.length > 0 || saleStatuses.length > 0 || purchasers.length > 0) return false;
|
||
if (categoryLevel2 && own.categoryLevel2 !== categoryLevel2) return false;
|
||
if (categoryLevel3 && own.categoryLevel3 !== categoryLevel3) return false;
|
||
if (dimension === 'level2' && categoryLevel3) return false;
|
||
if (keyword) {
|
||
const haystack = [
|
||
own.categoryLevel2,
|
||
own.categoryLevel3,
|
||
].map((value) => normalizeText(value).toLowerCase()).join(' ');
|
||
if (!haystack.includes(keyword)) {
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function buildDimensionRows(rows = [], dimension = 'level2', marketSummary = {}, filters = {}, ownSummary = {}) {
|
||
const activeDimension = normalizeDimension(dimension);
|
||
if (activeDimension === 'item') {
|
||
return rows.map((row) => ({ ...row, rowType: 'item', skuCount: 1 }));
|
||
}
|
||
|
||
const groups = new Map();
|
||
rows.forEach((row) => {
|
||
const level2 = normalizeText(row.categoryLevel2) || '未分类';
|
||
const level3 = normalizeText(row.categoryLevel3) || level2;
|
||
const key = activeDimension === 'level3' ? `${level2}\u0000${level3}` : level2;
|
||
const current = groups.get(key) || { level2, level3, rows: [] };
|
||
current.rows.push(row);
|
||
groups.set(key, current);
|
||
});
|
||
|
||
const marketRows = Object.values(marketSummary[activeDimension] || {});
|
||
marketRows.forEach((market) => {
|
||
const level2 = normalizeText(market.categoryLevel2) || '未分类';
|
||
const level3 = normalizeText(market.categoryLevel3) || level2;
|
||
const key = activeDimension === 'level3' ? `${level2}\u0000${level3}` : level2;
|
||
if (!groups.has(key) && shouldIncludeMarketOnlyGroup(market, activeDimension, filters)) {
|
||
groups.set(key, { level2, level3, rows: [] });
|
||
}
|
||
});
|
||
|
||
const ownRows = Object.values(ownSummary[activeDimension] || {});
|
||
ownRows.forEach((own) => {
|
||
const level2 = normalizeText(own.categoryLevel2) || '未分类';
|
||
const level3 = normalizeText(own.categoryLevel3) || level2;
|
||
const key = activeDimension === 'level3' ? `${level2}\u0000${level3}` : level2;
|
||
if (!groups.has(key) && shouldIncludeOwnSummaryGroup(own, activeDimension, filters)) {
|
||
groups.set(key, { level2, level3, rows: [] });
|
||
}
|
||
});
|
||
|
||
return Array.from(groups.values()).map((group) => buildGroupSummaryRow({
|
||
dimension: activeDimension,
|
||
level2: group.level2,
|
||
level3: group.level3,
|
||
rows: group.rows,
|
||
marketSummary,
|
||
ownSummary,
|
||
}));
|
||
}
|
||
|
||
function buildOverviewRows(rows = []) {
|
||
const isItemView = rows.some((row) => row.rowType === 'item');
|
||
const views = isItemView
|
||
? [
|
||
{ key: 'all', label: '全部SKU', description: '当前筛选下全部SKU明细。' },
|
||
...SKU_DECISION_DEFINITIONS.map((item) => ({ key: item.value, label: item.label, description: '' })),
|
||
]
|
||
: MARKET_VIEW_DEFINITIONS;
|
||
|
||
return views.map(({ key, label, description }) => {
|
||
const targetRows = key === 'all' ? rows : rows.filter((row) => row.decisionKey === key);
|
||
const skuCount = targetRows.reduce((sum, row) => sum + (Number(row.skuCount) || 1), 0);
|
||
const marketItemCount = sumRows(targetRows, 'marketItemCount', 0);
|
||
const keywordSearchVolume = sumRows(targetRows, 'keywordSearchVolume', 0);
|
||
const marketSalesAmount = sumRows(targetRows, 'marketSalesAmount', 2);
|
||
const brandSalesAmount = sumRows(targetRows, 'brandSalesAmount', 2);
|
||
const topBrandSalesAmount = sumRows(targetRows, 'topBrandSalesAmount', 2);
|
||
const brandSalesShareBase = brandSalesAmount > 0 ? brandSalesAmount : topBrandSalesAmount;
|
||
const brandSalesShare = safeDivide(brandSalesShareBase, marketSalesAmount, 4);
|
||
const supplyDemandRatio = safeDivide(marketItemCount, keywordSearchVolume, 8);
|
||
return {
|
||
key,
|
||
label,
|
||
description,
|
||
count: targetRows.length,
|
||
skuCount,
|
||
marketItemCount,
|
||
marketSalesQty: sumRows(targetRows, 'marketSalesQty', 0),
|
||
keywordSearchVolume,
|
||
supplyDemandRatio,
|
||
supplyDemandPer10k: roundNullableNumber(
|
||
supplyDemandRatio === null ? null : supplyDemandRatio * 10000,
|
||
2
|
||
),
|
||
idEfficiency: safeDivide(marketSalesAmount, marketItemCount, 2) ?? 0,
|
||
brandCompetitorCount: sumRows(targetRows, 'brandCompetitorCount', 0),
|
||
brandSalesShare,
|
||
brandSalesShareSource: brandSalesAmount > 0 ? 'brand_sales_amount' : 'top_brand_sales_amount_fallback',
|
||
brandSalesAmount,
|
||
topBrandSalesAmount,
|
||
brandCompetitionValue: brandSalesShare > 0
|
||
? roundNullableNumber(marketSalesAmount / brandSalesShare, 2)
|
||
: null,
|
||
avgMarketOpportunityScore: average(targetRows.map((row) => row.marketOpportunityScore), 1) ?? 0,
|
||
marketBuyerCount: sumRows(targetRows, 'marketBuyerCount', 0),
|
||
marketDemandQty: sumRows(targetRows, 'marketDemandQty', 0),
|
||
marketSalesAmount,
|
||
mayMatchedSkuCount: sumRows(targetRows, 'mayMatchedSkuCount', 0),
|
||
mayPerformanceAmount: sumRows(targetRows, 'mayPerformanceAmount', 2),
|
||
mayInventoryQuantity: sumRows(targetRows, 'mayInventoryQuantity', 0),
|
||
salesQty30d: sumRows(targetRows, 'salesQty30d', 2),
|
||
salesAmount30d: sumRows(targetRows, 'salesAmount30d', 2),
|
||
stockQty: sumRows(targetRows, 'stockQty', 0),
|
||
grossProfit30d: sumRows(targetRows, 'grossProfit30d', 2),
|
||
avgGoodsScore: average(targetRows.map((row) => row.goodsScore), 1) ?? 0,
|
||
};
|
||
});
|
||
}
|
||
|
||
function buildGoodsSummaryCards(overview = [], activeView = 'all') {
|
||
const activeCard = (overview || []).find((item) => item.key === activeView)
|
||
|| (overview || []).find((item) => item.key === 'all')
|
||
|| overview?.[0]
|
||
|| {};
|
||
|
||
return [
|
||
{
|
||
title: '市场销售额',
|
||
value: formatCompactSummaryCurrency(activeCard.marketSalesAmount),
|
||
delta: `${toNumber(activeCard.count, 0)} 个分组`,
|
||
},
|
||
{
|
||
title: '关键词需求量',
|
||
value: formatCompactSummaryNumber(activeCard.keywordSearchVolume || activeCard.marketDemandQty, 1),
|
||
delta: '优先关键词需求',
|
||
},
|
||
{
|
||
title: '市场ID数',
|
||
value: formatCompactSummaryNumber(activeCard.marketItemCount, 0),
|
||
delta: '市场供给规模',
|
||
},
|
||
{
|
||
title: '我方SKU数',
|
||
value: formatCompactSummaryNumber(activeCard.skuCount, 0),
|
||
delta: '当前视图覆盖',
|
||
},
|
||
{
|
||
title: '供需比',
|
||
value: activeCard.supplyDemandPer10k === null || activeCard.supplyDemandPer10k === undefined
|
||
? '--'
|
||
: formatCompactSummaryNumber(activeCard.supplyDemandPer10k, 2),
|
||
delta: '个ID/万搜索',
|
||
},
|
||
{
|
||
title: '我方30天销售额',
|
||
value: formatCompactSummaryCurrency(activeCard.salesAmount30d, 1),
|
||
delta: '近30天表现',
|
||
},
|
||
];
|
||
}
|
||
|
||
async function buildGoodsDataset() {
|
||
const startedAt = Date.now();
|
||
const [salesResult, marketSummary] = await Promise.all([
|
||
loadSalesRowsFromPlanningDb(),
|
||
loadMarketCategorySummaryFromPlanningDb(),
|
||
]);
|
||
const ownSummary = await loadOwnCategorySummaryFromBusinessDb(marketSummary);
|
||
const { latestBizDate, rows: salesRows } = salesResult;
|
||
const profileMap = await loadProductProfilesFromBusinessDb(salesRows.map((row) => row.skuCode));
|
||
const resolveMarketCategory = buildMarketCategoryResolver(marketSummary);
|
||
const preliminaryRows = buildInitialRows(salesRows, profileMap, latestBizDate, resolveMarketCategory);
|
||
const rows = finalizeRows(preliminaryRows).map((row) => attachMarketFieldsToItem(row, marketSummary));
|
||
|
||
return {
|
||
latestBizDate,
|
||
source: 'goods_operation',
|
||
sourceLabel: DATA_SOURCE_LABEL,
|
||
message: profileMap.size > 0
|
||
? ''
|
||
: '当前仅取到渠道日汇总,业务库 SKU 资料未补齐,库存/成本/毛利等字段可能为空。',
|
||
generatedAt: new Date().toISOString(),
|
||
elapsedMs: Date.now() - startedAt,
|
||
rows,
|
||
marketSummary,
|
||
ownSummary,
|
||
filterOptions: buildFilterOptions(rows, marketSummary),
|
||
};
|
||
}
|
||
|
||
function buildGoodsAnalysisResponse(dataset, query = {}) {
|
||
const page = safePage(query.page);
|
||
const pageSize = safePageSize(query.pageSize);
|
||
const activeDimension = normalizeDimension(query.dimension);
|
||
const filteredRows = applyFilters(dataset.rows || [], query);
|
||
const dimensionRows = buildDimensionRows(
|
||
filteredRows,
|
||
activeDimension,
|
||
dataset.marketSummary || {},
|
||
query,
|
||
dataset.ownSummary || {}
|
||
);
|
||
const overview = buildOverviewRows(dimensionRows);
|
||
const activeView = normalizeText(query.view) || 'all';
|
||
const scopedRows = applyView(dimensionRows, activeView);
|
||
const scopedSkuCount = scopedRows.reduce((sum, row) => sum + (Number(row.skuCount) || 1), 0);
|
||
const visibleRows = sortRows(
|
||
scopedRows,
|
||
query.sortField || GOODS_ANALYSIS_DEFAULT_QUERY.sortField,
|
||
query.sortOrder || GOODS_ANALYSIS_DEFAULT_QUERY.sortOrder
|
||
);
|
||
const offset = (page - 1) * pageSize;
|
||
const rows = visibleRows.slice(offset, offset + pageSize);
|
||
|
||
return {
|
||
latestBizDate: dataset.latestBizDate,
|
||
source: dataset.source,
|
||
sourceLabel: dataset.sourceLabel,
|
||
message: dataset.message,
|
||
generatedAt: dataset.generatedAt,
|
||
elapsedMs: dataset.elapsedMs,
|
||
activeView,
|
||
dimension: activeDimension,
|
||
categoryLevel2: normalizeText(query.categoryLevel2),
|
||
categoryLevel3: normalizeText(query.categoryLevel3),
|
||
overview,
|
||
summaryCards: buildGoodsSummaryCards(overview, activeView),
|
||
filterOptions: dataset.filterOptions,
|
||
page,
|
||
pageSize,
|
||
total: visibleRows.length,
|
||
skuTotal: scopedSkuCount,
|
||
rows,
|
||
};
|
||
}
|
||
|
||
function buildPendingGoodsAnalysisResponse(query = {}, dataset = null) {
|
||
const page = safePage(query.page);
|
||
const pageSize = safePageSize(query.pageSize);
|
||
|
||
if (dataset) {
|
||
return {
|
||
...buildGoodsAnalysisResponse(dataset, query),
|
||
pending: true,
|
||
stale: true,
|
||
message: '货品经营分析正在后台刷新,当前先展示上次成功结果。',
|
||
};
|
||
}
|
||
|
||
return {
|
||
latestBizDate: '',
|
||
source: 'goods_operation',
|
||
sourceLabel: DATA_SOURCE_LABEL,
|
||
message: '货品经营分析首次构建中,当前先返回加载状态,稍后会自动刷新。',
|
||
generatedAt: '',
|
||
elapsedMs: 0,
|
||
activeView: normalizeText(query.view) || 'all',
|
||
dimension: normalizeDimension(query.dimension),
|
||
categoryLevel2: normalizeText(query.categoryLevel2),
|
||
categoryLevel3: normalizeText(query.categoryLevel3),
|
||
overview: buildOverviewRows([]),
|
||
filterOptions: {
|
||
decisionKeys: [],
|
||
saleStatuses: [],
|
||
purchasers: [],
|
||
categoryLevel2: [],
|
||
categoryLevel3: [],
|
||
},
|
||
page,
|
||
pageSize,
|
||
total: 0,
|
||
skuTotal: 0,
|
||
rows: [],
|
||
pending: true,
|
||
stale: false,
|
||
};
|
||
}
|
||
|
||
function getCurrentGoodsDatasetState(options = {}) {
|
||
const forceRefresh = Boolean(options.forceRefresh);
|
||
const now = Date.now();
|
||
|
||
if (!forceRefresh && datasetCache.value && datasetCache.expiresAt > now) {
|
||
return { state: 'ready', dataset: datasetCache.value };
|
||
}
|
||
|
||
if (!forceRefresh && !datasetCache.value && persistedDatasetSnapshot) {
|
||
datasetCache = {
|
||
expiresAt: now + DATASET_CACHE_TTL_MS,
|
||
value: persistedDatasetSnapshot,
|
||
pending: null,
|
||
};
|
||
return { state: 'ready', dataset: datasetCache.value };
|
||
}
|
||
|
||
if (datasetCache.pending) {
|
||
return { state: 'pending', dataset: datasetCache.value || null, promise: datasetCache.pending };
|
||
}
|
||
|
||
const pending = runHeavyDatabaseTask('planning-goods-analysis:dataset', buildGoodsDataset)
|
||
.then(async (dataset) => {
|
||
datasetCache = {
|
||
expiresAt: Date.now() + DATASET_CACHE_TTL_MS,
|
||
value: dataset,
|
||
pending: null,
|
||
};
|
||
await Promise.all([
|
||
writeDatasetSnapshot(dataset),
|
||
writeDefaultResponseSnapshot(buildGoodsAnalysisResponse(dataset, GOODS_ANALYSIS_DEFAULT_QUERY)),
|
||
]);
|
||
console.log(`[PlanningGoodsAnalysis] goods operation build completed rows=${dataset.rows.length} elapsedMs=${dataset.elapsedMs}`);
|
||
return dataset;
|
||
})
|
||
.catch((error) => {
|
||
datasetCache.pending = null;
|
||
console.error('[PlanningGoodsAnalysis] goods operation build failed:', error.message || error);
|
||
throw error;
|
||
});
|
||
|
||
datasetCache.pending = pending;
|
||
return {
|
||
state: 'pending',
|
||
dataset: datasetCache.value ? clonePlainData(datasetCache.value) : null,
|
||
promise: pending,
|
||
};
|
||
}
|
||
|
||
async function getGoodsDataset(options = {}) {
|
||
const state = getCurrentGoodsDatasetState(options);
|
||
if (state.state === 'ready') {
|
||
return state.dataset;
|
||
}
|
||
return await state.promise;
|
||
}
|
||
|
||
async function getPlanningGoodsAnalysis(query = {}) {
|
||
const dataset = await getGoodsDataset({
|
||
forceRefresh: String(query.refresh || query._refresh || '') === '1',
|
||
});
|
||
return buildGoodsAnalysisResponse(dataset, query);
|
||
}
|
||
|
||
async function getPlanningGoodsAnalysisFast(query = {}) {
|
||
const forceRefresh = String(query.refresh || query._refresh || '') === '1';
|
||
const state = getCurrentGoodsDatasetState({ forceRefresh });
|
||
|
||
if (state.state === 'ready') {
|
||
return buildGoodsAnalysisResponse(state.dataset, query);
|
||
}
|
||
|
||
if (state.dataset) {
|
||
return buildPendingGoodsAnalysisResponse(query, state.dataset);
|
||
}
|
||
|
||
// Cold builds can take minutes. Return an explicit pending state with no synthetic rows.
|
||
if (state.promise && typeof state.promise.catch === 'function') {
|
||
state.promise.catch(() => {});
|
||
}
|
||
return buildPendingGoodsAnalysisResponse(query);
|
||
}
|
||
|
||
async function prewarmPlanningGoodsAnalysis(options = {}) {
|
||
if (goodsAnalysisPrewarmPending) {
|
||
return goodsAnalysisPrewarmPending;
|
||
}
|
||
|
||
goodsAnalysisPrewarmPending = (async () => {
|
||
const dataset = await getGoodsDataset({ forceRefresh: true });
|
||
const summary = buildGoodsAnalysisResponse(dataset, GOODS_ANALYSIS_DEFAULT_QUERY);
|
||
console.log(
|
||
`[PlanningGoodsAnalysisCache] goods prewarm source=${summary.source} total=${summary.total} skuTotal=${summary.skuTotal} elapsedMs=${summary.elapsedMs}`
|
||
);
|
||
return {
|
||
warmed: true,
|
||
source: summary.source,
|
||
total: summary.total,
|
||
skuTotal: summary.skuTotal,
|
||
latestBizDate: summary.latestBizDate,
|
||
elapsedMs: summary.elapsedMs,
|
||
};
|
||
})();
|
||
|
||
try {
|
||
return await goodsAnalysisPrewarmPending;
|
||
} finally {
|
||
goodsAnalysisPrewarmPending = null;
|
||
}
|
||
}
|
||
|
||
function getPlanningGoodsAnalysisSummary() {
|
||
if (datasetCache.value) {
|
||
const response = buildGoodsAnalysisResponse(datasetCache.value, GOODS_ANALYSIS_DEFAULT_QUERY);
|
||
return {
|
||
source: response.source,
|
||
total: response.total,
|
||
skuTotal: response.skuTotal,
|
||
latestBizDate: response.latestBizDate,
|
||
elapsedMs: response.elapsedMs,
|
||
pending: Boolean(datasetCache.pending),
|
||
};
|
||
}
|
||
|
||
if (persistedDefaultResponseSnapshot) {
|
||
return {
|
||
source: persistedDefaultResponseSnapshot.source,
|
||
total: persistedDefaultResponseSnapshot.total,
|
||
skuTotal: persistedDefaultResponseSnapshot.skuTotal,
|
||
latestBizDate: persistedDefaultResponseSnapshot.latestBizDate,
|
||
elapsedMs: persistedDefaultResponseSnapshot.elapsedMs,
|
||
pending: Boolean(datasetCache.pending),
|
||
};
|
||
}
|
||
|
||
return {
|
||
source: 'goods_operation',
|
||
total: 0,
|
||
skuTotal: 0,
|
||
latestBizDate: '',
|
||
elapsedMs: 0,
|
||
pending: Boolean(datasetCache.pending),
|
||
};
|
||
}
|
||
|
||
module.exports = {
|
||
getPlanningGoodsAnalysis,
|
||
getPlanningGoodsAnalysisFast,
|
||
getPlanningGoodsAnalysisSummary,
|
||
prewarmPlanningGoodsAnalysis,
|
||
};
|