cangku/backend/src/services/planningGoodsAnalysisService.js

2690 lines
105 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

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