cangku/backend/src/services/planningMayProductDataService.js

6700 lines
256 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 crypto = require('crypto');
const cloudDb = require('../config/cloudDatabase');
const planningDatabase = require('./planningDatabase');
const {
TAOBAO_PRODUCT_RECORDS_STANDARD_COLUMNS,
} = require('./taobaoProductRecordsAppearance');
const {
ensurePlanningAiImageSchema,
ensurePlanningChannelDailyReadSchema,
ensurePlanningPddSkuDimensionSchema,
ensurePlanningStockSelectionSchema,
} = require('./planningSupportSchema');
const AI_IMAGE_FEATURE_LIBRARY_TABLE = 'ai_image_feature_library';
const BUSINESS_SKU_LIBRARY_TABLE = 'fys_SkuDataLibraryNew';
const BUSINESS_SKU_TABLE = 'jst_sku';
const BUSINESS_SKU_MAP_TABLE = 'jst_skumap';
const BUSINESS_SKU_MAP_TABLES = Object.freeze([
BUSINESS_SKU_MAP_TABLE,
'jst_skumap_pdd',
'jst_skumap_qmcust_add',
]);
const BUSINESS_SKU_DIMENSION_TABLE = 'dwd.dim_ope_sku_data_df';
const PRICE_REQUEST_TIMEOUT_MS = 15000;
const CRITICAL_ENRICHMENT_TIMEOUT_MS = 8000;
const MAY_INVENTORY_ENRICHMENT_TIMEOUT_MS = 20000;
const MAY_SALES_ENRICHMENT_TIMEOUT_MS = 20000;
const OPTIONAL_ENRICHMENT_TIMEOUT_MS = 3000;
const MAY_FINANCIAL_ENRICHMENT_TIMEOUT_MS = 15000;
const MAY_PRICE_LIMIT_TABLE_TIMEOUT_MS = 8000;
const MAY_EMPTY_VALUE = '--';
const MAY_PRODUCT_SNAPSHOT_TABLE = 'planning_may_product_snapshot_di';
const MAY_PRODUCT_SNAPSHOT_SYNC_LOG_TABLE = 'planning_may_product_snapshot_sync_log';
const MAY_PRODUCT_REVIEW_SNAPSHOT_TABLE = 'planning_may_product_review_snapshot_di';
const MAY_PRODUCT_PROFILE_STAGE_TABLE = 'planning_may_product_profile_stage';
const MAY_PRODUCT_SALES_SUMMARY_STAGE_TABLE = 'planning_may_sales_summary_stage';
const MAY_PRODUCT_SALES_MAPPING_STAGE_TABLE = 'planning_may_sales_sku_mapping_stage';
const MAY_PRODUCT_PROFILE_STAGE_CALC_VERSION = 'may-profile-v20260710-oemodm-v13';
const MAY_PRODUCT_SALES_STAGE_CALC_VERSION = 'may-sales-stage-v20260617-transaction-amount-v8';
const MAY_PRODUCT_SNAPSHOT_CALC_VERSION = 'may-snapshot-v20260710-oemodm-v22';
const MAY_PRODUCT_SNAPSHOT_DEFAULT_CHUNK_SIZE = Math.max(
Number.parseInt(process.env.PLANNING_MAY_PRODUCT_SNAPSHOT_CHUNK_SIZE || '200', 10),
20
);
const MAY_PROFILE_INCLUDE_SKUMAP_SOURCES = String(process.env.PLANNING_MAY_PROFILE_INCLUDE_SKUMAP_SOURCES || 'false').toLowerCase() === 'true';
const MAY_PROFILE_USE_EXTRA_ENRICHMENT = String(process.env.PLANNING_MAY_PROFILE_USE_EXTRA_ENRICHMENT || 'false').toLowerCase() === 'true';
const MAY_PRODUCT_PROFILE_STAGE_READY_CACHE_TTL_MS = 60 * 1000;
let mayProductProfileStageReadyCache = null;
const STOCK_SELECTION_BASE_TABLE = 'op_BarcodeChannelDecisionBase';
const PDD_SKU_DIMENSION_SKU_TABLE = 'op_pdd_sku_dimension_sku_daily';
const CHANNEL_READ_TABLE = 'channel_daily_read';
const CHANNEL_SOURCE_TABLE = '[JSDataWarehouse].[dws].[dws_sn_sales_full_info_di]';
const CHANNEL_EXCLUDED_SKU_CODES = ['X00005', 'X00006', 'FUWU', 'TCXJ'];
const SQLSERVER_MAY_ACTIVE_STATUS_FILTER = "REPLACE(ISNULL(NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), pro_status))), N''), N''), N'''', N'') = N'销售'";
const SQLSERVER_JST_MAY_ACTIVE_STATUS_FILTER = [
"REPLACE(ISNULL(NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), other_5))), N''), N''), N'''', N'') = N'销售'",
"REPLACE(ISNULL(NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), item_type))), N''), N''), N'''', N'') = N'成品'",
"(NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), category))), N'') IS NULL OR LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), category))) NOT LIKE N'%配件%')",
].join('\n AND ');
function buildMayNotDiscontinuedCondition(columnExpression) {
return `REPLACE(COALESCE(NULLIF(TRIM(${columnExpression}), ''), ''), '''', '') = '销售'`;
}
const MAY_CHANNEL_RATIO_FIELDS = Object.freeze([
{
key: '1688',
snapshotColumn: 'may_1688_sales_ratio',
stageColumn: 'sales_ratio_1688',
label: 'MAY-1688销售占比',
keywords: ['1688', '阿里巴巴'],
},
{
key: 'taobao',
snapshotColumn: 'may_taobao_sales_ratio',
stageColumn: 'sales_ratio_taobao',
label: 'MAY-淘宝销售占比',
keywords: ['淘宝'],
},
{
key: 'tmall',
snapshotColumn: 'may_tmall_sales_ratio',
stageColumn: 'sales_ratio_tmall',
label: 'MAY-天猫销售占比',
keywords: ['天猫', 'tmall'],
},
{
key: 'pdd',
snapshotColumn: 'may_pdd_sales_ratio',
stageColumn: 'sales_ratio_pdd',
label: 'MAY-拼多多销售占比',
keywords: ['拼多多', '多多', 'pdd'],
},
{
key: 'jd',
snapshotColumn: 'may_jd_sales_ratio',
stageColumn: 'sales_ratio_jd',
label: 'MAY-京东销售占比',
keywords: ['京东', '京喜', 'jd'],
},
{
key: 'douyin',
snapshotColumn: 'may_douyin_sales_ratio',
stageColumn: 'sales_ratio_douyin',
label: 'MAY-抖音销售占比',
keywords: ['抖音', 'douyin'],
},
{
key: 'vip',
snapshotColumn: 'may_vip_sales_ratio',
stageColumn: 'sales_ratio_vip',
label: 'MAY-唯品会销售占比',
keywords: ['唯品会', 'vip'],
},
{
key: 'other',
snapshotColumn: 'may_other_channel_sales_ratio',
stageColumn: 'sales_ratio_other',
label: 'MAY-其他渠道销售占比',
keywords: [],
},
]);
const CHANNEL_NON_VIRTUAL_ORDER_FILTER_SQL = `
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.ps_payment_classify_isYes), 0) = 0
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.refund_amount_isZc), 0) = 0
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.refund_sale_compensate_isZc), 0) = 0
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.oo_mix_zc_kd), 0) = 0
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.fr_zc_dynamic_express_cost), 0) = 0
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.fr_zcd_pj_fdf_express_cost), 0) = 0`;
const ENABLE_MAY_FREIGHT_ENRICHMENT = String(process.env.PLANNING_MAY_ENABLE_FREIGHT || 'true').toLowerCase() !== 'false';
const MAY_PRICE_LIMIT_TABLE_SPECS = Object.freeze([
{
tableName: 'Bulk_Price_Limit',
keyFields: ['product_code', 'style_code', 'spu'],
limitFields: ['price_limit', 'min_price', 'alimama_limit_price', 'jingxi_limit_price_1', 'jingxi_limit_price_2', 'jingxi_limit_price'],
},
{
tableName: 'Brand_Price_Limit',
keyFields: ['spu', 'product_no', 'sku_id', 'style_code'],
limitFields: ['expected_daily_limit_price', 'non_spu_manager_limit_price_new', 'non_spu_manager_limit_price', 'main_sales_pdd_subsidy_limit_price', 'pdd_limit_price_new'],
},
{
tableName: 'Combo_Product_Price_Limit',
keyFields: ['combo_sku_id', 'child_sku_id', 'style_code'],
limitFields: ['final_combo_product_limit_price', 'combo_product_limit_price_aux', 'price_limit', 'pdd_al_limit_price', 'jingxi_limit_price', 'combo_product_jingxi_limit_price'],
},
{
tableName: 'Brand_Combo_Code_Price_Limit',
keyFields: ['combo_sku_id', 'child_sku_id', 'spu', 'product_no', 'style_code'],
limitFields: ['non_spu_manager_limit_price_new', 'non_spu_manager_limit_price', 'main_sales_pdd_subsidy_limit_price', 'pdd_limit_price_new', 'spu_manager_limit_price_combo_2', 'spu_manager_limit_price_combo', 'expected_daily_limit_price'],
},
]);
const MAY_PRODUCT_COLUMNS = Object.freeze(
TAOBAO_PRODUCT_RECORDS_STANDARD_COLUMNS
.filter((column) => String(column.name || '').startsWith('may_'))
.reduce((columns, column) => {
columns.push({ name: column.name, label: column.label });
if (column.name === 'may_sku_code') {
columns.push({ name: 'may_virtual_category', label: 'OEM/ODM' });
columns.push({ name: 'category_name', label: '类目名称' });
}
return columns;
}, [])
);
const MAY_PRODUCT_REVIEW_ACTIONS = Object.freeze({
all: '全部',
stop: '直接停售清单',
focus: '保留/重点运营',
boost: '爆款重点扶持',
observe: '中间观察清单',
data_missing: '关键数据不足',
});
const MAY_PRODUCT_REVIEW_COLUMNS = Object.freeze([
{ name: 'review_action', label: '复盘结论' },
{ name: 'review_tags', label: '命中标签' },
{ name: 'review_reasons', label: '命中原因' },
{ name: 'missing_metrics', label: '缺失指标' },
{ name: 'review_avg_daily_sales_30d', label: '近30天日销' },
{ name: 'review_short_term_growth_rate', label: '短期增长率' },
{ name: 'review_short_term_growth_basis', label: '增长口径' },
{ name: 'review_sales_7d_vs_30d_ratio', label: '近7日/近30日日销' },
{ name: 'review_stock_turnover_days', label: '库存周转天数' },
{ name: 'review_listing_duration_days', label: '铺货时长' },
{ name: 'review_gross_margin_rate', label: '毛利率' },
{ name: 'review_gross_profit_30d', label: '近30天毛利额' },
{ name: 'review_promotion_roi', label: '推广ROI' },
{ name: 'review_active_sales_day_ratio_30d', label: '动销率' },
]);
const MAY_PRODUCT_SNAPSHOT_COLUMNS = Object.freeze([
'stat_date',
'may_sku_code',
'may_virtual_category',
'product_code',
'spu_code',
'sku_name',
'product_name',
'category_name',
'supplier_name',
'product_cost',
'source_image_feature_id',
'may_appearance_recognition_code',
'may_sku_image_url',
'may_sales_status',
'may_core_material',
'may_min_limit_price',
'may_size',
'may_color_name',
'may_performance_amount',
'may_goods_id_count',
'may_covered_channels',
...MAY_CHANNEL_RATIO_FIELDS.map((field) => field.snapshotColumn),
'may_promotion_fee_30d',
'may_d_style_efficiency',
'may_inventory_quantity',
'may_real_company_inventory_quantity',
'may_estimated_sales_days',
'may_shipping_warehouse',
'may_inventory_scope_remark',
'may_avg_daily_sales_30d',
'may_avg_daily_sales_7d',
'may_sales_growth_rate_15d',
'may_listing_duration_days',
'may_gross_margin_rate',
'may_after_sales_ratio',
'may_active_sales_day_ratio_30d',
'source_latest_biz_date',
'source_row_count',
'calc_version',
'snapshot_batch_id',
'generated_at',
]);
let snapshotSchemaState = {
ready: false,
pending: null,
};
let reviewSnapshotSchemaState = {
ready: false,
pending: null,
};
let salesStageSchemaState = {
ready: false,
pending: null,
};
let profileStageSchemaState = {
ready: false,
pending: null,
};
const MAY_REAL_COMPANY_WAREHOUSE_INCLUDED_REMARKS = Object.freeze([
'真实库存',
]);
const MAY_REAL_COMPANY_WAREHOUSE_EXCLUDED_REMARKS = Object.freeze([
'不可释放',
]);
const MAY_INVENTORY_SCOPE_LABELS = Object.freeze({
realCompany: '真实公司库存',
dropShip: '代发库存',
factory: '工厂库存',
});
const MAY_PLANNING_BRAND_GROUPS = Object.freeze([
{
key: 'owned',
label: '自有品牌',
brands: [
{ name: '亿家达', aliases: ['亿家达'] },
{ name: '卓禾', aliases: ['卓禾', '卓和'] },
{ name: '欧意朗', aliases: ['欧意朗'] },
{ name: '朴美', aliases: ['朴美'] },
],
},
{
key: 'agent',
label: '代理品牌',
brands: [
{ name: '顾家', aliases: ['顾家', '顾家家居', 'KUKA', 'kuka', 'kukahome', '库卡', '顾升', '顾翎'] },
{ name: '索菲亚', aliases: ['索菲亚'] },
],
},
]);
const MAY_PLANNING_BRAND_DEFINITIONS = Object.freeze(
MAY_PLANNING_BRAND_GROUPS.flatMap((group) => (
group.brands.map((brand) => ({
...brand,
groupKey: group.key,
groupLabel: group.label,
}))
))
);
const MAY_REVIEW_DIMENSION_FALLBACK_LABEL = '其他';
const MAY_INVALID_DIMENSION_VALUES = Object.freeze(new Set([
'-',
'--',
'—',
'null',
'NULL',
'undefined',
'未配置',
'暂无',
'无',
'未分类',
'未归属供应商',
]));
const MAY_CATEGORY_OTHER_TERMS = Object.freeze([
'配件',
'赠品',
'辅料',
'样品',
'福利品',
'虚拟',
]);
const MAY_CATEGORY_NAME_INFERENCE_RULES = Object.freeze([
{ category: '床垫类', keywords: ['床垫'] },
{ category: '书架', keywords: ['书架'] },
{ category: '床类', keywords: ['实木床', '双人床', '单人床', '高低床', '上下床', '床架', '床屏'] },
{ category: '椅类', keywords: ['人体工学椅', '电竞椅', '办公椅', '电脑椅', '学习椅', '餐椅', '座椅', '椅子'] },
{ category: '桌类', keywords: ['电动升降桌', '升降桌', '电竞桌', '电脑桌', '办公桌', '学习桌', '书桌', '餐桌', '茶几'] },
{ category: '沙发类', keywords: ['沙发'] },
{ category: '柜类', keywords: ['电视柜', '斗柜', '床头柜', '边柜', '衣柜', '鞋柜', '柜'] },
]);
function normalizeText(value) {
if (value === undefined || value === null) {
return '';
}
return String(value).replace(/\u00a0/g, ' ').trim();
}
function normalizeSkuKey(value) {
return normalizeText(value).toUpperCase();
}
function normalizeMayVirtualCategory(value) {
const normalized = normalizeText(value).toUpperCase();
if (!normalized) {
return '';
}
if (normalized.includes('OEM')) {
return 'OEM';
}
if (normalized.includes('ODM')) {
return 'ODM';
}
return '';
}
function resolveMayVirtualCategory(value, fallback = '') {
return normalizeMayVirtualCategory(value) || fallback;
}
function resolveMayVirtualCategoryFilter(value) {
return normalizeMayVirtualCategory(value);
}
function appendMayVirtualCategoryWhere(whereParts, params, columnExpression, value) {
const virtualCategory = resolveMayVirtualCategoryFilter(value);
if (!virtualCategory) {
return virtualCategory;
}
whereParts.push(`UPPER(TRIM(COALESCE(${columnExpression}, ''))) = ?`);
params.push(virtualCategory);
return virtualCategory;
}
function resolveMayReviewDimensionFilter(value, fallbackLabel = MAY_REVIEW_DIMENSION_FALLBACK_LABEL) {
const normalized = normalizeText(value);
if (!normalized) {
return '';
}
return normalizeMayDimensionValue(normalized) || (normalized === fallbackLabel ? fallbackLabel : '');
}
function appendMayReviewDimensionWhere(whereParts, params, columnExpression, value, fallbackLabel = MAY_REVIEW_DIMENSION_FALLBACK_LABEL) {
const dimensionValue = resolveMayReviewDimensionFilter(value, fallbackLabel);
if (!dimensionValue) {
return '';
}
whereParts.push(`COALESCE(NULLIF(TRIM(${columnExpression}), ''), ?) = ?`);
params.push(fallbackLabel, dimensionValue);
return dimensionValue;
}
function resolveMayReviewMissingMetricFilter(value) {
const normalized = normalizeText(value);
if (!normalized || MAY_INVALID_DIMENSION_VALUES.has(normalized)) {
return '';
}
return normalized;
}
function appendMayReviewMissingMetricWhere(whereParts, params, value) {
const missingMetric = resolveMayReviewMissingMetricFilter(value);
if (!missingMetric) {
return '';
}
whereParts.push(`
FIND_IN_SET(
?,
REPLACE(REPLACE(REPLACE(COALESCE(r.missing_metrics, ''), ',', ','), '、', ','), ' ', '')
) > 0
`);
params.push(missingMetric.replace(/\s+/g, ''));
return missingMetric;
}
function resolveMayReviewInventoryScopeFilter(value) {
const normalized = normalizeText(value);
if (!normalized) {
return '';
}
return Object.values(MAY_INVENTORY_SCOPE_LABELS).includes(normalized) ? normalized : '';
}
function splitMayInventoryScopeRemark(value) {
return normalizeText(value)
.split(/[、,,]/u)
.map((item) => item.trim())
.filter((item) => Object.values(MAY_INVENTORY_SCOPE_LABELS).includes(item));
}
function appendMayReviewInventoryScopeWhere(whereParts, params, value) {
const inventoryScope = resolveMayReviewInventoryScopeFilter(value);
if (!inventoryScope) {
return '';
}
whereParts.push(`
FIND_IN_SET(
?,
REPLACE(REPLACE(REPLACE(COALESCE(s.may_inventory_scope_remark, ''), ',', ','), '、', ','), ' ', '')
) > 0
`);
params.push(inventoryScope.replace(/\s+/g, ''));
return inventoryScope;
}
function resolveMayPlanningBrandGroupFilter(value) {
const normalized = normalizeText(value);
if (!normalized) {
return '';
}
const normalizedLower = normalized.toLowerCase();
const matchedGroup = MAY_PLANNING_BRAND_GROUPS.find((group) => (
group.key === normalizedLower || group.label === normalized
));
return matchedGroup?.key || '';
}
function resolveMayPlanningBrandNameFilter(value) {
const normalized = normalizeText(value);
if (!normalized) {
return '';
}
const matchedBrand = MAY_PLANNING_BRAND_DEFINITIONS.find((brand) => (
brand.name === normalized || brand.aliases.includes(normalized)
));
return matchedBrand?.name || '';
}
function getMayPlanningBrandDefinition(name) {
const brandName = resolveMayPlanningBrandNameFilter(name);
if (!brandName) {
return null;
}
return MAY_PLANNING_BRAND_DEFINITIONS.find((brand) => brand.name === brandName) || null;
}
function getMayPlanningBrandDefinitionsByFilter(options = {}) {
const brandName = resolveMayPlanningBrandNameFilter(options.brandName || options.brand || options.brand_name);
if (brandName) {
const brand = getMayPlanningBrandDefinition(brandName);
return brand ? [brand] : [];
}
const brandGroup = resolveMayPlanningBrandGroupFilter(options.brandGroup || options.brand_group);
if (brandGroup) {
return MAY_PLANNING_BRAND_DEFINITIONS.filter((brand) => brand.groupKey === brandGroup);
}
return MAY_PLANNING_BRAND_DEFINITIONS;
}
function buildMayBrandWhereClause(columnExpression, brandDefinitions) {
const aliases = Array.from(new Set(
brandDefinitions
.flatMap((brand) => brand.aliases)
.map(normalizeText)
.filter(Boolean)
));
if (aliases.length === 0) {
return { sql: '', params: [] };
}
return {
sql: `(${aliases.map(() => `${columnExpression} LIKE ?`).join(' OR ')})`,
params: aliases.map((alias) => `%${alias}%`),
};
}
function appendMayPlanningBrandWhere(whereParts, params, options = {}) {
const brandName = resolveMayPlanningBrandNameFilter(options.brandName || options.brand || options.brand_name);
const brandGroup = resolveMayPlanningBrandGroupFilter(options.brandGroup || options.brand_group);
const brandDefinitions = getMayPlanningBrandDefinitionsByFilter({ brandName, brandGroup });
if (brandDefinitions.length === 0 || (!brandName && !brandGroup)) {
return { brandName, brandGroup };
}
const brandWhere = buildMayBrandWhereClause('COALESCE(s.may_covered_channels, \'\')', brandDefinitions);
if (brandWhere.sql) {
whereParts.push(brandWhere.sql);
params.push(...brandWhere.params);
}
return { brandName, brandGroup };
}
function getMayPlanningBrandMatchesFromText(value, options = {}) {
const text = normalizeText(value);
if (!text) {
return [];
}
return getMayPlanningBrandDefinitionsByFilter(options).filter((brand) => (
brand.aliases.some((alias) => alias && text.includes(alias))
));
}
function createEmptyMayInventorySummary() {
return {
stockQty: 0,
realCompanyStockQty: 0,
dropShipStockQty: 0,
factoryStockQty: 0,
warehouses: [],
inventoryScopeLabels: [],
};
}
function sortMayInventoryScopeLabels(labels = []) {
const order = [
MAY_INVENTORY_SCOPE_LABELS.realCompany,
MAY_INVENTORY_SCOPE_LABELS.dropShip,
MAY_INVENTORY_SCOPE_LABELS.factory,
];
return Array.from(new Set(labels.filter(Boolean))).sort((left, right) => order.indexOf(left) - order.indexOf(right));
}
function buildMayInventoryScopeRemark(labels = []) {
const normalizedLabels = sortMayInventoryScopeLabels(labels);
return normalizedLabels.length > 0 ? normalizedLabels.join('、') : '';
}
function classifyMayInventoryScope(warehouseName = '', warehouseRemark2 = '') {
const normalizedWarehouseName = normalizeText(warehouseName);
const normalizedRemark2 = normalizeText(warehouseRemark2);
const isIncludedRealCompanyRemark = MAY_REAL_COMPANY_WAREHOUSE_INCLUDED_REMARKS
.some((keyword) => normalizedRemark2.includes(keyword));
const isExcludedRealCompanyRemark = MAY_REAL_COMPANY_WAREHOUSE_EXCLUDED_REMARKS
.some((keyword) => normalizedRemark2.includes(keyword));
if (isIncludedRealCompanyRemark && !isExcludedRealCompanyRemark) {
return MAY_INVENTORY_SCOPE_LABELS.realCompany;
}
if (normalizedWarehouseName.startsWith('工厂-')) {
return MAY_INVENTORY_SCOPE_LABELS.factory;
}
return MAY_INVENTORY_SCOPE_LABELS.dropShip;
}
function toNumber(value, fallback = 0) {
if (value === undefined || value === null || value === '') {
return fallback;
}
const parsed = Number(String(value).replace(/[,,]/g, ''));
return Number.isFinite(parsed) ? parsed : fallback;
}
function toNullableNumber(value, digits = 2) {
const parsed = Number(String(value ?? '').replace(/[,,]/g, ''));
if (!Number.isFinite(parsed)) {
return null;
}
return roundNumber(parsed, digits);
}
function roundNumber(value, digits = 2) {
const numericValue = Number(value);
if (!Number.isFinite(numericValue)) {
return 0;
}
const factor = 10 ** digits;
return Math.round(numericValue * factor) / factor;
}
function formatNumberCell(value, digits = 2, fallback = MAY_EMPTY_VALUE) {
if (!Number.isFinite(Number(value))) {
return fallback;
}
return roundNumber(Number(value), digits);
}
function formatPercentCell(value, digits = 1) {
if (!Number.isFinite(Number(value))) {
return MAY_EMPTY_VALUE;
}
return `${roundNumber(Number(value), digits)}%`;
}
function firstMeaningfulText(...values) {
for (const value of values) {
const normalized = normalizeText(value);
if (
normalized
&& !['-', '--', '—', 'null', 'NULL', 'undefined', '未配置', '暂无', '无'].includes(normalized)
) {
return normalized;
}
}
return '';
}
function normalizeMayDimensionValue(value) {
const normalized = normalizeText(value);
if (!normalized || MAY_INVALID_DIMENSION_VALUES.has(normalized)) {
return '';
}
return normalized;
}
function normalizeMayCategoryValue(value) {
const normalized = normalizeMayDimensionValue(value);
if (!normalized) {
return '';
}
let categoryName = normalized;
const dashParts = categoryName.split(/\s*-\s*/).map((item) => normalizeText(item)).filter(Boolean);
if (dashParts.length > 1) {
categoryName = dashParts[dashParts.length - 1];
}
const pathParts = categoryName.split(/\s*>\s*/).map((item) => normalizeText(item)).filter(Boolean);
if (pathParts.length > 1) {
categoryName = pathParts[pathParts.length - 1];
}
if (MAY_CATEGORY_OTHER_TERMS.some((term) => categoryName.includes(term))) {
return '';
}
return normalizeMayDimensionValue(categoryName);
}
function resolveMayCategoryName(...values) {
for (const value of values) {
const normalized = normalizeMayCategoryValue(value);
if (normalized) {
return normalized;
}
}
return MAY_REVIEW_DIMENSION_FALLBACK_LABEL;
}
function resolveMaySupplierName(...values) {
for (const value of values) {
const normalized = normalizeMayDimensionValue(value);
if (normalized) {
return normalized;
}
}
return MAY_REVIEW_DIMENSION_FALLBACK_LABEL;
}
function inferMayCategoryNameFromText(...values) {
const combinedText = values
.map((value) => normalizeText(value))
.filter(Boolean)
.join(' ');
if (!combinedText) {
return '';
}
const matchedRule = MAY_CATEGORY_NAME_INFERENCE_RULES.find((rule) => (
rule.keywords.some((keyword) => combinedText.includes(keyword))
));
return matchedRule?.category || '';
}
function normalizeMaySaleStatus(value) {
return normalizeText(value);
}
function resolveMaySaleStatus(...values) {
for (const value of values) {
const normalized = normalizeMaySaleStatus(value);
if (
normalized
&& !['-', '--', '—', 'null', 'NULL', 'undefined', '暂无', '无'].includes(normalized)
) {
return normalized;
}
}
return '';
}
function isBusinessSkuMapSourceTable(sourceTable) {
const normalized = normalizeText(sourceTable);
return BUSINESS_SKU_MAP_TABLES.some((tableName) => normalized === `dbo.${tableName}`);
}
function isAuthoritativeMaySaleStatusSource(sourceTable) {
const normalized = normalizeText(sourceTable);
return normalized === `dbo.${BUSINESS_SKU_TABLE}`;
}
function getMaySaleStatusPriority(value) {
const normalized = normalizeMaySaleStatus(value);
if (!normalized) {
return 99;
}
if (normalized === '销售') {
return 20;
}
if (normalized === '未配置') {
return 30;
}
return 0;
}
function getMaySaleStatusSourceRank(sourceTable) {
const normalized = normalizeText(sourceTable);
if (normalized === `dbo.${BUSINESS_SKU_TABLE}`) {
return 0;
}
const skuMapIndex = BUSINESS_SKU_MAP_TABLES.findIndex((tableName) => normalized === `dbo.${tableName}`);
if (skuMapIndex >= 0) {
return 1 + skuMapIndex;
}
if (normalized === BUSINESS_SKU_DIMENSION_TABLE) {
return 5;
}
if (normalized === `dbo.${BUSINESS_SKU_LIBRARY_TABLE}`) {
return 6;
}
return 9;
}
function toTimestamp(value) {
if (!value) {
return 0;
}
const timestamp = new Date(value).getTime();
return Number.isFinite(timestamp) ? timestamp : 0;
}
function shouldReplaceMaySaleStatus(profile = {}, row = {}) {
const nextStatus = normalizeMaySaleStatus(row.saleStatus);
if (!nextStatus || !isAuthoritativeMaySaleStatusSource(row.sourceTable)) {
return false;
}
const currentStatus = normalizeMaySaleStatus(profile.saleStatus);
if (!currentStatus) {
return true;
}
const currentPriority = Number.isFinite(profile.saleStatusPriority)
? profile.saleStatusPriority
: getMaySaleStatusPriority(currentStatus);
const nextPriority = getMaySaleStatusPriority(nextStatus);
if (nextPriority !== currentPriority) {
return nextPriority < currentPriority;
}
const currentSourceRank = Number.isFinite(profile.saleStatusSourceRank)
? profile.saleStatusSourceRank
: getMaySaleStatusSourceRank(profile.saleStatusSourceTable || profile.sourceTable);
const nextSourceRank = getMaySaleStatusSourceRank(row.sourceTable);
if (nextSourceRank !== currentSourceRank) {
return nextSourceRank < currentSourceRank;
}
return toTimestamp(row.createdAt) > toTimestamp(profile.saleStatusCreatedAt);
}
function mergeMayBusinessProfile(currentProfile = null, row = {}) {
const profile = currentProfile ? { ...currentProfile } : {
productCode: '',
skuName: '',
skuSize: '',
material: '',
colorName: '',
productCost: null,
salePrice: null,
saleStatus: '',
virtualCategory: '',
categoryName: '',
supplierName: '',
skuImageUrl: '',
skuLength: null,
skuWidth: null,
skuHeight: null,
createdAt: null,
sourceTable: '',
sourceRowCount: 0,
saleStatusPriority: 99,
saleStatusSourceRank: 99,
saleStatusSourceTable: '',
saleStatusCreatedAt: null,
};
profile.productCode = firstMeaningfulText(profile.productCode, row.productCode);
profile.skuName = firstMeaningfulText(profile.skuName, row.skuName);
profile.skuSize = firstMeaningfulText(profile.skuSize, row.skuSize);
profile.material = firstMeaningfulText(profile.material, row.material);
profile.colorName = firstMeaningfulText(profile.colorName, row.colorName);
if (shouldReplaceMaySaleStatus(profile, row)) {
profile.saleStatus = normalizeMaySaleStatus(row.saleStatus);
profile.saleStatusPriority = getMaySaleStatusPriority(profile.saleStatus);
profile.saleStatusSourceRank = getMaySaleStatusSourceRank(row.sourceTable);
profile.saleStatusSourceTable = normalizeText(row.sourceTable);
profile.saleStatusCreatedAt = row.createdAt || null;
}
profile.virtualCategory = resolveMayVirtualCategory(profile.virtualCategory)
|| resolveMayVirtualCategory(row.virtualCategory);
profile.categoryName = normalizeMayCategoryValue(profile.categoryName) || normalizeMayCategoryValue(row.categoryName);
profile.supplierName = normalizeMayDimensionValue(profile.supplierName) || normalizeMayDimensionValue(row.supplierName);
profile.skuImageUrl = firstMeaningfulText(profile.skuImageUrl, row.skuImageUrl);
profile.sourceTable = firstMeaningfulText(profile.sourceTable, row.sourceTable);
profile.sourceRowCount = Math.max(Number(profile.sourceRowCount || 0), Number(row.sourceRowCount || 0), 1);
if (profile.productCost === null) {
profile.productCost = toNullableNumber(row.productCost, 2);
}
if (profile.salePrice === null) {
profile.salePrice = toNullableNumber(row.salePrice, 2);
}
if (profile.skuLength === null) {
profile.skuLength = toNullableNumber(row.skuLength, 2);
}
if (profile.skuWidth === null) {
profile.skuWidth = toNullableNumber(row.skuWidth, 2);
}
if (profile.skuHeight === null) {
profile.skuHeight = toNullableNumber(row.skuHeight, 2);
}
if (!profile.createdAt && row.createdAt) {
profile.createdAt = row.createdAt;
}
return profile;
}
function chunkArray(list = [], size = 200) {
const chunks = [];
for (let index = 0; index < list.length; index += size) {
chunks.push(list.slice(index, index + size));
}
return chunks;
}
function withFallback(promise, fallback, label, timeoutMs = OPTIONAL_ENRICHMENT_TIMEOUT_MS) {
let timeoutId;
const timeoutPromise = new Promise((resolve) => {
timeoutId = setTimeout(() => {
console.warn(`[PlanningMayProductData] ${label}超过${timeoutMs}ms,先返回默认值`);
resolve(fallback);
}, timeoutMs);
});
const guardedPromise = Promise.resolve(promise)
.then((value) => {
clearTimeout(timeoutId);
return value;
})
.catch((error) => {
clearTimeout(timeoutId);
console.warn(`[PlanningMayProductData] ${label}失败:`, error.message || error);
return fallback;
});
return Promise.race([guardedPromise, timeoutPromise]);
}
function escapeIdentifier(value) {
return `\`${String(value).replace(/`/g, '``')}\``;
}
function escapeSqlString(value) {
return String(value || '').replace(/\\/g, '\\\\').replace(/'/g, "\\'");
}
async function ensurePlanningTableColumn(tableName, columnName, columnDefinition) {
const rows = await planningDatabase.query(
`SHOW COLUMNS FROM ${escapeIdentifier(tableName)} LIKE ?`,
[columnName]
);
if (Array.isArray(rows) && rows.length > 0) {
return;
}
await planningDatabase.execute(
`ALTER TABLE ${escapeIdentifier(tableName)} ADD COLUMN ${escapeIdentifier(columnName)} ${columnDefinition}`
);
}
async function getPlanningTableIndexColumns(tableName, indexName) {
const rows = await planningDatabase.query(
`SELECT column_name AS columnName
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = ?
AND index_name = ?
ORDER BY seq_in_index ASC`,
[tableName, indexName]
);
return rows.map((row) => normalizeText(row.columnName)).filter(Boolean);
}
async function ensurePlanningTableIndexDefinition(tableName, indexName, columnNames = [], definition, options = {}) {
const expectedColumns = columnNames.map((columnName) => normalizeText(columnName)).filter(Boolean);
const existingColumns = await getPlanningTableIndexColumns(tableName, indexName);
const sameDefinition = expectedColumns.length > 0
&& existingColumns.length === expectedColumns.length
&& existingColumns.every((columnName, index) => columnName === expectedColumns[index]);
if (sameDefinition) {
return;
}
if (existingColumns.length > 0) {
await planningDatabase.execute(
`ALTER TABLE ${escapeIdentifier(tableName)} DROP INDEX ${escapeIdentifier(indexName)}`
);
}
const keyword = options.unique ? 'UNIQUE KEY' : 'INDEX';
await planningDatabase.execute(
`ALTER TABLE ${escapeIdentifier(tableName)} ADD ${keyword} ${escapeIdentifier(indexName)} ${definition}`
);
}
function normalizeChannelName(value) {
return normalizeText(value).toLowerCase();
}
function resolveMayChannelRatioKey(channelName) {
const normalized = normalizeChannelName(channelName);
if (!normalized) {
return 'other';
}
const matched = MAY_CHANNEL_RATIO_FIELDS.find((field) => (
field.key !== 'other'
&& (
normalized === field.key
|| normalized.includes(field.key)
|| field.keywords.some((keyword) => normalized.includes(String(keyword).toLowerCase()))
)
));
return matched?.key || 'other';
}
function createChannelAmountMap() {
return new Map(MAY_CHANNEL_RATIO_FIELDS.map((field) => [field.key, 0]));
}
function parseChannelSalesAmounts(value) {
const channelAmounts = createChannelAmountMap();
const rawText = normalizeText(value);
if (!rawText) {
return channelAmounts;
}
rawText.split('|').forEach((part) => {
const [name, amountText] = String(part || '').split('=');
const amount = toNumber(amountText, 0);
if (!name || amount <= 0) {
return;
}
const key = resolveMayChannelRatioKey(name);
channelAmounts.set(key, (channelAmounts.get(key) || 0) + amount);
});
return channelAmounts;
}
function addChannelAmountMap(targetMap, sourceMap) {
MAY_CHANNEL_RATIO_FIELDS.forEach((field) => {
targetMap.set(field.key, (targetMap.get(field.key) || 0) + (sourceMap.get(field.key) || 0));
});
}
function buildChannelSalesRatios(channelAmounts, totalAmount) {
const channelTotal = MAY_CHANNEL_RATIO_FIELDS.reduce((sum, field) => {
const amount = toNumber(channelAmounts?.get(field.key), 0);
return amount > 0 ? sum + amount : sum;
}, 0);
const total = channelTotal > 0 ? channelTotal : toNumber(totalAmount, 0);
const result = {};
MAY_CHANNEL_RATIO_FIELDS.forEach((field) => {
const amount = channelAmounts?.get(field.key) || 0;
const ratio = total > 0 && amount > 0 ? roundNumber((amount / total) * 100, 2) : 0;
result[field.key] = Math.min(Math.max(ratio, 0), 100);
});
return result;
}
function splitChannelNames(value) {
return normalizeText(value)
.split(/[、,,|]/u)
.map((item) => normalizeText(item))
.filter(Boolean);
}
function getLocalChannelAmountAlias(field) {
return `channelAmount_${field.key}`;
}
function buildChannelSalesAmountsTextFromRow(row = {}) {
return MAY_CHANNEL_RATIO_FIELDS
.map((field) => {
const amount = toNumber(row[getLocalChannelAmountAlias(field)], 0);
if (amount <= 0) {
return '';
}
return `${field.key}=${roundNumber(amount, 2)}`;
})
.filter(Boolean)
.join('|');
}
const LOCAL_CHANNEL_MATCH_SQL = Object.freeze({
'1688': "(LOWER(COALESCE(d.shop_name, '')) LIKE '%1688%' OR COALESCE(d.shop_name, '') LIKE '%阿里巴巴%')",
taobao: "(COALESCE(d.shop_name, '') LIKE '%淘宝%')",
tmall: "(LOWER(COALESCE(d.shop_name, '')) LIKE '%tmall%' OR COALESCE(d.shop_name, '') LIKE '%天猫%')",
pdd: "(LOWER(COALESCE(d.shop_name, '')) LIKE '%pdd%' OR COALESCE(d.shop_name, '') LIKE '%拼多多%' OR COALESCE(d.shop_name, '') LIKE '%多多%')",
jd: "(LOWER(COALESCE(d.shop_name, '')) LIKE '%jd%' OR COALESCE(d.shop_name, '') LIKE '%京东%' OR COALESCE(d.shop_name, '') LIKE '%京喜%')",
douyin: "(LOWER(COALESCE(d.shop_name, '')) LIKE '%douyin%' OR COALESCE(d.shop_name, '') LIKE '%抖音%')",
vip: "(LOWER(COALESCE(d.shop_name, '')) LIKE '%vip%' OR COALESCE(d.shop_name, '') LIKE '%唯品会%')",
});
const LOCAL_CHANNEL_OTHER_MATCH_SQL = `NOT (${Object.values(LOCAL_CHANNEL_MATCH_SQL).join(' OR ')})`;
async function ensurePlanningMayProductSnapshotSchema() {
if (snapshotSchemaState.ready) {
return;
}
if (snapshotSchemaState.pending) {
await snapshotSchemaState.pending;
return;
}
snapshotSchemaState.pending = (async () => {
await planningDatabase.execute(
`CREATE TABLE IF NOT EXISTS ${escapeIdentifier(MAY_PRODUCT_SNAPSHOT_TABLE)} (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
stat_date DATE NOT NULL COMMENT '快照业务日期',
may_sku_code VARCHAR(100) NOT NULL COMMENT 'MAY SKU编码',
may_virtual_category VARCHAR(50) NULL COMMENT 'MAY OEM/ODM模式',
product_code VARCHAR(100) NULL COMMENT '商品货号',
spu_code VARCHAR(100) NULL COMMENT 'SPU编码',
sku_name VARCHAR(255) NULL COMMENT 'SKU名称',
product_name VARCHAR(255) NULL COMMENT '商品名称',
category_name VARCHAR(255) NULL COMMENT '类目名称',
supplier_name VARCHAR(255) NULL COMMENT '供应商',
product_cost DECIMAL(18,2) NULL COMMENT '商品成本',
source_image_feature_id BIGINT NULL COMMENT '图片特征库记录ID',
may_appearance_recognition_code VARCHAR(160) NULL COMMENT 'MAY外观识别编码',
may_sku_image_url VARCHAR(1000) NULL COMMENT 'MAY SKU图片',
may_sales_status VARCHAR(120) NULL COMMENT 'MAY销售状态',
may_core_material VARCHAR(255) NULL COMMENT 'MAY核心材质',
may_min_limit_price DECIMAL(18,2) NULL COMMENT 'MAY最低限价',
may_size VARCHAR(255) NULL COMMENT 'MAY尺寸',
may_color_name VARCHAR(255) NULL COMMENT 'MAY颜色名称',
may_performance_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT 'MAY近30天业绩',
may_goods_id_count INT NOT NULL DEFAULT 0 COMMENT 'MAY近30天ID数量',
may_covered_channels VARCHAR(500) NULL COMMENT 'MAY ID覆盖渠道',
${MAY_CHANNEL_RATIO_FIELDS.map((field) => `${escapeIdentifier(field.snapshotColumn)} DECIMAL(10,4) NOT NULL DEFAULT 0 COMMENT '${escapeSqlString(field.label)}'`).join(',\n ')},
may_promotion_fee_30d DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT 'MAY近30天推广费用',
may_d_style_efficiency DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT 'MAY D款效',
may_inventory_quantity DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT 'MAY库存',
may_real_company_inventory_quantity DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT 'MAY真实公司库存',
may_estimated_sales_days DECIMAL(18,2) NULL COMMENT 'MAY预计销售时长',
may_shipping_warehouse TEXT NULL COMMENT 'MAY发货仓库',
may_inventory_scope_remark VARCHAR(255) NULL COMMENT 'MAY库存备注',
may_avg_daily_sales_30d DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT 'MAY近30天平均日销',
may_avg_daily_sales_7d DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT 'MAY近7天平均日销',
may_sales_growth_rate_15d DECIMAL(10,4) NULL COMMENT 'MAY近15天环比增长率(%)',
may_listing_duration_days INT NULL COMMENT 'MAY铺货时长(天)',
may_gross_margin_rate DECIMAL(10,4) NULL COMMENT 'MAY销售毛利率(%)',
may_after_sales_ratio DECIMAL(10,4) NULL COMMENT 'MAY售后占比(%)',
may_active_sales_day_ratio_30d DECIMAL(10,4) NULL COMMENT 'MAY近30天动销天数占比(%)',
source_latest_biz_date DATE NULL COMMENT '渠道读库最新业务日期',
source_row_count INT NOT NULL DEFAULT 0 COMMENT '源SKU总数',
calc_version VARCHAR(64) NOT NULL COMMENT '计算口径版本',
snapshot_batch_id VARCHAR(64) NOT NULL COMMENT '快照批次ID',
generated_at DATETIME NOT NULL COMMENT '生成时间',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
UNIQUE KEY uk_planning_may_product_snapshot (stat_date, snapshot_batch_id, may_sku_code),
INDEX idx_planning_may_product_snapshot_sku (may_sku_code),
INDEX idx_planning_may_product_snapshot_generated (generated_at),
INDEX idx_planning_may_product_snapshot_batch (snapshot_batch_id),
INDEX idx_planning_may_product_snapshot_appearance (may_appearance_recognition_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='企划MAY商品数据日快照宽表'`
);
await planningDatabase.execute(
`CREATE TABLE IF NOT EXISTS ${escapeIdentifier(MAY_PRODUCT_SNAPSHOT_SYNC_LOG_TABLE)} (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
batch_id VARCHAR(64) NOT NULL COMMENT '快照批次ID',
stat_date DATE NULL COMMENT '快照业务日期',
source_latest_biz_date DATE NULL COMMENT '渠道读库最新业务日期',
source_row_count INT NOT NULL DEFAULT 0 COMMENT '源SKU总数',
snapshot_row_count INT NOT NULL DEFAULT 0 COMMENT '写入快照行数',
progress_pct DECIMAL(10,4) NOT NULL DEFAULT 0 COMMENT '刷新进度百分比',
status ENUM('running', 'success', 'failed') NOT NULL DEFAULT 'running' COMMENT '刷新状态',
calc_version VARCHAR(64) NOT NULL COMMENT '计算口径版本',
duration_ms INT NULL COMMENT '耗时毫秒',
error_message VARCHAR(2000) NULL COMMENT '错误信息',
started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '开始时间',
heartbeat_at DATETIME NULL COMMENT '最近心跳时间',
finished_at DATETIME NULL COMMENT '结束时间',
INDEX idx_planning_may_product_snapshot_log_date (stat_date),
INDEX idx_planning_may_product_snapshot_log_status (status),
INDEX idx_planning_may_product_snapshot_log_batch (batch_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='企划MAY商品数据快照刷新日志'`
);
await planningDatabase.execute(
`ALTER TABLE ${escapeIdentifier(MAY_PRODUCT_SNAPSHOT_TABLE)}
MODIFY COLUMN may_shipping_warehouse TEXT NULL COMMENT 'MAY发货仓库'`
);
await ensurePlanningTableColumn(
MAY_PRODUCT_SNAPSHOT_TABLE,
'may_virtual_category',
"VARCHAR(50) NULL COMMENT 'MAY OEM/ODM模式'"
);
await ensurePlanningTableColumn(
MAY_PRODUCT_SNAPSHOT_TABLE,
'may_real_company_inventory_quantity',
"DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT 'MAY真实公司库存'"
);
await ensurePlanningTableColumn(
MAY_PRODUCT_SNAPSHOT_TABLE,
'may_inventory_scope_remark',
"VARCHAR(255) NULL COMMENT 'MAY库存备注'"
);
await ensurePlanningTableColumn(
MAY_PRODUCT_SNAPSHOT_SYNC_LOG_TABLE,
'progress_pct',
"DECIMAL(10,4) NOT NULL DEFAULT 0 COMMENT '刷新进度百分比'"
);
await ensurePlanningTableColumn(
MAY_PRODUCT_SNAPSHOT_SYNC_LOG_TABLE,
'heartbeat_at',
"DATETIME NULL COMMENT '最近心跳时间'"
);
await ensurePlanningTableIndexDefinition(
MAY_PRODUCT_SNAPSHOT_TABLE,
'uk_planning_may_product_snapshot',
['stat_date', 'snapshot_batch_id', 'may_sku_code'],
'(stat_date, snapshot_batch_id, may_sku_code)',
{ unique: true }
);
await ensurePlanningTableIndexDefinition(
MAY_PRODUCT_SNAPSHOT_TABLE,
'idx_planning_may_product_snapshot_stat_batch',
['stat_date', 'snapshot_batch_id', 'may_sku_code'],
'(stat_date, snapshot_batch_id, may_sku_code)'
);
await ensurePlanningTableIndexDefinition(
MAY_PRODUCT_SNAPSHOT_SYNC_LOG_TABLE,
'idx_planning_may_product_snapshot_log_status_date',
['status', 'stat_date', 'finished_at'],
'(status, stat_date, finished_at)'
);
await ensurePlanningTableIndexDefinition(
MAY_PRODUCT_SNAPSHOT_TABLE,
'idx_planning_may_product_snapshot_virtual_category',
['stat_date', 'snapshot_batch_id', 'may_virtual_category', 'may_sales_status'],
'(stat_date, snapshot_batch_id, may_virtual_category, may_sales_status)'
);
for (const field of MAY_CHANNEL_RATIO_FIELDS) {
await ensurePlanningTableColumn(
MAY_PRODUCT_SNAPSHOT_TABLE,
field.snapshotColumn,
`DECIMAL(10,4) NOT NULL DEFAULT 0 COMMENT '${escapeSqlString(field.label)}'`
);
}
snapshotSchemaState.ready = true;
})();
try {
await snapshotSchemaState.pending;
} finally {
snapshotSchemaState.pending = null;
}
}
async function ensurePlanningMayProductReviewSnapshotSchema() {
if (reviewSnapshotSchemaState.ready) {
return;
}
if (reviewSnapshotSchemaState.pending) {
await reviewSnapshotSchemaState.pending;
return;
}
reviewSnapshotSchemaState.pending = (async () => {
await planningDatabase.execute(
`CREATE TABLE IF NOT EXISTS ${escapeIdentifier(MAY_PRODUCT_REVIEW_SNAPSHOT_TABLE)} (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
stat_date DATE NOT NULL COMMENT '复盘业务日期',
snapshot_batch_id VARCHAR(64) NOT NULL COMMENT '来源MAY快照批次ID',
may_sku_code VARCHAR(100) NOT NULL COMMENT 'MAY SKU编码',
category_name VARCHAR(255) NULL COMMENT '类目名称',
review_action VARCHAR(64) NOT NULL COMMENT '复盘结论',
review_tags TEXT NULL COMMENT '命中标签',
review_reasons TEXT NULL COMMENT '命中原因',
missing_metrics TEXT NULL COMMENT '缺失指标',
sales_qty_30d DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '近30天销量',
sales_qty_7d DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '近7天销量',
avg_daily_sales_30d DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '近30天日销',
short_term_growth_rate DECIMAL(10,4) NULL COMMENT '短期增长率',
short_term_growth_basis VARCHAR(120) NULL COMMENT '增长口径',
sales_7d_vs_30d_ratio DECIMAL(18,4) NULL COMMENT '近7日/近30日日销',
inventory_quantity DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '库存',
stock_turnover_days DECIMAL(18,2) NULL COMMENT '库存周转天数',
listing_duration_days INT NULL COMMENT '铺货时长',
gross_margin_rate DECIMAL(10,4) NULL COMMENT '毛利率',
gross_profit_30d DECIMAL(18,2) NULL COMMENT '近30天毛利额',
promotion_roi DECIMAL(18,4) NULL COMMENT '推广ROI',
active_sales_day_ratio_30d DECIMAL(10,4) NULL COMMENT '动销率',
category_avg_daily_sales_30d DECIMAL(18,4) NULL COMMENT '同类近30天平均日销',
generated_at DATETIME NOT NULL COMMENT '生成时间',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
UNIQUE KEY uk_planning_may_product_review_snapshot (stat_date, snapshot_batch_id, may_sku_code),
INDEX idx_planning_may_product_review_action (stat_date, snapshot_batch_id, review_action),
INDEX idx_planning_may_product_review_sku (may_sku_code),
INDEX idx_planning_may_product_review_generated (generated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='企划MAY商品复盘规则快照表'`
);
reviewSnapshotSchemaState.ready = true;
})();
try {
await reviewSnapshotSchemaState.pending;
} finally {
reviewSnapshotSchemaState.pending = null;
}
}
async function ensurePlanningMaySalesStageSchema() {
if (salesStageSchemaState.ready) {
return;
}
if (salesStageSchemaState.pending) {
await salesStageSchemaState.pending;
return;
}
salesStageSchemaState.pending = (async () => {
await planningDatabase.execute(
`CREATE TABLE IF NOT EXISTS ${escapeIdentifier(MAY_PRODUCT_SALES_SUMMARY_STAGE_TABLE)} (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
stat_date DATE NOT NULL COMMENT '销售汇总业务日期',
may_sku_code VARCHAR(100) NOT NULL COMMENT 'MAY SKU编码',
source_sales_sku_count INT NOT NULL DEFAULT 0 COMMENT '匹配到的销售SKU数量',
source_sales_sku_codes TEXT NULL COMMENT '匹配到的销售SKU清单',
match_mode_summary VARCHAR(255) NULL COMMENT '映射口径摘要',
performance_amount_30d DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '近30天业绩',
transaction_amount_30d DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '近30天销售额',
sales_qty_30d DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '近30天销售件数',
sales_qty_7d DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '近7天销售件数',
sales_qty_last15d DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '近15天销售件数',
sales_qty_previous15d DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '前15天销售件数',
after_sale_amount_30d DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '近30天售后金额',
gross_cost_amount_30d DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '近30天毛利成本',
promotion_fee_30d DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '近30天推广费用',
goods_id_count_30d INT NOT NULL DEFAULT 0 COMMENT '近30天ID数量',
covered_channels TEXT NULL COMMENT '覆盖渠道',
${MAY_CHANNEL_RATIO_FIELDS.map((field) => `${escapeIdentifier(field.stageColumn)} DECIMAL(10,4) NOT NULL DEFAULT 0 COMMENT '${escapeSqlString(field.label)}'`).join(',\n ')},
active_sales_days_30d INT NOT NULL DEFAULT 0 COMMENT '近30天动销天数',
latest_biz_date DATE NULL COMMENT '销售源最新业务日期',
calc_version VARCHAR(64) NOT NULL DEFAULT '' COMMENT '计算口径版本',
refreshed_at DATETIME NOT NULL COMMENT '刷新时间',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
UNIQUE KEY uk_planning_may_sales_summary (stat_date, may_sku_code),
INDEX idx_planning_may_sales_summary_sku (may_sku_code),
INDEX idx_planning_may_sales_summary_latest (latest_biz_date),
INDEX idx_planning_may_sales_summary_refreshed (refreshed_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='企划MAY销售SKU预计算汇总表'`
);
await planningDatabase.execute(
`CREATE TABLE IF NOT EXISTS ${escapeIdentifier(MAY_PRODUCT_SALES_MAPPING_STAGE_TABLE)} (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
stat_date DATE NOT NULL COMMENT '销售汇总业务日期',
sales_sku_code VARCHAR(100) NOT NULL COMMENT '销售源SKU编码',
may_sku_code VARCHAR(100) NOT NULL COMMENT '匹配后的MAY SKU编码',
match_mode VARCHAR(50) NOT NULL COMMENT '匹配方式',
source_table VARCHAR(120) NOT NULL COMMENT '匹配来源表',
match_priority INT NOT NULL DEFAULT 0 COMMENT '匹配优先级',
source_row_count INT NOT NULL DEFAULT 1 COMMENT '来源匹配行数',
calc_version VARCHAR(64) NOT NULL DEFAULT '' COMMENT '计算口径版本',
refreshed_at DATETIME NOT NULL COMMENT '刷新时间',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
UNIQUE KEY uk_planning_may_sales_mapping (stat_date, sales_sku_code, may_sku_code, match_mode),
INDEX idx_planning_may_sales_mapping_may_sku (may_sku_code),
INDEX idx_planning_may_sales_mapping_sales_sku (sales_sku_code),
INDEX idx_planning_may_sales_mapping_refreshed (refreshed_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='企划MAY销售SKU映射预计算表'`
);
for (const field of MAY_CHANNEL_RATIO_FIELDS) {
await ensurePlanningTableColumn(
MAY_PRODUCT_SALES_SUMMARY_STAGE_TABLE,
field.stageColumn,
`DECIMAL(10,4) NOT NULL DEFAULT 0 COMMENT '${escapeSqlString(field.label)}'`
);
}
await ensurePlanningTableColumn(
MAY_PRODUCT_SALES_SUMMARY_STAGE_TABLE,
'calc_version',
"VARCHAR(64) NOT NULL DEFAULT '' COMMENT '计算口径版本'"
);
await ensurePlanningTableColumn(
MAY_PRODUCT_SALES_MAPPING_STAGE_TABLE,
'calc_version',
"VARCHAR(64) NOT NULL DEFAULT '' COMMENT '计算口径版本'"
);
salesStageSchemaState.ready = true;
})();
try {
await salesStageSchemaState.pending;
} finally {
salesStageSchemaState.pending = null;
}
}
async function ensurePlanningMayProductProfileStageSchema() {
if (profileStageSchemaState.ready) {
return;
}
if (profileStageSchemaState.pending) {
await profileStageSchemaState.pending;
return;
}
profileStageSchemaState.pending = (async () => {
const createProfileStageSql = `CREATE TABLE IF NOT EXISTS ${escapeIdentifier(MAY_PRODUCT_PROFILE_STAGE_TABLE)} (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
may_sku_code VARCHAR(100) NOT NULL COMMENT 'MAY SKU编码',
virtual_category VARCHAR(50) NULL COMMENT 'OEM/ODM模式',
product_code VARCHAR(100) NULL COMMENT '商品货号',
sku_name VARCHAR(255) NULL COMMENT 'SKU名称',
sku_size VARCHAR(255) NULL COMMENT 'SKU尺寸',
material VARCHAR(255) NULL COMMENT '材质',
color_name VARCHAR(255) NULL COMMENT '颜色',
product_cost DECIMAL(18,2) NULL COMMENT '采购成本',
sale_price DECIMAL(18,2) NULL COMMENT '售价',
sale_status VARCHAR(120) NULL COMMENT '销售状态',
category_name VARCHAR(255) NULL COMMENT '类目名称',
supplier_name VARCHAR(255) NULL COMMENT '供应商名称',
sku_image_url VARCHAR(1000) NULL COMMENT 'SKU图片',
sku_length DECIMAL(18,2) NULL COMMENT '长',
sku_width DECIMAL(18,2) NULL COMMENT '宽',
sku_height DECIMAL(18,2) NULL COMMENT '高',
created_at_source DATETIME NULL COMMENT '业务源创建时间',
source_table VARCHAR(120) NOT NULL COMMENT '来源表',
source_row_count INT NOT NULL DEFAULT 0 COMMENT '来源匹配行数',
calc_version VARCHAR(64) NOT NULL DEFAULT '' COMMENT '计算口径版本',
refreshed_at DATETIME NOT NULL COMMENT '刷新时间',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
UNIQUE KEY uk_planning_may_profile_stage_sku (may_sku_code),
INDEX idx_planning_may_profile_stage_status (sale_status),
INDEX idx_planning_may_profile_stage_refreshed (refreshed_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='企划MAY商品资料预计算表'`;
await planningDatabase.execute(createProfileStageSql);
await ensurePlanningTableColumn(
MAY_PRODUCT_PROFILE_STAGE_TABLE,
'virtual_category',
"VARCHAR(50) NULL COMMENT 'OEM/ODM模式'"
);
await ensurePlanningTableColumn(
MAY_PRODUCT_PROFILE_STAGE_TABLE,
'calc_version',
"VARCHAR(64) NOT NULL DEFAULT '' COMMENT '计算口径版本'"
);
await ensurePlanningTableColumn(
MAY_PRODUCT_PROFILE_STAGE_TABLE,
'category_name',
"VARCHAR(255) NULL COMMENT '类目名称'"
);
await ensurePlanningTableColumn(
MAY_PRODUCT_PROFILE_STAGE_TABLE,
'supplier_name',
"VARCHAR(255) NULL COMMENT '供应商名称'"
);
await ensurePlanningTableIndexDefinition(
MAY_PRODUCT_PROFILE_STAGE_TABLE,
'idx_planning_may_profile_stage_virtual_category',
['virtual_category', 'sale_status', 'calc_version'],
'(virtual_category, sale_status, calc_version)'
);
const tableColumns = await planningDatabase.query(
`SHOW COLUMNS FROM ${escapeIdentifier(MAY_PRODUCT_PROFILE_STAGE_TABLE)}`
);
const hasCurrentSkuColumn = tableColumns.some((column) => column.Field === 'may_sku_code');
if (!hasCurrentSkuColumn) {
await planningDatabase.execute(`DROP TABLE IF EXISTS ${escapeIdentifier(MAY_PRODUCT_PROFILE_STAGE_TABLE)}`);
await planningDatabase.execute(createProfileStageSql);
}
profileStageSchemaState.ready = true;
})();
try {
await profileStageSchemaState.pending;
} finally {
profileStageSchemaState.pending = null;
}
}
function buildSqlServerValueRows(values = [], paramPrefix = 'value') {
const params = {};
const rows = values.map((value, index) => {
const paramName = `${paramPrefix}${index}`;
params[paramName] = value;
return `(@${paramName})`;
});
return { params, sql: rows.join(', ') };
}
function deriveProductCodeFromSku(value) {
const skuCode = normalizeText(value);
if (!skuCode) {
return '';
}
const withoutVariant = skuCode.replace(/[-_].*$/u, '');
const match = withoutVariant.match(/^([A-Z]+[0-9]+)/iu);
return match ? match[1] : withoutVariant;
}
function deriveStyleCodeFromProductCode(value) {
const productCode = normalizeText(value);
if (!productCode) {
return '';
}
const match = productCode.match(/^([A-Z]+[0-9]+)/iu);
return match ? match[1] : productCode;
}
function buildLimitLookupCandidates(row = {}, profile = {}) {
const skuCode = normalizeText(row.skuCode || row.sku_id);
const productCode = normalizeText(profile.productCode || row.productCode || row.spuCode);
const derivedProductCode = deriveProductCodeFromSku(skuCode);
return Array.from(new Set([
skuCode,
productCode,
derivedProductCode,
deriveStyleCodeFromProductCode(productCode),
deriveStyleCodeFromProductCode(derivedProductCode),
].map(normalizeText).filter(Boolean)));
}
function buildSkuLookupCandidates(items = []) {
const candidateMap = new Map();
const addCandidate = (ownerSku, matchCode, matchMode = 'exact', priority = 0) => {
const ownerKey = normalizeSkuKey(ownerSku);
const matchKey = normalizeSkuKey(matchCode);
if (!ownerKey || !matchKey) {
return;
}
const candidateKey = `${ownerKey}::${matchMode}::${matchKey}`;
if (candidateMap.has(candidateKey)) {
return;
}
candidateMap.set(candidateKey, {
ownerSku: ownerKey,
matchCode: matchKey,
matchMode,
priority,
});
};
items.forEach((item) => {
if (typeof item === 'string') {
addCandidate(item, item, 'exact', 0);
return;
}
const ownerSku = normalizeSkuKey(item?.skuCode || item?.sku_id);
if (!ownerSku) {
return;
}
addCandidate(ownerSku, ownerSku, 'exact', 0);
const productCode = normalizeSkuKey(item?.productCode || item?.product_code);
if (productCode && productCode !== ownerSku) {
addCandidate(ownerSku, productCode, 'exact', 1);
if (productCode.length >= 5) {
addCandidate(ownerSku, productCode, 'prefix', 2);
}
}
const spuCode = normalizeSkuKey(item?.spuCode || item?.spu_code);
if (spuCode && spuCode !== ownerSku && spuCode !== productCode) {
addCandidate(ownerSku, spuCode, 'exact', 3);
if (spuCode.length >= 5) {
addCandidate(ownerSku, spuCode, 'prefix', 4);
}
}
const derivedProductCode = normalizeSkuKey(deriveProductCodeFromSku(ownerSku));
if (
derivedProductCode
&& derivedProductCode.length >= 5
&& derivedProductCode !== ownerSku
&& derivedProductCode !== productCode
&& derivedProductCode !== spuCode
) {
addCandidate(ownerSku, derivedProductCode, 'prefix', 5);
}
});
return Array.from(candidateMap.values());
}
function buildPriceLimitValueExpression(tableAlias, fieldName) {
return `TRY_CONVERT(DECIMAL(18, 2), NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), ${tableAlias}.[${fieldName}]))), N''))`;
}
function extractSizeFromText(value) {
const text = normalizeText(value);
if (!text) {
return '';
}
const slashSize = text.match(/\d+(?:\.\d+)?\s*[/xX*×]\s*\d+(?:\.\d+)?(?:\s*[/xX*×]\s*\d+(?:\.\d+)?)?/u);
if (slashSize) {
return slashSize[0].replace(/\s+/g, '');
}
const cmSize = text.match(/\d+(?:\.\d+)?\s*(?:cm|厘米)\s*[/xX*×]\s*\d+(?:\.\d+)?\s*(?:cm|厘米)?(?:\s*[/xX*×]\s*\d+(?:\.\d+)?\s*(?:cm|厘米)?)?/iu);
return cmSize ? cmSize[0].replace(/\s+/g, '') : '';
}
function formatDimension(length, width, height) {
const numbers = [length, width, height]
.map((value) => toNullableNumber(value, 1))
.filter((value) => Number.isFinite(value) && value > 0);
if (numbers.length < 2) {
return '';
}
return numbers.map((value) => String(value).replace(/\.0$/u, '')).join('/');
}
function extractColorFromText(value) {
const text = normalizeText(value);
if (!text) {
return '';
}
const colors = [
'原野橡木色', '原野橡木', '加勒比松木色', '加勒比松木', '巴伦橡木色', '巴伦橡木',
'尼亚美胡桃色', '尼亚美胡桃', '新浅胡桃色', '新浅胡桃', '澳洲橡木色', '澳洲橡木',
'云溪白', '北美原木纹色', '北美原木纹', '柚木色', '绛瑛色', '绛瑛', '暖白色',
'暖白', '纯白', '幸福时光', '花香怡人', '原木色', '胡桃色', '黑胡桃', '浅胡桃',
'白色', '黑色', '灰色', '粉色', '蓝色', '绿色', '红色', '黄色', '橙色', '棕色',
'米色', '透明', '银色', '金色', '木色',
];
return colors.find((color) => text.includes(color)) || '';
}
function extractMaterialFromText(value) {
const text = normalizeText(value);
if (!text) {
return '';
}
const materials = [
'橡胶木', '松木', '榉木', '白蜡木', '实木', '颗粒板', '刨花板', '密度板', '纤维板',
'生态板', '多层板', '奥松板', '中纤板', '岩板', '大理石', '钢化玻璃', '玻璃',
'不锈钢', '碳素钢', '钢架', '铁艺', '金属', '亚克力', '塑料', '布艺', '皮革',
'皮艺', '海绵', '竹', '藤',
];
return materials.find((material) => text.includes(material)) || '';
}
function getListingDurationDays(listingAt, endAt = null) {
const listingTime = listingAt ? new Date(listingAt).getTime() : 0;
if (!Number.isFinite(listingTime) || listingTime <= 0) {
return null;
}
const endTime = endAt ? new Date(endAt).getTime() : Date.now();
if (!Number.isFinite(endTime) || endTime <= 0) {
return null;
}
const days = Math.floor((endTime - listingTime) / 86400000);
return days >= 0 ? days : null;
}
function formatDateOnly(value) {
if (!value) {
return '';
}
const text = normalizeText(value);
const match = text.match(/\d{4}-\d{2}-\d{2}/u);
if (match) {
return match[0];
}
const date = new Date(value);
if (!Number.isFinite(date.getTime())) {
return '';
}
const localTime = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
return localTime.toISOString().slice(0, 10);
}
function getShanghaiDateOnly(value = new Date()) {
const date = value instanceof Date ? value : new Date(value);
if (!Number.isFinite(date.getTime())) {
return '';
}
return new Date(date.getTime() + 8 * 60 * 60 * 1000).toISOString().slice(0, 10);
}
function createSnapshotBatchId() {
if (typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return crypto.randomBytes(16).toString('hex');
}
function parseDisplayNumber(value, fallback = null) {
if (value === undefined || value === null || value === '') {
return fallback;
}
const text = normalizeText(value);
if (!text || ['-', '--', '—', 'null', 'NULL', 'undefined', '未配置', '暂无'].includes(text)) {
return fallback;
}
const parsed = Number(text
.replace(/[,,]/g, '')
.replace(/%/g, '')
.replace(/天/g, '')
.replace(/\s+/g, ''));
return Number.isFinite(parsed) ? parsed : fallback;
}
function displayNumberFromSnapshot(value, digits = 2, fallback = MAY_EMPTY_VALUE) {
return value === null || value === undefined
? fallback
: formatNumberCell(value, digits, fallback);
}
function displayPercentFromSnapshot(value, digits = 1) {
return value === null || value === undefined
? MAY_EMPTY_VALUE
: formatPercentCell(value, digits);
}
function displayDurationDaysFromSnapshot(value) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? `${Math.round(numericValue)} 天` : MAY_EMPTY_VALUE;
}
function buildDefaultSalesSummary() {
return {
performanceAmount30d: 0,
salesAmount30d: 0,
transactionAmount30d: 0,
salesQty30d: 0,
salesQty7d: 0,
salesQtyLast15d: 0,
salesQtyPrevious15d: 0,
afterSaleAmount30d: 0,
grossCostAmount30d: 0,
goodsIdCount30d: 0,
promotionFee30d: 0,
coveredChannels: '',
channelRatios: Object.fromEntries(MAY_CHANNEL_RATIO_FIELDS.map((field) => [field.key, 0])),
activeSalesDays30d: 0,
latestBizDate: null,
};
}
function resolveMaySalesAmount30d(transactionAmount30d, performanceAmount30d, fallback = 0) {
const transactionAmount = toNumber(transactionAmount30d, 0);
if (transactionAmount !== 0) {
return transactionAmount;
}
const performanceAmount = toNumber(performanceAmount30d, 0);
if (performanceAmount !== 0) {
return performanceAmount;
}
return fallback;
}
async function getLatestChannelBizDate() {
try {
await ensurePlanningChannelDailyReadSchema();
const [row] = await planningDatabase.query(
`SELECT DATE_FORMAT(MAX(stat_date), '%Y-%m-%d') AS latestBizDate FROM ${escapeIdentifier(CHANNEL_READ_TABLE)}`
);
return normalizeText(row?.latestBizDate);
} catch (error) {
console.warn('[PlanningMayProductData] 获取渠道最新业务日期失败:', error.message || error);
return '';
}
}
async function getLatestMaySalesBizDate() {
const financialBizDate = normalizeText(await withFallback(
getLatestFinancialSalesBizDate(),
'',
'获取MAY财务最新业务日期',
CRITICAL_ENRICHMENT_TIMEOUT_MS
));
if (financialBizDate) {
return financialBizDate;
}
return await getLatestChannelBizDate();
}
async function resolveSnapshotStatDate(options = {}) {
const explicitDate = formatDateOnly(options.statDate || options.snapshotDate);
if (explicitDate) {
return explicitDate;
}
return await getLatestMaySalesBizDate() || getShanghaiDateOnly();
}
async function queryMayActiveSkuCodesFromDimensionSource() {
const rows = await cloudDb.query(
`
SELECT DISTINCT UPPER(NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), sku_id))), N'')) AS skuCode
FROM dbo.${BUSINESS_SKU_TABLE} WITH (NOLOCK)
WHERE NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), sku_id))), N'') IS NOT NULL
AND ISNULL(enabled, 0) = 1
AND ISNULL(stock_disabled, 0) = 0
AND ${SQLSERVER_JST_MAY_ACTIVE_STATUS_FILTER}
AND UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), sku_id)))) NOT IN (N'FUWU', N'TCXJ', N'X00005', N'X00006')
`,
{},
{ requestTimeout: CRITICAL_ENRICHMENT_TIMEOUT_MS, maxAttempts: 1 }
);
return rows.map((row) => normalizeSkuKey(row.skuCode)).filter(Boolean);
}
async function queryMayActiveSkuProfileMapFromBusinessSource() {
const profileMap = new Map();
const rows = await cloudDb.query(
`
WITH source_rows AS (
SELECT
UPPER(NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), sku_id))), N'')) AS skuCode,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), name))), N'') AS skuName,
CASE
WHEN TRY_CONVERT(DECIMAL(18, 2), [l]) > 0
AND TRY_CONVERT(DECIMAL(18, 2), [w]) > 0
THEN CONCAT(
TRY_CONVERT(NVARCHAR(30), TRY_CONVERT(DECIMAL(18, 1), [l])),
N'/',
TRY_CONVERT(NVARCHAR(30), TRY_CONVERT(DECIMAL(18, 1), [w])),
CASE
WHEN TRY_CONVERT(DECIMAL(18, 2), [h]) > 0
THEN CONCAT(N'/', TRY_CONVERT(NVARCHAR(30), TRY_CONVERT(DECIMAL(18, 1), [h])))
ELSE N''
END
)
ELSE NULL
END AS skuSize,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), other_10))), N'') AS material,
COALESCE(
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), color))), N''),
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), properties_value))), N'')
) AS colorName,
COALESCE(
NULLIF(TRY_CONVERT(DECIMAL(18, 2), cost_price), 0),
NULLIF(TRY_CONVERT(DECIMAL(18, 2), purchase_price), 0)
) AS productCost,
NULLIF(TRY_CONVERT(DECIMAL(18, 2), sale_price), 0) AS salePrice,
CASE
WHEN ISNULL(enabled, 0) <> 1 OR ISNULL(stock_disabled, 0) <> 0 THEN N'未配置'
WHEN REPLACE(ISNULL(NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), item_type))), N''), N''), N'''', N'') <> N'成品' THEN N'半成品'
WHEN LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), category))) LIKE N'%配件%' THEN N'配件'
ELSE COALESCE(NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), other_5))), N''), N'未配置')
END AS saleStatus,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), vc_name))), N'') AS virtualCategory,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), category))), N'') AS categoryName,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), supplier_name))), N'') AS supplierName,
COALESCE(
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(1000), pic_big))), N''),
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(1000), pic))), N'')
) AS skuImageUrl,
TRY_CONVERT(DECIMAL(18, 2), [l]) AS skuLength,
TRY_CONVERT(DECIMAL(18, 2), [w]) AS skuWidth,
TRY_CONVERT(DECIMAL(18, 2), [h]) AS skuHeight,
COALESCE(TRY_CONVERT(datetime, modified), TRY_CONVERT(datetime, created)) AS createdAt
FROM dbo.${BUSINESS_SKU_TABLE} WITH (NOLOCK)
WHERE NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), sku_id))), N'') IS NOT NULL
AND ISNULL(enabled, 0) = 1
AND ISNULL(stock_disabled, 0) = 0
AND ${SQLSERVER_JST_MAY_ACTIVE_STATUS_FILTER}
AND UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), sku_id)))) NOT IN (N'FUWU', N'TCXJ', N'X00005', N'X00006')
),
picked AS (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY skuCode
ORDER BY createdAt DESC, skuCode ASC
) AS rn
FROM source_rows
WHERE skuCode IS NOT NULL
)
SELECT *
FROM picked
WHERE rn = 1
ORDER BY skuCode ASC
`,
{},
{ requestTimeout: 20000, maxAttempts: 1 }
);
rows.forEach((row) => {
const skuKey = normalizeSkuKey(row.skuCode);
if (!skuKey) {
return;
}
profileMap.set(skuKey, {
...row,
ownerSku: skuKey,
skuCode: skuKey,
productCode: '',
saleStatus: resolveMaySaleStatus(row.saleStatus, '销售'),
virtualCategory: resolveMayVirtualCategory(row.virtualCategory),
sourceTable: `dbo.${BUSINESS_SKU_TABLE}`,
sourceRowCount: 1,
});
});
return profileMap;
}
async function queryMayProductOwnerSkuCodesForStage(options = {}) {
const ownerSkuSet = new Set();
const dimensionSkuCodes = await queryMayActiveSkuCodesFromDimensionSource();
dimensionSkuCodes.forEach((skuCode) => ownerSkuSet.add(skuCode));
return Array.from(ownerSkuSet).sort((left, right) => left.localeCompare(right));
}
async function countMayProductSkuRows(options = {}) {
const virtualCategory = resolveMayVirtualCategoryFilter(options.virtualCategory || options.mode);
if (virtualCategory) {
try {
await ensurePlanningMayProductProfileStageSchema();
const whereParts = ['calc_version = ?', buildMayNotDiscontinuedCondition('sale_status')];
const params = [MAY_PRODUCT_PROFILE_STAGE_CALC_VERSION];
appendMayVirtualCategoryWhere(whereParts, params, 'virtual_category', virtualCategory);
const [row] = await planningDatabase.query(
`SELECT COUNT(*) AS total
FROM ${escapeIdentifier(MAY_PRODUCT_PROFILE_STAGE_TABLE)}
WHERE ${whereParts.join(' AND ')}`,
params
);
return Number(row?.total || 0);
} catch (error) {
console.warn('[PlanningMayProductData] 按OEM/ODM统计MAY商品资料预计算失败:', error.message || error);
}
}
const ownerSkuCodes = await queryMayProductOwnerSkuCodesForStage();
return ownerSkuCodes.length;
}
function clearMayProductProfileStageReadyCache() {
mayProductProfileStageReadyCache = null;
}
function isMayActiveSaleStatus(value) {
return normalizeMaySaleStatus(value) === '销售';
}
function getMinimumHealthyMayRowCount(totalSkuCount) {
const total = Math.max(Number(totalSkuCount) || 0, 0);
return total > 0 ? Math.floor(total * 0.98) : 0;
}
function assertMayProductProfileStageRowsReady(rows = [], totalSkuCount = 0) {
const total = Math.max(Number(totalSkuCount) || 0, 0);
const rowCount = Array.isArray(rows) ? rows.length : 0;
const activeStatusCount = rows.filter((row) => isMayActiveSaleStatus(row.sale_status)).length;
const minimumHealthyRows = getMinimumHealthyMayRowCount(total);
if (total <= 0) {
throw new Error('MAY商品资料预计算源SKU为空,禁止替换可用资料表');
}
if (rowCount < minimumHealthyRows) {
throw new Error(`MAY商品资料预计算结果不足,当前 ${rowCount}/${total} 行,禁止替换可用资料表`);
}
if (activeStatusCount < minimumHealthyRows) {
throw new Error(`MAY商品资料预计算销售状态不足,当前销售状态 ${activeStatusCount}/${total} 行,禁止替换可用资料表`);
}
}
function assertMayProductSnapshotRowsReady(snapshotRowCount = 0, totalSkuCount = 0) {
const total = Math.max(Number(totalSkuCount) || 0, 0);
const snapshotRows = Math.max(Number(snapshotRowCount) || 0, 0);
const minimumHealthyRows = getMinimumHealthyMayRowCount(total);
if (total <= 0) {
throw new Error('MAY商品快照源SKU为空,禁止标记为成功快照');
}
if (snapshotRows < minimumHealthyRows) {
throw new Error(`MAY商品快照生成结果不足,当前 ${snapshotRows}/${total} 行,禁止标记为成功快照`);
}
}
async function getMayProductProfileStageReadyState(options = {}) {
const expectedTotalSkuCount = Number.isFinite(Number(options.totalSkuCount))
? Math.max(Number(options.totalSkuCount), 0)
: null;
const useCache = options.force !== true
&& mayProductProfileStageReadyCache
&& mayProductProfileStageReadyCache.expiresAt > Date.now()
&& (
expectedTotalSkuCount === null
|| mayProductProfileStageReadyCache.totalSkuCount === expectedTotalSkuCount
);
if (useCache) {
return mayProductProfileStageReadyCache;
}
await ensurePlanningMayProductProfileStageSchema();
const [stageRow] = await planningDatabase.query(
`SELECT
COUNT(*) AS total,
MAX(calc_version) AS calcVersion,
SUM(CASE WHEN ${buildMayNotDiscontinuedCondition('sale_status')} THEN 1 ELSE 0 END) AS activeStatusCount,
SUM(CASE WHEN COALESCE(NULLIF(TRIM(sale_status), ''), '') = '' THEN 1 ELSE 0 END) AS emptyStatusCount
FROM ${escapeIdentifier(MAY_PRODUCT_PROFILE_STAGE_TABLE)}`
);
const totalSkuCount = expectedTotalSkuCount ?? await countMayProductSkuRows();
const stageCount = Number(stageRow?.total || 0);
const activeStatusCount = Number(stageRow?.activeStatusCount || 0);
const emptyStatusCount = Number(stageRow?.emptyStatusCount || 0);
const versionMatches = normalizeText(stageRow?.calcVersion) === MAY_PRODUCT_PROFILE_STAGE_CALC_VERSION;
const stageCountMatchesSkuCount = totalSkuCount > 0
&& stageCount >= Math.floor(totalSkuCount * 0.98)
&& stageCount <= Math.ceil(totalSkuCount * 1.02);
const activeStatusMatchesSkuCount = activeStatusCount >= getMinimumHealthyMayRowCount(totalSkuCount);
const ready = stageCount > 0
&& versionMatches
&& stageCountMatchesSkuCount
&& activeStatusMatchesSkuCount
&& emptyStatusCount === 0;
const readiness = {
ready,
stageCount,
totalSkuCount,
versionMatches,
stageCountMatchesSkuCount,
activeStatusCount,
activeStatusMatchesSkuCount,
emptyStatusCount,
calcVersion: normalizeText(stageRow?.calcVersion),
expiresAt: Date.now() + MAY_PRODUCT_PROFILE_STAGE_READY_CACHE_TTL_MS,
};
mayProductProfileStageReadyCache = readiness;
return readiness;
}
async function queryMayProductBaseRows(page = 1, pageSize = 20, options = {}) {
const offset = (Math.max(Number(page) || 1, 1) - 1) * pageSize;
const virtualCategory = resolveMayVirtualCategoryFilter(options.virtualCategory || options.mode);
try {
const stagedRows = await queryMayProductBaseRowsFromProfileStage(page, pageSize, { ...options, virtualCategory });
if (stagedRows.length > 0 || virtualCategory) {
return stagedRows;
}
} catch (error) {
console.warn('[PlanningMayProductData] 读取MAY商品资料预计算基础行失败,回退业务SKU表:', error.message || error);
}
const skuRows = await cloudDb.query(
`
WITH source_rows AS (
SELECT
UPPER(NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), skucode))), N'')) AS skuCode,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), procode))), N'') AS productCode,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), skuname))), N'') AS skuName,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(1000), pic_url))), N'') AS skuImageUrl,
TRY_CONVERT(datetime, created) AS createdAt,
TRY_CONVERT(INT, status) AS productStatus,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), pro_status))), N'') AS saleStatus,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), supplier))), N'') AS supplierName,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), spu))), N'') AS spuCode,
TRY_CONVERT(DECIMAL(18, 2), cost_price) AS productCost,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), category))), N'') AS categoryName
FROM ${BUSINESS_SKU_DIMENSION_TABLE} WITH (NOLOCK)
WHERE NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), skucode))), N'') IS NOT NULL
AND TRY_CONVERT(INT, status) = 1
AND ${SQLSERVER_MAY_ACTIVE_STATUS_FILTER}
AND UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), skucode)))) NOT IN (N'FUWU', N'TCXJ', N'X00005', N'X00006')
),
picked AS (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY skuCode
ORDER BY
CASE WHEN saleStatus IS NULL THEN 1 ELSE 0 END,
createdAt DESC,
skuCode ASC
) AS rn
FROM source_rows
WHERE skuCode IS NOT NULL
)
SELECT
skuCode,
productCode,
skuName,
skuImageUrl,
createdAt,
productStatus,
saleStatus,
supplierName,
spuCode,
productCost,
categoryName
FROM picked
WHERE rn = 1
ORDER BY skuCode ASC
OFFSET @offset ROWS FETCH NEXT @pageSize ROWS ONLY
`,
{ offset, pageSize },
{ requestTimeout: CRITICAL_ENRICHMENT_TIMEOUT_MS, maxAttempts: 1 }
);
const pageSkuCodes = skuRows.map((row) => normalizeSkuKey(row.skuCode)).filter(Boolean);
if (pageSkuCodes.length === 0) {
return [];
}
const imageRows = [];
await ensurePlanningAiImageSchema();
const imageLookupCandidates = buildSkuLookupCandidates(skuRows);
for (const candidateChunk of chunkArray(imageLookupCandidates, 400)) {
const params = [];
const candidateSql = candidateChunk
.map((candidate, index) => {
params.push(candidate.ownerSku, candidate.matchCode, candidate.matchMode, candidate.priority);
return index === 0
? 'SELECT ? AS ownerSku, ? AS matchCode, ? AS matchMode, ? AS matchPriority'
: 'UNION ALL SELECT ?, ?, ?, ?';
})
.join('\n');
const chunkRows = await planningDatabase.query(
`WITH sku_filter AS (
${candidateSql}
),
matched AS (
SELECT
sf.ownerSku,
f.id,
f.sku_id AS skuCode,
f.goods_id AS goodsId,
f.product_name AS productName,
f.category_name AS categoryName,
f.appearance_similarity_code AS appearanceCode,
f.image_url AS imageUrl,
f.image_status AS imageStatus,
f.category_tag AS categoryTag,
f.color_tag AS colorTag,
f.material_tag AS materialTag,
f.created_at AS createdAt,
f.updated_at AS updatedAt,
ROW_NUMBER() OVER (
PARTITION BY sf.ownerSku
ORDER BY
CAST(sf.matchPriority AS UNSIGNED) ASC,
CASE WHEN UPPER(TRIM(f.sku_id)) = sf.ownerSku THEN 0 ELSE 1 END,
f.updated_at DESC,
f.id DESC
) AS rn
FROM ${escapeIdentifier(AI_IMAGE_FEATURE_LIBRARY_TABLE)} f
INNER JOIN sku_filter sf
ON (
sf.matchMode = 'exact'
AND UPPER(TRIM(f.sku_id)) = sf.matchCode
)
OR (
sf.matchMode = 'prefix'
AND UPPER(TRIM(f.sku_id)) LIKE CONCAT(sf.matchCode, '%')
)
WHERE f.biz_type = 'internal'
AND NULLIF(TRIM(f.sku_id), '') IS NOT NULL
)
SELECT *
FROM matched
WHERE rn = 1`,
params
);
imageRows.push(...chunkRows);
}
const imageRowMap = new Map(imageRows.map((row) => [normalizeSkuKey(row.ownerSku || row.skuCode), row]));
const skuRowMap = new Map(skuRows.map((row) => [normalizeSkuKey(row.skuCode), row]));
let authoritativeProfileMap = new Map();
try {
authoritativeProfileMap = await queryExactBusinessProfilesBySkuCodes(pageSkuCodes);
const saleStatusFallbackItems = pageSkuCodes
.filter((skuCode) => !normalizeMaySaleStatus(authoritativeProfileMap.get(skuCode)?.saleStatus))
.map((skuCode) => {
const skuRow = skuRowMap.get(skuCode) || {};
return {
skuCode,
productCode: firstMeaningfulText(skuRow.productCode, deriveProductCodeFromSku(skuCode)),
};
});
if (saleStatusFallbackItems.length > 0) {
const fallbackProfileMap = await queryBusinessSkuProfiles(saleStatusFallbackItems);
fallbackProfileMap.forEach((profile, skuCode) => {
authoritativeProfileMap.set(
skuCode,
mergeMayBusinessProfile(authoritativeProfileMap.get(skuCode), profile)
);
});
}
} catch (error) {
console.warn('[PlanningMayProductData] 读取 jst_sku 销售状态失败,基础行回退为空状态:', error.message || error);
}
return pageSkuCodes.map((skuCode) => {
const skuRow = skuRowMap.get(skuCode) || {};
const imageRow = imageRowMap.get(skuCode);
const authoritativeProfile = authoritativeProfileMap.get(skuCode) || {};
return {
id: imageRow?.id || null,
skuCode: skuRow.skuCode || imageRow?.skuCode || skuCode,
goodsId: imageRow?.goodsId || skuRow.productCode || skuRow.spuCode || null,
productCode: skuRow.productCode || '',
spuCode: skuRow.spuCode || '',
skuName: skuRow.skuName || '',
productName: imageRow?.productName || skuRow.skuName || '',
categoryName: imageRow?.categoryName || skuRow.categoryName || '',
appearanceCode: imageRow?.appearanceCode || '',
imageUrl: imageRow?.imageUrl || skuRow.skuImageUrl || '',
skuImageUrl: skuRow.skuImageUrl || '',
imageStatus: imageRow?.imageStatus || '',
categoryTag: imageRow?.categoryTag || skuRow.categoryName || '',
colorTag: imageRow?.colorTag || '',
materialTag: imageRow?.materialTag || '',
saleStatus: resolveMaySaleStatus(authoritativeProfile.saleStatus, skuRow.saleStatus, '销售'),
virtualCategory: resolveMayVirtualCategory(authoritativeProfile.virtualCategory),
productStatus: skuRow.productStatus || null,
supplierName: skuRow.supplierName || '',
productCost: toNullableNumber(skuRow.productCost, 2),
createdAt: imageRow?.createdAt || skuRow.createdAt || null,
updatedAt: imageRow?.updatedAt || null,
};
});
}
async function queryMayProductProfileBaseRowsForStage(page = 1, pageSize = 1000) {
const offset = (Math.max(Number(page) || 1, 1) - 1) * pageSize;
return await cloudDb.query(
`
WITH source_rows AS (
SELECT
UPPER(NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), skucode))), N'')) AS skuCode,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), procode))), N'') AS productCode,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), skuname))), N'') AS skuName,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(1000), pic_url))), N'') AS skuImageUrl,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), pro_status))), N'') AS saleStatus,
TRY_CONVERT(DECIMAL(18, 2), cost_price) AS productCost,
TRY_CONVERT(datetime, created) AS createdAt
FROM ${BUSINESS_SKU_DIMENSION_TABLE} WITH (NOLOCK)
WHERE NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), skucode))), N'') IS NOT NULL
AND TRY_CONVERT(INT, status) = 1
AND ${SQLSERVER_MAY_ACTIVE_STATUS_FILTER}
AND UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), skucode)))) NOT IN (N'FUWU', N'TCXJ', N'X00005', N'X00006')
),
picked AS (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY skuCode
ORDER BY CASE WHEN saleStatus IS NULL THEN 1 ELSE 0 END, createdAt DESC, skuCode ASC
) AS rn
FROM source_rows
WHERE skuCode IS NOT NULL
)
SELECT
skuCode,
productCode,
skuName,
skuImageUrl,
saleStatus,
productCost,
createdAt
FROM picked
WHERE rn = 1
ORDER BY skuCode ASC
OFFSET @offset ROWS FETCH NEXT @pageSize ROWS ONLY
`,
{ offset, pageSize },
{ requestTimeout: CRITICAL_ENRICHMENT_TIMEOUT_MS, maxAttempts: 1 }
);
}
function buildExactBusinessSkuMapSourceProfileSql(sourcePriorityOffset = 3) {
return BUSINESS_SKU_MAP_TABLES.map((tableName, index) => `
SELECT
sf.skuCode AS ownerSku,
${sourcePriorityOffset + index} AS sourcePriority,
N'dbo.${tableName}' AS sourceTable,
COALESCE(
NULLIF(UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), m.raw_sku_id)))), N''),
NULLIF(UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), m.sku_id)))), N''),
NULLIF(UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), m.shop_sku_id)))), N''),
NULLIF(UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), m.outer_sku_code)))), N'')
) AS skuCode,
COALESCE(
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), m.raw_sku_id))), N''),
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), m.sku_id))), N'')
) AS productCode,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), m.name))), N'') AS skuName,
NULL AS skuSize,
NULL AS material,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), m.properties_value))), N'') AS colorName,
NULL AS productCost,
NULL AS salePrice,
CASE WHEN ISNULL(m.enabled, 0) = 1 THEN N'销售' ELSE N'未配置' END AS saleStatus,
NULL AS virtualCategory,
NULL AS categoryName,
NULL AS supplierName,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(1000), m.pic))), N'') AS skuImageUrl,
NULL AS skuLength,
NULL AS skuWidth,
NULL AS skuHeight,
COALESCE(TRY_CONVERT(datetime, m.modified), TRY_CONVERT(datetime, m.created), TRY_CONVERT(datetime, m.modifiedTime), TRY_CONVERT(datetime, m.createdTime)) AS createdAt
FROM dbo.${tableName} m WITH (NOLOCK)
INNER JOIN sku_filter sf
ON UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), m.shop_sku_id)))) = sf.skuCode
OR UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), m.outer_sku_code)))) = sf.skuCode
OR UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), m.raw_sku_id)))) = sf.skuCode
OR UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), m.sku_id)))) = sf.skuCode
OR UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), m.i_id)))) = sf.skuCode
`.trim()).join('\n\n UNION ALL\n\n');
}
function buildLookupBusinessSkuMapSourceProfileSql(sourcePriorityOffset = 2) {
return BUSINESS_SKU_MAP_TABLES.map((tableName, index) => `
SELECT
sf.ownerSku,
TRY_CONVERT(INT, sf.matchPriority) AS matchPriority,
${sourcePriorityOffset + index} AS sourcePriority,
N'dbo.${tableName}' AS sourceTable,
COALESCE(
NULLIF(UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), m.raw_sku_id)))), N''),
NULLIF(UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), m.sku_id)))), N''),
NULLIF(UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), m.shop_sku_id)))), N''),
NULLIF(UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), m.outer_sku_code)))), N'')
) AS skuCode,
COALESCE(
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), m.raw_sku_id))), N''),
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), m.sku_id))), N'')
) AS productCode,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), m.name))), N'') AS skuName,
NULL AS skuSize,
NULL AS material,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), m.properties_value))), N'') AS colorName,
NULL AS productCost,
NULL AS salePrice,
CASE WHEN ISNULL(m.enabled, 0) = 1 THEN N'销售' ELSE N'未配置' END AS saleStatus,
NULL AS virtualCategory,
NULL AS categoryName,
NULL AS supplierName,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(1000), m.pic))), N'') AS skuImageUrl,
NULL AS skuLength,
NULL AS skuWidth,
NULL AS skuHeight,
COALESCE(TRY_CONVERT(datetime, m.modified), TRY_CONVERT(datetime, m.created), TRY_CONVERT(datetime, m.modifiedTime), TRY_CONVERT(datetime, m.createdTime)) AS createdAt
FROM dbo.${tableName} m WITH (NOLOCK)
INNER JOIN sku_filter sf
ON (
sf.matchMode = N'exact'
AND (
UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), m.shop_sku_id)))) = sf.matchCode
OR UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), m.outer_sku_code)))) = sf.matchCode
OR UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), m.raw_sku_id)))) = sf.matchCode
OR UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), m.sku_id)))) = sf.matchCode
OR UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), m.i_id)))) = sf.matchCode
)
)
OR (
sf.matchMode = N'prefix'
AND (
UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), m.raw_sku_id)))) LIKE sf.matchCode + N'%'
OR UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), m.sku_id)))) LIKE sf.matchCode + N'%'
)
)
`.trim()).join('\n\n UNION ALL\n\n');
}
function buildOptionalExactBusinessSkuMapSourceProfileSql(sourcePriorityOffset = 3) {
return MAY_PROFILE_INCLUDE_SKUMAP_SOURCES
? `UNION ALL\n\n ${buildExactBusinessSkuMapSourceProfileSql(sourcePriorityOffset)}`
: '';
}
function buildOptionalLookupBusinessSkuMapSourceProfileSql(sourcePriorityOffset = 2) {
return MAY_PROFILE_INCLUDE_SKUMAP_SOURCES
? `UNION ALL\n\n ${buildLookupBusinessSkuMapSourceProfileSql(sourcePriorityOffset)}`
: '';
}
async function queryExactBusinessProfilesBySkuCodes(skuCodes = []) {
const profileMap = new Map();
const uniqueSkuCodes = Array.from(new Set(skuCodes.map(normalizeSkuKey).filter(Boolean)));
if (uniqueSkuCodes.length === 0) {
return profileMap;
}
for (const skuChunk of chunkArray(uniqueSkuCodes, 350)) {
const { params, sql } = buildSqlServerValueRows(skuChunk, 'profileSku');
const rows = await cloudDb.query(
`
WITH sku_filter(skuCode) AS (
SELECT skuCode
FROM (VALUES ${sql}) AS v(skuCode)
),
source_profile AS (
SELECT
sf.skuCode AS ownerSku,
10 AS sourcePriority,
N'${BUSINESS_SKU_DIMENSION_TABLE}' AS sourceTable,
UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.skucode)))) AS skuCode,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.procode))), N'') AS productCode,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), d.skuname))), N'') AS skuName,
NULL AS skuSize,
NULL AS material,
NULL AS colorName,
TRY_CONVERT(DECIMAL(18, 2), d.cost_price) AS productCost,
NULL AS salePrice,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.pro_status))), N'') AS saleStatus,
NULL AS virtualCategory,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), d.category))), N'') AS categoryName,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), d.supplier))), N'') AS supplierName,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(1000), d.pic_url))), N'') AS skuImageUrl,
NULL AS skuLength,
NULL AS skuWidth,
NULL AS skuHeight,
TRY_CONVERT(datetime, d.created) AS createdAt
FROM ${BUSINESS_SKU_DIMENSION_TABLE} d WITH (NOLOCK)
INNER JOIN sku_filter sf
ON UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.skucode)))) = sf.skuCode
WHERE TRY_CONVERT(INT, d.status) = 1
AND ${SQLSERVER_MAY_ACTIVE_STATUS_FILTER}
UNION ALL
SELECT
sf.skuCode AS ownerSku,
20 AS sourcePriority,
N'dbo.${BUSINESS_SKU_LIBRARY_TABLE}' AS sourceTable,
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(255), l.SkuSize))), N'') AS skuSize,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.materialcode))), N'') AS material,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.Color))), N'') AS colorName,
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 productCost,
COALESCE(
NULLIF(TRY_CONVERT(DECIMAL(18, 2), l.ActualPrice), 0),
NULLIF(TRY_CONVERT(DECIMAL(18, 2), l.ErpPrice), 0)
) AS salePrice,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.ProStatus))), N'') AS saleStatus,
NULL AS virtualCategory,
COALESCE(
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.CategoryTwo))), N''),
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.CategoryOne))), N'')
) AS categoryName,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), l.Supplier))), N'') AS supplierName,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(1000), l.Picture))), N'') AS skuImageUrl,
TRY_CONVERT(DECIMAL(18, 2), l.Length) AS skuLength,
TRY_CONVERT(DECIMAL(18, 2), l.Width) AS skuWidth,
TRY_CONVERT(DECIMAL(18, 2), l.Height) AS skuHeight,
COALESCE(TRY_CONVERT(datetime, l.CreationTime), TRY_CONVERT(datetime, l.created)) AS createdAt
FROM dbo.${BUSINESS_SKU_LIBRARY_TABLE} l WITH (NOLOCK)
INNER JOIN sku_filter sf
ON UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.SkuCode)))) = sf.skuCode
UNION ALL
SELECT
sf.skuCode AS ownerSku,
0 AS sourcePriority,
N'dbo.${BUSINESS_SKU_TABLE}' AS sourceTable,
UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), j.sku_id)))) AS skuCode,
NULL AS productCode,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), j.name))), N'') AS skuName,
CASE
WHEN TRY_CONVERT(DECIMAL(18, 2), j.[l]) > 0
AND TRY_CONVERT(DECIMAL(18, 2), j.[w]) > 0
THEN CONCAT(
TRY_CONVERT(NVARCHAR(30), TRY_CONVERT(DECIMAL(18, 1), j.[l])),
N'/',
TRY_CONVERT(NVARCHAR(30), TRY_CONVERT(DECIMAL(18, 1), j.[w])),
CASE
WHEN TRY_CONVERT(DECIMAL(18, 2), j.[h]) > 0
THEN CONCAT(N'/', TRY_CONVERT(NVARCHAR(30), TRY_CONVERT(DECIMAL(18, 1), j.[h])))
ELSE N''
END
)
ELSE NULL
END AS skuSize,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), j.other_10))), N'') AS material,
COALESCE(
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), j.color))), N''),
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), j.properties_value))), N'')
) AS colorName,
COALESCE(
NULLIF(TRY_CONVERT(DECIMAL(18, 2), j.cost_price), 0),
NULLIF(TRY_CONVERT(DECIMAL(18, 2), j.purchase_price), 0)
) AS productCost,
NULLIF(TRY_CONVERT(DECIMAL(18, 2), j.sale_price), 0) AS salePrice,
CASE
WHEN ISNULL(j.enabled, 0) <> 1 OR ISNULL(j.stock_disabled, 0) <> 0 THEN N'未配置'
WHEN REPLACE(ISNULL(NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), j.item_type))), N''), N''), N'''', N'') <> N'成品' THEN N'半成品'
WHEN LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), j.category))) LIKE N'%配件%' THEN N'配件'
ELSE COALESCE(NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), j.other_5))), N''), N'未配置')
END AS saleStatus,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), j.vc_name))), N'') AS virtualCategory,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), j.category))), N'') AS categoryName,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), j.supplier_name))), N'') AS supplierName,
COALESCE(
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(1000), j.pic_big))), N''),
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(1000), j.pic))), N'')
) AS skuImageUrl,
TRY_CONVERT(DECIMAL(18, 2), j.[l]) AS skuLength,
TRY_CONVERT(DECIMAL(18, 2), j.[w]) AS skuWidth,
TRY_CONVERT(DECIMAL(18, 2), j.[h]) AS skuHeight,
COALESCE(TRY_CONVERT(datetime, j.modified), TRY_CONVERT(datetime, j.created)) AS createdAt
FROM dbo.${BUSINESS_SKU_TABLE} j WITH (NOLOCK)
INNER JOIN sku_filter sf
ON UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), j.sku_id)))) = sf.skuCode
${buildOptionalExactBusinessSkuMapSourceProfileSql(3)}
),
ranked AS (
SELECT
*,
COUNT(*) OVER (PARTITION BY ownerSku) AS sourceRowCount,
ROW_NUMBER() OVER (
PARTITION BY ownerSku
ORDER BY
CASE WHEN saleStatus IS NULL THEN 1 ELSE 0 END,
sourcePriority ASC,
createdAt DESC,
skuCode ASC
) AS rn
FROM source_profile
WHERE skuCode IS NOT NULL
)
SELECT *
FROM ranked
ORDER BY ownerSku ASC, rn ASC
`,
params,
{ requestTimeout: MAY_INVENTORY_ENRICHMENT_TIMEOUT_MS, maxAttempts: 1 }
);
rows.forEach((row) => {
const skuKey = normalizeSkuKey(row.ownerSku || row.skuCode);
if (!skuKey) {
return;
}
profileMap.set(skuKey, mergeMayBusinessProfile(profileMap.get(skuKey), row));
});
}
return profileMap;
}
async function queryMayProductBaseRowsFromProfileStage(page = 1, pageSize = 20, options = {}) {
const readiness = await getMayProductProfileStageReadyState();
if (!readiness.ready) {
return [];
}
const offset = (Math.max(Number(page) || 1, 1) - 1) * pageSize;
const whereParts = ['calc_version = ?', buildMayNotDiscontinuedCondition('sale_status')];
const params = [MAY_PRODUCT_PROFILE_STAGE_CALC_VERSION];
appendMayVirtualCategoryWhere(whereParts, params, 'virtual_category', options.virtualCategory || options.mode);
const profileRows = await planningDatabase.query(
`
SELECT
may_sku_code AS skuCode,
virtual_category AS virtualCategory,
product_code AS productCode,
sku_name AS skuName,
sku_size AS skuSize,
material,
color_name AS colorName,
product_cost AS productCost,
sale_price AS salePrice,
sale_status AS saleStatus,
category_name AS categoryName,
supplier_name AS supplierName,
sku_image_url AS skuImageUrl,
created_at_source AS createdAt
FROM ${escapeIdentifier(MAY_PRODUCT_PROFILE_STAGE_TABLE)}
WHERE ${whereParts.join(' AND ')}
ORDER BY may_sku_code ASC
LIMIT ? OFFSET ?
`,
[...params, pageSize, offset]
);
const pageSkuCodes = profileRows.map((row) => normalizeSkuKey(row.skuCode)).filter(Boolean);
if (pageSkuCodes.length === 0) {
return [];
}
const imageRows = [];
await ensurePlanningAiImageSchema();
const imageLookupCandidates = buildSkuLookupCandidates(profileRows);
for (const candidateChunk of chunkArray(imageLookupCandidates, 400)) {
const params = [];
const candidateSql = candidateChunk
.map((candidate, index) => {
params.push(candidate.ownerSku, candidate.matchCode, candidate.matchMode, candidate.priority);
return index === 0
? 'SELECT ? AS ownerSku, ? AS matchCode, ? AS matchMode, ? AS matchPriority'
: 'UNION ALL SELECT ?, ?, ?, ?';
})
.join('\n');
const chunkRows = await planningDatabase.query(
`WITH sku_filter AS (
${candidateSql}
),
matched AS (
SELECT
sf.ownerSku,
f.id,
f.sku_id AS skuCode,
f.goods_id AS goodsId,
f.product_name AS productName,
f.category_name AS categoryName,
f.appearance_similarity_code AS appearanceCode,
f.image_url AS imageUrl,
f.image_status AS imageStatus,
f.category_tag AS categoryTag,
f.color_tag AS colorTag,
f.material_tag AS materialTag,
f.created_at AS createdAt,
f.updated_at AS updatedAt,
ROW_NUMBER() OVER (
PARTITION BY sf.ownerSku
ORDER BY
CAST(sf.matchPriority AS UNSIGNED) ASC,
CASE WHEN UPPER(TRIM(f.sku_id)) = sf.ownerSku THEN 0 ELSE 1 END,
f.updated_at DESC,
f.id DESC
) AS rn
FROM ${escapeIdentifier(AI_IMAGE_FEATURE_LIBRARY_TABLE)} f
INNER JOIN sku_filter sf
ON (
sf.matchMode = 'exact'
AND UPPER(TRIM(f.sku_id)) = sf.matchCode
)
OR (
sf.matchMode = 'prefix'
AND UPPER(TRIM(f.sku_id)) LIKE CONCAT(sf.matchCode, '%')
)
WHERE f.biz_type = 'internal'
AND NULLIF(TRIM(f.sku_id), '') IS NOT NULL
)
SELECT *
FROM matched
WHERE rn = 1`,
params
);
imageRows.push(...chunkRows);
}
const imageRowMap = new Map(imageRows.map((row) => [normalizeSkuKey(row.ownerSku || row.skuCode), row]));
const profileRowMap = new Map(profileRows.map((row) => [normalizeSkuKey(row.skuCode), row]));
return pageSkuCodes.map((skuCode) => {
const profileRow = profileRowMap.get(skuCode) || {};
const imageRow = imageRowMap.get(skuCode);
return {
id: imageRow?.id || null,
skuCode: profileRow.skuCode || imageRow?.skuCode || skuCode,
goodsId: imageRow?.goodsId || profileRow.productCode || null,
productCode: profileRow.productCode || '',
spuCode: '',
skuName: profileRow.skuName || '',
skuSize: profileRow.skuSize || '',
productName: imageRow?.productName || profileRow.skuName || '',
categoryName: firstMeaningfulText(profileRow.categoryName, imageRow?.categoryName, imageRow?.categoryTag),
appearanceCode: imageRow?.appearanceCode || '',
imageUrl: imageRow?.imageUrl || profileRow.skuImageUrl || '',
skuImageUrl: profileRow.skuImageUrl || '',
imageStatus: imageRow?.imageStatus || '',
categoryTag: imageRow?.categoryTag || '',
colorTag: imageRow?.colorTag || profileRow.colorName || '',
materialTag: imageRow?.materialTag || profileRow.material || '',
saleStatus: profileRow.saleStatus || '',
virtualCategory: resolveMayVirtualCategory(profileRow.virtualCategory),
productStatus: null,
supplierName: profileRow.supplierName || '',
productCost: toNullableNumber(profileRow.productCost, 2),
salePrice: toNullableNumber(profileRow.salePrice, 2),
createdAt: imageRow?.createdAt || profileRow.createdAt || null,
updatedAt: imageRow?.updatedAt || null,
};
});
}
async function queryMayProductProfileStageMap(skuCodes = []) {
const uniqueSkuCodes = Array.from(new Set(skuCodes.map(normalizeSkuKey).filter(Boolean)));
const profileMap = new Map();
if (uniqueSkuCodes.length === 0) {
return { ready: false, profileMap };
}
try {
const readiness = await getMayProductProfileStageReadyState();
if (!readiness.ready) {
return { ready: false, profileMap };
}
for (const skuChunk of chunkArray(uniqueSkuCodes, 500)) {
const rows = await planningDatabase.query(
`
SELECT
may_sku_code AS skuCode,
virtual_category AS virtualCategory,
product_code AS productCode,
sku_name AS skuName,
sku_size AS skuSize,
material,
color_name AS colorName,
product_cost AS productCost,
sale_price AS salePrice,
sale_status AS saleStatus,
category_name AS categoryName,
supplier_name AS supplierName,
sku_image_url AS skuImageUrl,
sku_length AS skuLength,
sku_width AS skuWidth,
sku_height AS skuHeight,
created_at_source AS createdAt
FROM ${escapeIdentifier(MAY_PRODUCT_PROFILE_STAGE_TABLE)}
WHERE calc_version = ?
AND may_sku_code IN (${skuChunk.map(() => '?').join(', ')})
`,
[MAY_PRODUCT_PROFILE_STAGE_CALC_VERSION, ...skuChunk]
);
rows.forEach((row) => {
const skuKey = normalizeSkuKey(row.skuCode);
if (!skuKey) {
return;
}
profileMap.set(skuKey, {
productCode: normalizeText(row.productCode),
skuName: normalizeText(row.skuName),
skuSize: normalizeText(row.skuSize),
material: normalizeText(row.material),
colorName: normalizeText(row.colorName),
productCost: toNullableNumber(row.productCost, 2),
salePrice: toNullableNumber(row.salePrice, 2),
saleStatus: normalizeText(row.saleStatus),
virtualCategory: resolveMayVirtualCategory(row.virtualCategory),
categoryName: normalizeText(row.categoryName),
supplierName: normalizeText(row.supplierName),
skuImageUrl: normalizeText(row.skuImageUrl),
skuLength: toNullableNumber(row.skuLength, 2),
skuWidth: toNullableNumber(row.skuWidth, 2),
skuHeight: toNullableNumber(row.skuHeight, 2),
createdAt: row.createdAt || null,
});
});
}
return { ready: true, profileMap };
} catch (error) {
console.warn('[PlanningMayProductData] 读取MAY商品资料预计算失败,回退业务库:', error.message || error);
return { ready: false, profileMap };
}
}
async function refreshPlanningMayProductProfileStage(options = {}) {
await ensurePlanningMayProductProfileStageSchema();
clearMayProductProfileStageReadyCache();
const latestBizDate = await getLatestMaySalesBizDate();
const ownerSkuCodes = await queryMayProductOwnerSkuCodesForStage({ latestBizDate });
const totalSkuCount = ownerSkuCodes.length;
if (options.skipIfFresh) {
const readiness = await getMayProductProfileStageReadyState({
totalSkuCount,
force: true,
});
if (readiness.ready) {
return {
success: true,
skipped: true,
profileRowCount: readiness.stageCount,
totalSkuCount,
};
}
}
const startedAt = Date.now();
const chunkSize = Math.max(Number(options.chunkSize) || 1000, 100);
const refreshedAt = new Date();
const columns = [
'may_sku_code',
'virtual_category',
'product_code',
'sku_name',
'sku_size',
'material',
'color_name',
'product_cost',
'sale_price',
'sale_status',
'category_name',
'supplier_name',
'sku_image_url',
'sku_length',
'sku_width',
'sku_height',
'created_at_source',
'source_table',
'source_row_count',
'calc_version',
'refreshed_at',
];
let profileRowCount = 0;
let enrichedProfileCount = 0;
const pendingStageRows = [];
const baseProfileMap = await withFallback(
queryMayActiveSkuProfileMapFromBusinessSource(),
new Map(),
'读取MAY商品基础资料',
25000
);
if (typeof options.onProgress === 'function') {
options.onProgress({
phase: 'stage_init',
page: 0,
processedRows: 0,
enrichedProfileCount,
totalSkuCount,
chunkSize,
});
}
for (let start = 0; start < ownerSkuCodes.length; start += chunkSize) {
const skuCodes = ownerSkuCodes.slice(start, start + chunkSize);
if (typeof options.onProgress === 'function') {
options.onProgress({
phase: 'stage_batch_lookup',
page: Math.floor(start / chunkSize) + 1,
processedRows: profileRowCount,
enrichedProfileCount,
totalSkuCount,
chunkSize: skuCodes.length,
});
}
const businessProfileMap = new Map();
skuCodes.forEach((skuCode) => {
const skuKey = normalizeSkuKey(skuCode);
const baseProfile = baseProfileMap.get(skuKey);
if (baseProfile) {
businessProfileMap.set(skuKey, baseProfile);
}
});
if (MAY_PROFILE_USE_EXTRA_ENRICHMENT) {
try {
const exactProfileMap = await withFallback(
queryExactBusinessProfilesBySkuCodes(skuCodes),
new Map(),
'读取MAY商品富集资料',
MAY_INVENTORY_ENRICHMENT_TIMEOUT_MS
);
exactProfileMap.forEach((profile, skuKey) => {
businessProfileMap.set(skuKey, mergeMayBusinessProfile(businessProfileMap.get(skuKey), profile));
});
const fallbackItems = skuCodes
.map((skuCode) => {
const skuKey = normalizeSkuKey(skuCode);
const currentProfile = businessProfileMap.get(skuKey) || {};
if (
normalizeMayDimensionValue(currentProfile.categoryName)
&& normalizeMayDimensionValue(currentProfile.supplierName)
&& normalizeMaySaleStatus(currentProfile.saleStatus)
) {
return null;
}
return {
skuCode: skuKey,
productCode: firstMeaningfulText(currentProfile.productCode, deriveProductCodeFromSku(skuKey)),
};
})
.filter(Boolean);
if (fallbackItems.length > 0) {
const fallbackProfileMap = await withFallback(
queryBusinessSkuProfiles(fallbackItems),
new Map(),
'读取MAY商品补充资料',
MAY_INVENTORY_ENRICHMENT_TIMEOUT_MS
);
fallbackProfileMap.forEach((profile, skuKey) => {
businessProfileMap.set(skuKey, mergeMayBusinessProfile(businessProfileMap.get(skuKey), profile));
});
}
} catch (error) {
console.warn('[PlanningMayProductData] 批量读取业务商品资料失败,本批次使用基础资料:', error.message || error);
}
}
const stageRows = skuCodes.map((skuCode) => {
const skuKey = normalizeSkuKey(skuCode);
const profile = businessProfileMap.get(skuKey) || {};
if (!skuKey) {
return null;
}
const skuNameForFallback = firstMeaningfulText(profile.skuName, skuKey);
const sourceTable = normalizeText(profile.sourceTable) || BUSINESS_SKU_DIMENSION_TABLE;
if (sourceTable !== BUSINESS_SKU_DIMENSION_TABLE) {
enrichedProfileCount += 1;
}
return {
may_sku_code: skuKey,
virtual_category: resolveMayVirtualCategory(profile.virtualCategory, '未配置'),
product_code: firstMeaningfulText(profile.productCode, deriveProductCodeFromSku(skuKey)),
sku_name: firstMeaningfulText(profile.skuName, skuKey),
sku_size: firstMeaningfulText(
profile.skuSize,
formatDimension(profile.skuLength, profile.skuWidth, profile.skuHeight),
extractSizeFromText(skuNameForFallback)
),
material: firstMeaningfulText(profile.material, extractMaterialFromText(skuNameForFallback)),
color_name: firstMeaningfulText(profile.colorName, extractColorFromText(skuNameForFallback)),
product_cost: toNullableNumber(profile.productCost, 2),
sale_price: toNullableNumber(profile.salePrice, 2),
// The owner SKU list is already filtered to active finished SKUs from jst_sku.
sale_status: resolveMaySaleStatus(profile.saleStatus, '销售'),
category_name: resolveMayCategoryName(
profile.categoryName,
inferMayCategoryNameFromText(profile.skuName, skuNameForFallback)
),
supplier_name: resolveMaySupplierName(profile.supplierName),
sku_image_url: firstMeaningfulText(profile.skuImageUrl),
sku_length: toNullableNumber(profile.skuLength, 2),
sku_width: toNullableNumber(profile.skuWidth, 2),
sku_height: toNullableNumber(profile.skuHeight, 2),
created_at_source: profile.createdAt || null,
source_table: sourceTable,
source_row_count: Math.max(Number(profile.sourceRowCount || 0), 1),
calc_version: MAY_PRODUCT_PROFILE_STAGE_CALC_VERSION,
refreshed_at: refreshedAt,
};
}).filter(Boolean);
pendingStageRows.push(...stageRows);
profileRowCount += stageRows.length;
if (typeof options.onProgress === 'function') {
options.onProgress({
page: Math.floor(start / chunkSize) + 1,
processedRows: profileRowCount,
enrichedProfileCount,
totalSkuCount,
});
}
}
assertMayProductProfileStageRowsReady(pendingStageRows, totalSkuCount);
await planningDatabase.withTransaction(async (connection) => {
const executeInTransaction = async (sql, params = []) => {
const [result] = await connection.query(sql, params);
return result;
};
await executeInTransaction(`DELETE FROM ${escapeIdentifier(MAY_PRODUCT_PROFILE_STAGE_TABLE)}`);
await upsertPlanningRows(
MAY_PRODUCT_PROFILE_STAGE_TABLE,
columns,
pendingStageRows,
columns.filter((column) => column !== 'may_sku_code'),
{
executor: executeInTransaction,
chunkSize,
}
);
});
const stageCountMatchesSkuCount = totalSkuCount > 0
&& profileRowCount >= Math.floor(totalSkuCount * 0.98)
&& profileRowCount <= Math.ceil(totalSkuCount * 1.02);
const activeStatusCount = pendingStageRows.filter((row) => isMayActiveSaleStatus(row.sale_status)).length;
const emptyStatusCount = pendingStageRows.filter((row) => !normalizeMaySaleStatus(row.sale_status)).length;
const activeStatusMatchesSkuCount = activeStatusCount >= getMinimumHealthyMayRowCount(totalSkuCount);
mayProductProfileStageReadyCache = {
ready: stageCountMatchesSkuCount && activeStatusMatchesSkuCount && emptyStatusCount === 0,
stageCount: profileRowCount,
totalSkuCount,
versionMatches: true,
stageCountMatchesSkuCount,
activeStatusCount,
activeStatusMatchesSkuCount,
emptyStatusCount,
calcVersion: MAY_PRODUCT_PROFILE_STAGE_CALC_VERSION,
expiresAt: Date.now() + MAY_PRODUCT_PROFILE_STAGE_READY_CACHE_TTL_MS,
};
return {
success: true,
profileRowCount,
enrichedProfileCount,
totalSkuCount,
elapsedMs: Date.now() - startedAt,
};
}
async function queryBusinessSkuProfiles(items = []) {
const lookupCandidates = buildSkuLookupCandidates(items);
const profileMap = new Map();
if (lookupCandidates.length === 0) {
return profileMap;
}
const ownerSkuCodes = Array.from(new Set(
lookupCandidates
.map((candidate) => normalizeSkuKey(candidate.ownerSku))
.filter(Boolean)
));
const stagedProfiles = await queryMayProductProfileStageMap(ownerSkuCodes);
if (stagedProfiles.ready) {
stagedProfiles.profileMap.forEach((value, key) => {
profileMap.set(key, value);
});
if (profileMap.size >= ownerSkuCodes.length) {
return profileMap;
}
}
const missingOwnerSkuSet = new Set(
ownerSkuCodes.filter((skuCode) => !profileMap.has(skuCode))
);
const fallbackCandidates = lookupCandidates.filter(
(candidate) => missingOwnerSkuSet.has(normalizeSkuKey(candidate.ownerSku))
);
for (const candidateChunk of chunkArray(fallbackCandidates, 300)) {
const params = {};
const sqlRows = candidateChunk.map((candidate, index) => {
const ownerParam = `ownerSku${index}`;
const matchParam = `matchCode${index}`;
const modeParam = `matchMode${index}`;
const priorityParam = `matchPriority${index}`;
params[ownerParam] = candidate.ownerSku;
params[matchParam] = candidate.matchCode;
params[modeParam] = candidate.matchMode;
params[priorityParam] = candidate.priority;
return `(@${ownerParam}, @${matchParam}, @${modeParam}, @${priorityParam})`;
}).join(', ');
const rows = await cloudDb.query(
`
WITH sku_filter(ownerSku, matchCode, matchMode, matchPriority) AS (
SELECT ownerSku, matchCode, matchMode, matchPriority
FROM (VALUES ${sqlRows}) AS v(ownerSku, matchCode, matchMode, matchPriority)
),
source_profile AS (
SELECT
sf.ownerSku,
TRY_CONVERT(INT, sf.matchPriority) AS matchPriority,
20 AS sourcePriority,
N'dbo.${BUSINESS_SKU_LIBRARY_TABLE}' AS sourceTable,
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(255), l.SkuSize))), N'') AS skuSize,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.materialcode))), N'') AS material,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.Color))), N'') AS colorName,
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 productCost,
COALESCE(
NULLIF(TRY_CONVERT(DECIMAL(18, 2), l.ActualPrice), 0),
NULLIF(TRY_CONVERT(DECIMAL(18, 2), l.ErpPrice), 0)
) AS salePrice,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.ProStatus))), N'') AS saleStatus,
NULL AS virtualCategory,
COALESCE(
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.CategoryTwo))), N''),
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.CategoryOne))), N'')
) AS categoryName,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), l.Supplier))), N'') AS supplierName,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(1000), l.Picture))), N'') AS skuImageUrl,
TRY_CONVERT(DECIMAL(18, 2), l.Length) AS skuLength,
TRY_CONVERT(DECIMAL(18, 2), l.Width) AS skuWidth,
TRY_CONVERT(DECIMAL(18, 2), l.Height) AS skuHeight,
COALESCE(TRY_CONVERT(datetime, l.CreationTime), TRY_CONVERT(datetime, l.created)) AS createdAt
FROM dbo.${BUSINESS_SKU_LIBRARY_TABLE} l WITH (NOLOCK)
INNER JOIN sku_filter sf
ON (
sf.matchMode = N'exact'
AND (
UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.SkuCode)))) = sf.matchCode
OR UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.ItemNum)))) = sf.matchCode
)
)
OR (
sf.matchMode = N'prefix'
AND (
UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.SkuCode)))) LIKE sf.matchCode + N'%'
OR UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.ItemNum)))) LIKE sf.matchCode + N'%'
)
)
UNION ALL
SELECT
sf.ownerSku,
TRY_CONVERT(INT, sf.matchPriority) AS matchPriority,
0 AS sourcePriority,
N'dbo.${BUSINESS_SKU_TABLE}' AS sourceTable,
UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), j.sku_id)))) AS skuCode,
NULL AS productCode,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), j.name))), N'') AS skuName,
CASE
WHEN TRY_CONVERT(DECIMAL(18, 2), j.[l]) > 0
AND TRY_CONVERT(DECIMAL(18, 2), j.[w]) > 0
THEN CONCAT(
TRY_CONVERT(NVARCHAR(30), TRY_CONVERT(DECIMAL(18, 1), j.[l])),
N'/',
TRY_CONVERT(NVARCHAR(30), TRY_CONVERT(DECIMAL(18, 1), j.[w])),
CASE
WHEN TRY_CONVERT(DECIMAL(18, 2), j.[h]) > 0
THEN CONCAT(N'/', TRY_CONVERT(NVARCHAR(30), TRY_CONVERT(DECIMAL(18, 1), j.[h])))
ELSE N''
END
)
ELSE NULL
END AS skuSize,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), j.other_10))), N'') AS material,
COALESCE(
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), j.color))), N''),
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), j.properties_value))), N'')
) AS colorName,
COALESCE(
NULLIF(TRY_CONVERT(DECIMAL(18, 2), j.cost_price), 0),
NULLIF(TRY_CONVERT(DECIMAL(18, 2), j.purchase_price), 0)
) AS productCost,
NULLIF(TRY_CONVERT(DECIMAL(18, 2), j.sale_price), 0) AS salePrice,
CASE
WHEN ISNULL(j.enabled, 0) <> 1 OR ISNULL(j.stock_disabled, 0) <> 0 THEN N'未配置'
WHEN REPLACE(ISNULL(NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), j.item_type))), N''), N''), N'''', N'') <> N'成品' THEN N'半成品'
WHEN LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), j.category))) LIKE N'%配件%' THEN N'配件'
ELSE COALESCE(NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), j.other_5))), N''), N'未配置')
END AS saleStatus,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), j.vc_name))), N'') AS virtualCategory,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), j.category))), N'') AS categoryName,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), j.supplier_name))), N'') AS supplierName,
COALESCE(
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(1000), j.pic_big))), N''),
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(1000), j.pic))), N'')
) AS skuImageUrl,
TRY_CONVERT(DECIMAL(18, 2), j.[l]) AS skuLength,
TRY_CONVERT(DECIMAL(18, 2), j.[w]) AS skuWidth,
TRY_CONVERT(DECIMAL(18, 2), j.[h]) AS skuHeight,
COALESCE(TRY_CONVERT(datetime, j.modified), TRY_CONVERT(datetime, j.created)) AS createdAt
FROM dbo.${BUSINESS_SKU_TABLE} j WITH (NOLOCK)
INNER JOIN sku_filter sf
ON (
sf.matchMode = N'exact'
AND UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), j.sku_id)))) = sf.matchCode
)
OR (
sf.matchMode = N'prefix'
AND UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), j.sku_id)))) LIKE sf.matchCode + N'%'
)
${buildOptionalLookupBusinessSkuMapSourceProfileSql(2)}
)
SELECT
ownerSku,
sourceTable,
skuCode,
productCode,
skuName,
skuSize,
material,
colorName,
productCost,
salePrice,
saleStatus,
virtualCategory,
categoryName,
supplierName,
skuImageUrl,
skuLength,
skuWidth,
skuHeight,
createdAt,
ROW_NUMBER() OVER (
PARTITION BY ownerSku
ORDER BY
matchPriority ASC,
CASE WHEN saleStatus IS NULL THEN 1 ELSE 0 END,
sourcePriority ASC,
createdAt DESC,
skuCode ASC
) AS rn
FROM source_profile
WHERE skuCode IS NOT NULL
ORDER BY ownerSku ASC, rn ASC
`,
params,
{ requestTimeout: MAY_INVENTORY_ENRICHMENT_TIMEOUT_MS, maxAttempts: 1 }
);
rows.forEach((row) => {
const skuKey = normalizeSkuKey(row.ownerSku || row.skuCode);
if (!skuKey) {
return;
}
profileMap.set(skuKey, mergeMayBusinessProfile(profileMap.get(skuKey), row));
});
}
return profileMap;
}
async function querySkuShopListingMap(skuCodes = []) {
const uniqueSkuCodes = Array.from(new Set(skuCodes.map(normalizeSkuKey).filter(Boolean)));
const listingMap = new Map();
if (uniqueSkuCodes.length === 0) {
return listingMap;
}
try {
await ensurePlanningStockSelectionSchema();
for (const skuChunk of chunkArray(uniqueSkuCodes, 300)) {
const rows = await planningDatabase.query(
`
SELECT
UPPER(TRIM(b.barcode)) AS skuCode,
MIN(b.firstListedDate) AS firstShopListingAt,
MAX(COALESCE(b.listingShopCount, 0)) AS listingShopCount
FROM ${escapeIdentifier(STOCK_SELECTION_BASE_TABLE)} b
WHERE b.statDate = (
SELECT MAX(latest.statDate)
FROM ${escapeIdentifier(STOCK_SELECTION_BASE_TABLE)} latest
)
AND b.firstListedDate IS NOT NULL
AND UPPER(TRIM(b.barcode)) IN (${skuChunk.map(() => '?').join(', ')})
GROUP BY UPPER(TRIM(b.barcode))
`,
skuChunk
);
rows.forEach((row) => {
const skuKey = normalizeSkuKey(row.skuCode);
if (!skuKey || !row.firstShopListingAt) {
return;
}
listingMap.set(skuKey, {
firstShopListingAt: row.firstShopListingAt,
listingShopCount: toNumber(row.listingShopCount, 0),
});
});
}
} catch (error) {
console.warn('[PlanningMayProductData] 获取店铺铺货时间缓存失败:', error.message || error);
}
return listingMap;
}
async function querySkuInventoryMap(skuCodes = []) {
const candidateMap = new Map();
const addCandidate = (ownerSku, matchCode, matchMode = 'exact', priority = 0) => {
const ownerKey = normalizeSkuKey(ownerSku);
const matchKey = normalizeSkuKey(matchCode);
if (!ownerKey || !matchKey) {
return;
}
const candidateKey = `${ownerKey}::${matchMode}::${matchKey}`;
if (candidateMap.has(candidateKey)) {
return;
}
candidateMap.set(candidateKey, {
ownerSku: ownerKey,
matchCode: matchKey,
matchMode,
priority,
});
};
skuCodes.forEach((item) => {
if (typeof item === 'string') {
addCandidate(item, item, 'exact', 0);
return;
}
const ownerSku = normalizeSkuKey(item?.skuCode || item?.sku_id);
if (!ownerSku) {
return;
}
addCandidate(ownerSku, ownerSku, 'exact', 0);
});
const lookupCandidates = Array.from(candidateMap.values());
if (lookupCandidates.length === 0) {
return new Map();
}
const jstInventoryMap = new Map();
for (const candidateChunk of chunkArray(lookupCandidates, 400)) {
const params = {};
const sqlRows = candidateChunk.map((candidate, index) => {
const ownerParam = `ownerSku${index}`;
const matchParam = `matchCode${index}`;
const modeParam = `matchMode${index}`;
const priorityParam = `matchPriority${index}`;
params[ownerParam] = candidate.ownerSku;
params[matchParam] = candidate.matchCode;
params[modeParam] = candidate.matchMode;
params[priorityParam] = candidate.priority;
return `(@${ownerParam}, @${matchParam}, @${modeParam}, @${priorityParam})`;
}).join(', ');
const rows = await cloudDb.query(
`
WITH sku_filter(ownerSku, matchCode, matchMode, matchPriority) AS (
SELECT ownerSku, matchCode, matchMode, matchPriority
FROM (VALUES ${sqlRows}) AS v(ownerSku, matchCode, matchMode, matchPriority)
),
inventory_latest AS (
SELECT
sf.ownerSku,
UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), i.sku_id)))) AS skuCode,
LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(200), wh.name))) AS warehouseName,
LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(500), wh.remark2))) AS warehouseRemark2,
COALESCE(TRY_CONVERT(DECIMAL(18, 2), i.qty), 0) AS qty,
TRY_CONVERT(INT, sf.matchPriority) AS matchPriority,
ROW_NUMBER() OVER (
PARTITION BY
sf.ownerSku,
UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), i.sku_id)))),
TRY_CONVERT(INT, i.wms_co_id)
ORDER BY TRY_CONVERT(datetime, i.modified) DESC, TRY_CONVERT(datetime, i.updateTime) DESC, i.Id DESC
) AS rn
FROM dbo.jst_inventoryInfo i
INNER JOIN dbo.jst_warehouseInfo wh
ON wh.wms_co_id = TRY_CONVERT(INT, i.wms_co_id)
INNER JOIN sku_filter sf
ON (
sf.matchMode = N'exact'
AND UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), i.sku_id)))) = sf.matchCode
)
WHERE COALESCE(TRY_CONVERT(DECIMAL(18, 2), i.qty), 0) > 0
),
inventory_dedup AS (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY ownerSku, skuCode, warehouseName
ORDER BY matchPriority ASC
) AS matchRn
FROM inventory_latest
WHERE rn = 1
)
SELECT ownerSku AS skuCode, warehouseName, warehouseRemark2, SUM(qty) AS stockQty
FROM inventory_dedup
WHERE matchRn = 1
GROUP BY ownerSku, warehouseName, warehouseRemark2
`,
params,
{ requestTimeout: CRITICAL_ENRICHMENT_TIMEOUT_MS, maxAttempts: 1 }
);
rows.forEach((row) => {
const skuKey = normalizeSkuKey(row.skuCode);
const current = jstInventoryMap.get(skuKey) || createEmptyMayInventorySummary();
const stockQty = toNumber(row.stockQty, 0);
const warehouseName = normalizeText(row.warehouseName);
const warehouseRemark2 = normalizeText(row.warehouseRemark2);
const inventoryScope = classifyMayInventoryScope(warehouseName, warehouseRemark2);
current.stockQty += stockQty;
if (inventoryScope === MAY_INVENTORY_SCOPE_LABELS.realCompany) {
current.realCompanyStockQty += stockQty;
} else if (inventoryScope === MAY_INVENTORY_SCOPE_LABELS.factory) {
current.factoryStockQty += stockQty;
} else {
current.dropShipStockQty += stockQty;
}
if (warehouseName && !current.warehouses.includes(warehouseName)) {
current.warehouses.push(warehouseName);
}
if (inventoryScope && !current.inventoryScopeLabels.includes(inventoryScope)) {
current.inventoryScopeLabels.push(inventoryScope);
}
jstInventoryMap.set(skuKey, current);
});
}
const inventoryMap = new Map();
jstInventoryMap.forEach((row, skuKey) => {
inventoryMap.set(skuKey, {
stockQty: row.stockQty,
realCompanyStockQty: row.realCompanyStockQty,
dropShipStockQty: row.dropShipStockQty,
factoryStockQty: row.factoryStockQty,
warehouses: row.warehouses,
inventoryScopeLabels: sortMayInventoryScopeLabels(row.inventoryScopeLabels),
inventoryScopeRemark: buildMayInventoryScopeRemark(row.inventoryScopeLabels),
});
});
return inventoryMap;
}
async function querySkuFreightMap(skuCodes = []) {
const uniqueSkuCodes = Array.from(new Set(skuCodes.map(normalizeSkuKey).filter(Boolean)));
const freightMap = new Map();
if (uniqueSkuCodes.length === 0) {
return freightMap;
}
for (const skuChunk of chunkArray(uniqueSkuCodes, 300)) {
const { params, sql } = buildSqlServerValueRows(skuChunk, 'sku');
const rows = await cloudDb.query(
`
WITH sku_filter(skuCode) AS (
SELECT skuCode
FROM (VALUES ${sql}) AS v(skuCode)
),
latest_date AS (
SELECT MAX(TRY_CONVERT(date, f.send_date)) AS endDate
FROM dbo.fys_jstFreightCostDetail f
INNER JOIN sku_filter sf
ON f.sku_id = sf.skuCode
)
SELECT
UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), f.sku_id)))) AS skuCode,
AVG(TRY_CONVERT(DECIMAL(18, 2), f.freightCost)) AS avgFreight
FROM dbo.fys_jstFreightCostDetail f
CROSS JOIN latest_date
INNER JOIN sku_filter sf
ON f.sku_id = sf.skuCode
WHERE latest_date.endDate IS NOT NULL
AND TRY_CONVERT(date, f.send_date) BETWEEN DATEADD(day, -29, latest_date.endDate) AND latest_date.endDate
AND TRY_CONVERT(DECIMAL(18, 2), f.freightCost) IS NOT NULL
AND TRY_CONVERT(DECIMAL(18, 2), f.freightCost) > 0
GROUP BY UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), f.sku_id))))
`,
params,
{ requestTimeout: OPTIONAL_ENRICHMENT_TIMEOUT_MS, maxAttempts: 1 }
);
rows.forEach((row) => {
const skuKey = normalizeSkuKey(row.skuCode);
freightMap.set(skuKey, {
avgFreight: toNullableNumber(row.avgFreight, 2),
});
});
}
return freightMap;
}
async function getLatestFinancialSalesBizDate() {
const [row] = await cloudDb.query(
`SELECT CONVERT(varchar(10), MAX(stat_date), 23) AS latestBizDate
FROM ${CHANNEL_SOURCE_TABLE} WITH (NOLOCK)`,
{},
{ requestTimeout: CRITICAL_ENRICHMENT_TIMEOUT_MS, maxAttempts: 1 }
);
return normalizeText(row?.latestBizDate);
}
async function querySalesSourceSummaryRows(latestBizDate, skuCodes = []) {
if (!latestBizDate) {
return [];
}
const params = { latestBizDate };
const uniqueSkuCodes = Array.from(new Set(skuCodes.map(normalizeSkuKey).filter(Boolean)));
uniqueSkuCodes.forEach((skuCode, index) => {
params[`sourceSku${index}`] = skuCode;
});
CHANNEL_EXCLUDED_SKU_CODES.forEach((skuCode, index) => {
params[`excludedSku${index}`] = skuCode;
});
const sourceSkuSql = uniqueSkuCodes
.map((_, index) => `@sourceSku${index}`)
.join(', ');
const excludedSkuSql = CHANNEL_EXCLUDED_SKU_CODES
.map((_, index) => `@excludedSku${index}`)
.join(', ');
const sourceSkuFilterSql = sourceSkuSql
? `AND UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.skuoid)))) IN (${sourceSkuSql})`
: '';
return cloudDb.query(
`
WITH sales_base AS (
SELECT
d.*,
UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.skuoid)))) AS normalizedSkuCode,
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), d.shop_name))), N'') AS normalizedShopName
FROM ${CHANNEL_SOURCE_TABLE} d WITH (NOLOCK)
WHERE d.stat_date BETWEEN DATEADD(day, -29, TRY_CONVERT(date, @latestBizDate)) AND TRY_CONVERT(date, @latestBizDate)
AND d.skuoid IS NOT NULL
AND LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.skuoid))) <> N''
${sourceSkuFilterSql}
AND UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.skuoid)))) NOT IN (${excludedSkuSql})
${CHANNEL_NON_VIRTUAL_ORDER_FILTER_SQL}
),
sku_agg AS (
SELECT
sb.normalizedSkuCode AS skuCode,
MAX(sb.stat_date) AS latestBizDate,
SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), sb.real_performance), 0)) AS performanceAmount30d,
SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), sb.sales_amount), 0)) AS transactionAmount30d,
SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), sb.goods_count), 0)) AS salesQty30d,
SUM(CASE
WHEN sb.stat_date BETWEEN DATEADD(day, -6, TRY_CONVERT(date, @latestBizDate)) AND TRY_CONVERT(date, @latestBizDate)
THEN ISNULL(TRY_CONVERT(DECIMAL(18, 2), sb.goods_count), 0)
ELSE 0
END) AS salesQty7d,
SUM(CASE
WHEN sb.stat_date BETWEEN DATEADD(day, -14, TRY_CONVERT(date, @latestBizDate)) AND TRY_CONVERT(date, @latestBizDate)
THEN ISNULL(TRY_CONVERT(DECIMAL(18, 2), sb.goods_count), 0)
ELSE 0
END) AS salesQtyLast15d,
SUM(CASE
WHEN sb.stat_date BETWEEN DATEADD(day, -29, TRY_CONVERT(date, @latestBizDate)) AND DATEADD(day, -15, TRY_CONVERT(date, @latestBizDate))
THEN ISNULL(TRY_CONVERT(DECIMAL(18, 2), sb.goods_count), 0)
ELSE 0
END) AS salesQtyPrevious15d,
SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), sb.after_sale_amount), 0)) AS afterSaleAmount30d,
SUM(
COALESCE(
TRY_CONVERT(DECIMAL(18, 2), sb.push_fee_total),
ISNULL(TRY_CONVERT(DECIMAL(18, 2), sb.push_fee), 0) + ISNULL(TRY_CONVERT(DECIMAL(18, 2), sb.shared_push_fee_sum), 0)
)
) AS promotionFee30d,
SUM(
ISNULL(TRY_CONVERT(DECIMAL(18, 2), sb.clear_sales_cost), 0)
+ ISNULL(TRY_CONVERT(DECIMAL(18, 2), sb.send_freight), 0)
) AS grossCostAmount30d,
COUNT(DISTINCT CASE
WHEN ISNULL(TRY_CONVERT(DECIMAL(18, 2), sb.real_performance), 0) <> 0
OR ISNULL(TRY_CONVERT(DECIMAL(18, 2), sb.goods_count), 0) <> 0
THEN sb.stat_date
ELSE NULL
END) AS activeSalesDays30d
,
COUNT(DISTINCT CASE
WHEN sb.item_id IS NOT NULL
AND LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), sb.item_id))) <> N''
THEN NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), sb.item_id))), N'')
ELSE NULL
END) AS goodsIdCount30d
FROM sales_base sb
GROUP BY sb.normalizedSkuCode
)
SELECT
sa.skuCode,
sa.latestBizDate,
sa.performanceAmount30d,
sa.transactionAmount30d,
sa.salesQty30d,
sa.salesQty7d,
sa.salesQtyLast15d,
sa.salesQtyPrevious15d,
sa.afterSaleAmount30d,
sa.promotionFee30d,
sa.grossCostAmount30d,
ISNULL(sa.goodsIdCount30d, 0) AS goodsIdCount30d,
sa.activeSalesDays30d,
CAST(NULL AS NVARCHAR(MAX)) AS coveredChannels,
CAST(NULL AS NVARCHAR(MAX)) AS channelSalesAmounts
FROM sku_agg sa
`,
params,
{ requestTimeout: 180000, maxAttempts: 1 }
);
}
async function queryLocalChannelIdSummaryMap(latestBizDate, skuCodes = []) {
if (!latestBizDate) {
return new Map();
}
const uniqueSkuCodes = Array.from(new Set(skuCodes.map(normalizeSkuKey).filter(Boolean)));
const resultMap = new Map();
if (uniqueSkuCodes.length === 0) {
return resultMap;
}
await ensurePlanningChannelDailyReadSchema();
for (const skuChunk of chunkArray(uniqueSkuCodes, 500)) {
const rows = await planningDatabase.query(
`
SELECT
skuCode,
SUM(afterSaleAmount30d) AS afterSaleAmount30d,
SUM(grossCostAmount30d) AS grossCostAmount30d,
SUM(promotionFee30d) AS promotionFee30d,
COUNT(*) AS goodsIdCount30d
FROM (
SELECT
UPPER(TRIM(primary_sku_code)) AS skuCode,
item_id,
SUM(COALESCE(performance_amount, 0)) AS itemPerformanceAmount30d,
SUM(COALESCE(after_sale_amount, 0)) AS afterSaleAmount30d,
SUM(COALESCE(gross_cost_amount, 0)) AS grossCostAmount30d,
SUM(COALESCE(push_fee_amount, 0)) AS promotionFee30d
FROM ${escapeIdentifier('channel_id_daily_read')}
WHERE stat_date BETWEEN DATE_SUB(?, INTERVAL 29 DAY) AND ?
AND NULLIF(TRIM(item_id), '') IS NOT NULL
AND UPPER(TRIM(primary_sku_code)) IN (${skuChunk.map(() => '?').join(', ')})
GROUP BY UPPER(TRIM(primary_sku_code)), item_id
) summary
GROUP BY skuCode
`,
[latestBizDate, latestBizDate, ...skuChunk]
);
rows.forEach((row) => {
const skuKey = normalizeSkuKey(row.skuCode);
if (!skuKey) {
return;
}
resultMap.set(skuKey, {
afterSaleAmount30d: toNumber(row.afterSaleAmount30d, 0),
grossCostAmount30d: toNumber(row.grossCostAmount30d, 0),
promotionFee30d: toNumber(row.promotionFee30d, 0),
goodsIdCount30d: toNumber(row.goodsIdCount30d, 0),
});
});
}
return resultMap;
}
async function queryLocalChannelCoverageMap(latestBizDate, skuCodes = []) {
if (!latestBizDate) {
return new Map();
}
const uniqueSkuCodes = Array.from(new Set(skuCodes.map(normalizeSkuKey).filter(Boolean)));
const resultMap = new Map();
if (uniqueSkuCodes.length === 0) {
return resultMap;
}
await ensurePlanningChannelDailyReadSchema();
const channelAmountSelectSql = MAY_CHANNEL_RATIO_FIELDS
.map((field) => {
const matchSql = field.key === 'other'
? LOCAL_CHANNEL_OTHER_MATCH_SQL
: LOCAL_CHANNEL_MATCH_SQL[field.key];
return `SUM(CASE WHEN ${matchSql} THEN COALESCE(d.sales_amount, 0) ELSE 0 END) AS ${escapeIdentifier(getLocalChannelAmountAlias(field))}`;
})
.join(',\n ');
for (const skuChunk of chunkArray(uniqueSkuCodes, 500)) {
const rows = await planningDatabase.query(
`
SELECT
UPPER(TRIM(d.sku_code)) AS skuCode,
GROUP_CONCAT(DISTINCT NULLIF(TRIM(d.shop_name), '') SEPARATOR '、') AS coveredChannels,
${channelAmountSelectSql}
FROM ${escapeIdentifier(CHANNEL_READ_TABLE)} d
WHERE d.stat_date BETWEEN DATE_SUB(?, INTERVAL 29 DAY) AND ?
AND UPPER(TRIM(d.sku_code)) IN (${skuChunk.map(() => '?').join(', ')})
GROUP BY UPPER(TRIM(d.sku_code))
`,
[latestBizDate, latestBizDate, ...skuChunk]
);
rows.forEach((row) => {
const skuKey = normalizeSkuKey(row.skuCode);
if (!skuKey) {
return;
}
resultMap.set(skuKey, {
coveredChannels: normalizeText(row.coveredChannels),
channelSalesAmounts: buildChannelSalesAmountsTextFromRow(row),
});
});
}
return resultMap;
}
async function queryLocalSalesSourceSummaryRows(latestBizDate, skuCodes = []) {
if (!latestBizDate) {
return [];
}
const uniqueSkuCodes = Array.from(new Set(skuCodes.map(normalizeSkuKey).filter(Boolean)));
if (uniqueSkuCodes.length === 0) {
return [];
}
await ensurePlanningChannelDailyReadSchema();
const localChannelIdSummaryMap = await queryLocalChannelIdSummaryMap(latestBizDate, uniqueSkuCodes);
const channelAmountSelectSql = MAY_CHANNEL_RATIO_FIELDS
.map((field) => {
const matchSql = field.key === 'other'
? LOCAL_CHANNEL_OTHER_MATCH_SQL
: LOCAL_CHANNEL_MATCH_SQL[field.key];
return `SUM(CASE WHEN ${matchSql} THEN COALESCE(d.sales_amount, 0) ELSE 0 END) AS ${escapeIdentifier(getLocalChannelAmountAlias(field))}`;
})
.join(',\n ');
const resultRows = [];
for (const skuChunk of chunkArray(uniqueSkuCodes, 500)) {
const rows = await planningDatabase.query(
`
SELECT
UPPER(TRIM(d.sku_code)) AS skuCode,
MAX(d.stat_date) AS latestBizDate,
SUM(COALESCE(d.sales_amount, 0)) AS performanceAmount30d,
SUM(COALESCE(d.sales_amount, 0)) AS transactionAmount30d,
SUM(COALESCE(d.ship_count, 0)) AS salesQty30d,
SUM(CASE WHEN d.stat_date BETWEEN DATE_SUB(?, INTERVAL 6 DAY) AND ? THEN COALESCE(d.ship_count, 0) ELSE 0 END) AS salesQty7d,
SUM(CASE WHEN d.stat_date BETWEEN DATE_SUB(?, INTERVAL 14 DAY) AND ? THEN COALESCE(d.ship_count, 0) ELSE 0 END) AS salesQtyLast15d,
SUM(CASE WHEN d.stat_date BETWEEN DATE_SUB(?, INTERVAL 29 DAY) AND DATE_SUB(?, INTERVAL 15 DAY) THEN COALESCE(d.ship_count, 0) ELSE 0 END) AS salesQtyPrevious15d,
SUM(COALESCE(d.after_sale_amount, 0)) AS afterSaleAmount30d,
0 AS promotionFee30d,
0 AS grossCostAmount30d,
0 AS goodsIdCount30d,
COUNT(DISTINCT CASE WHEN COALESCE(d.sales_amount, 0) > 0 OR COALESCE(d.ship_count, 0) > 0 THEN d.stat_date END) AS activeSalesDays30d,
GROUP_CONCAT(DISTINCT NULLIF(TRIM(d.shop_name), '') SEPARATOR '、') AS coveredChannels,
${channelAmountSelectSql}
FROM ${escapeIdentifier(CHANNEL_READ_TABLE)} d
WHERE d.stat_date BETWEEN DATE_SUB(?, INTERVAL 29 DAY) AND ?
AND UPPER(TRIM(d.sku_code)) IN (${skuChunk.map(() => '?').join(', ')})
GROUP BY UPPER(TRIM(d.sku_code))
`,
[
latestBizDate, latestBizDate,
latestBizDate, latestBizDate,
latestBizDate, latestBizDate,
latestBizDate, latestBizDate,
...skuChunk,
]
);
rows.forEach((row) => {
const idSummary = localChannelIdSummaryMap.get(normalizeSkuKey(row.skuCode)) || {};
resultRows.push({
skuCode: normalizeSkuKey(row.skuCode),
latestBizDate: formatDateOnly(row.latestBizDate) || latestBizDate,
performanceAmount30d: toNumber(row.performanceAmount30d, 0),
transactionAmount30d: toNumber(row.transactionAmount30d, 0),
salesQty30d: toNumber(row.salesQty30d, 0),
salesQty7d: toNumber(row.salesQty7d, 0),
salesQtyLast15d: toNumber(row.salesQtyLast15d, 0),
salesQtyPrevious15d: toNumber(row.salesQtyPrevious15d, 0),
afterSaleAmount30d: toNumber(row.afterSaleAmount30d, 0),
promotionFee30d: toNumber(idSummary.promotionFee30d, 0),
grossCostAmount30d: toNumber(idSummary.grossCostAmount30d, 0),
goodsIdCount30d: toNumber(idSummary.goodsIdCount30d, 0),
activeSalesDays30d: toNumber(row.activeSalesDays30d, 0),
coveredChannels: normalizeText(row.coveredChannels),
channelSalesAmounts: buildChannelSalesAmountsTextFromRow(row),
});
});
}
return resultRows;
}
async function queryExactSkuPresenceMap(skuCodes = [], source = 'dwd') {
const uniqueSkuCodes = Array.from(new Set(skuCodes.map(normalizeSkuKey).filter(Boolean)));
const resultMap = new Map();
if (uniqueSkuCodes.length === 0) {
return resultMap;
}
const sourceConfig = source === 'dwd'
? {
tableName: BUSINESS_SKU_DIMENSION_TABLE,
codeExpression: 'skucode',
sourceTable: BUSINESS_SKU_DIMENSION_TABLE,
matchMode: 'exact_dwd',
priority: 0,
}
: source === 'fys'
? {
tableName: `dbo.${BUSINESS_SKU_LIBRARY_TABLE}`,
codeExpression: 'SkuCode',
sourceTable: `dbo.${BUSINESS_SKU_LIBRARY_TABLE}`,
matchMode: 'exact_product_profile',
priority: 1,
}
: {
tableName: `dbo.${BUSINESS_SKU_TABLE}`,
codeExpression: 'sku_id',
sourceTable: `dbo.${BUSINESS_SKU_TABLE}`,
matchMode: 'exact_jst_sku',
priority: 2,
};
for (const skuChunk of chunkArray(uniqueSkuCodes, 800)) {
const { params, sql } = buildSqlServerValueRows(skuChunk, 'sku');
const rows = await cloudDb.query(
`
WITH sku_filter(skuCode) AS (
SELECT skuCode
FROM (VALUES ${sql}) AS v(skuCode)
)
SELECT
UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), src.${sourceConfig.codeExpression})))) AS skuCode,
COUNT(*) AS sourceRowCount
FROM ${sourceConfig.tableName} src WITH (NOLOCK)
INNER JOIN sku_filter sf
ON UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), src.${sourceConfig.codeExpression})))) = sf.skuCode
WHERE src.${sourceConfig.codeExpression} IS NOT NULL
GROUP BY UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), src.${sourceConfig.codeExpression}))))
`,
params,
{ requestTimeout: 30000, maxAttempts: 1 }
);
rows.forEach((row) => {
const skuKey = normalizeSkuKey(row.skuCode);
if (!skuKey) {
return;
}
resultMap.set(skuKey, {
skuCode: skuKey,
sourceTable: sourceConfig.sourceTable,
matchMode: sourceConfig.matchMode,
priority: sourceConfig.priority,
sourceRowCount: Math.max(Number(row.sourceRowCount || 0), 1),
});
});
}
return resultMap;
}
function addSalesSummaryMetric(summary, row) {
summary.performanceAmount30d += toNumber(row.performanceAmount30d, 0);
summary.transactionAmount30d += toNumber(row.transactionAmount30d, 0);
summary.salesQty30d += toNumber(row.salesQty30d, 0);
summary.salesQty7d += toNumber(row.salesQty7d, 0);
summary.salesQtyLast15d += toNumber(row.salesQtyLast15d, 0);
summary.salesQtyPrevious15d += toNumber(row.salesQtyPrevious15d, 0);
summary.afterSaleAmount30d += toNumber(row.afterSaleAmount30d, 0);
summary.grossCostAmount30d += toNumber(row.grossCostAmount30d, 0);
summary.promotionFee30d += toNumber(row.promotionFee30d, 0);
summary.goodsIdCount30d += toNumber(row.goodsIdCount30d, 0);
summary.activeSalesDays30d = Math.max(summary.activeSalesDays30d, toNumber(row.activeSalesDays30d, 0));
const salesSkuCode = normalizeSkuKey(row.skuCode);
if (salesSkuCode) {
summary.sourceSalesSkuCodes.add(salesSkuCode);
}
splitChannelNames(row.coveredChannels).forEach((channelName) => summary.coveredChannels.add(channelName));
if (summary.channelAmounts) {
addChannelAmountMap(summary.channelAmounts, parseChannelSalesAmounts(row.channelSalesAmounts));
}
const latestBizDate = formatDateOnly(row.latestBizDate);
if (latestBizDate && (!summary.latestBizDate || latestBizDate > summary.latestBizDate)) {
summary.latestBizDate = latestBizDate;
}
}
async function insertPlanningRows(tableName, columns, rows, options = {}) {
if (!Array.isArray(rows) || rows.length === 0) {
return 0;
}
const executeSql = typeof options.executor === 'function'
? options.executor
: (sql, params) => planningDatabase.execute(sql, params);
const chunkSize = Math.max(Number(options.chunkSize) || 500, 1);
const columnSql = columns.map((column) => escapeIdentifier(column)).join(', ');
const placeholder = `(${columns.map(() => '?').join(', ')})`;
let affectedRows = 0;
for (const rowChunk of chunkArray(rows, chunkSize)) {
const valueSql = rowChunk.map(() => placeholder).join(', ');
const params = [];
rowChunk.forEach((row) => {
columns.forEach((column) => {
params.push(row[column] ?? null);
});
});
const result = await executeSql(
`INSERT INTO ${escapeIdentifier(tableName)} (${columnSql}) VALUES ${valueSql}`,
params
);
affectedRows += Number(result.affectedRows || 0);
}
return affectedRows;
}
async function upsertPlanningRows(tableName, columns, rows, updateColumns = columns, options = {}) {
if (!Array.isArray(rows) || rows.length === 0) {
return 0;
}
const executeSql = typeof options.executor === 'function'
? options.executor
: (sql, params) => planningDatabase.execute(sql, params);
const chunkSize = Math.max(Number(options.chunkSize) || 300, 1);
const columnSql = columns.map((column) => escapeIdentifier(column)).join(', ');
const placeholder = `(${columns.map(() => '?').join(', ')})`;
const updateSql = updateColumns
.filter((column) => !['id', 'created_at'].includes(column))
.map((column) => `${escapeIdentifier(column)} = VALUES(${escapeIdentifier(column)})`)
.join(', ');
let affectedRows = 0;
for (const rowChunk of chunkArray(rows, chunkSize)) {
const valueSql = rowChunk.map(() => placeholder).join(', ');
const params = [];
rowChunk.forEach((row) => {
columns.forEach((column) => {
params.push(row[column] ?? null);
});
});
const result = await executeSql(
`INSERT INTO ${escapeIdentifier(tableName)} (${columnSql}) VALUES ${valueSql}
ON DUPLICATE KEY UPDATE ${updateSql}`,
params
);
affectedRows += Number(result.affectedRows || 0);
}
return affectedRows;
}
async function queryMayProductSalesSourceSkuCodesForStage() {
try {
await ensurePlanningMayProductProfileStageSchema();
const rows = await planningDatabase.query(
`SELECT may_sku_code AS skuCode
FROM ${escapeIdentifier(MAY_PRODUCT_PROFILE_STAGE_TABLE)}
WHERE NULLIF(TRIM(may_sku_code), '') IS NOT NULL
AND calc_version = ?
AND ${buildMayNotDiscontinuedCondition('sale_status')}`,
[MAY_PRODUCT_PROFILE_STAGE_CALC_VERSION]
);
return rows.map((row) => normalizeSkuKey(row.skuCode)).filter(Boolean);
} catch (error) {
console.warn('[PlanningMayProductData] 读取MAY商品SKU清单失败,销售预计算回退全量源:', error.message || error);
return [];
}
}
async function queryAllLocalSalesSourceSkuCodes(latestBizDate) {
if (!latestBizDate) {
return [];
}
await ensurePlanningChannelDailyReadSchema();
const excludedSkuCodes = CHANNEL_EXCLUDED_SKU_CODES.map((skuCode) => normalizeSkuKey(skuCode)).filter(Boolean);
const excludedSql = excludedSkuCodes.length > 0
? `AND UPPER(TRIM(d.sku_code)) NOT IN (${excludedSkuCodes.map(() => '?').join(', ')})`
: '';
const rows = await planningDatabase.query(
`
SELECT DISTINCT UPPER(TRIM(d.sku_code)) AS skuCode
FROM ${escapeIdentifier(CHANNEL_READ_TABLE)} d
WHERE d.stat_date BETWEEN DATE_SUB(?, INTERVAL 29 DAY) AND ?
AND NULLIF(TRIM(d.sku_code), '') IS NOT NULL
AND (COALESCE(d.sales_amount, 0) <> 0 OR COALESCE(d.ship_count, 0) <> 0)
${excludedSql}
`,
[latestBizDate, latestBizDate, ...excludedSkuCodes]
);
return rows.map((row) => normalizeSkuKey(row.skuCode)).filter(Boolean);
}
async function queryMayProductSalesProfileRowsForStage() {
await ensurePlanningMayProductProfileStageSchema();
const rows = await planningDatabase.query(
`SELECT may_sku_code AS skuCode, product_code AS productCode
FROM ${escapeIdentifier(MAY_PRODUCT_PROFILE_STAGE_TABLE)}
WHERE NULLIF(TRIM(may_sku_code), '') IS NOT NULL
AND calc_version = ?
AND ${buildMayNotDiscontinuedCondition('sale_status')}`,
[MAY_PRODUCT_PROFILE_STAGE_CALC_VERSION]
);
return rows.map((row) => ({
skuCode: normalizeSkuKey(row.skuCode),
productCode: normalizeSkuKey(row.productCode),
})).filter((row) => row.skuCode);
}
function buildUniqueMayProductCodeMap(profileRows = []) {
const maySkuSet = new Set();
const productCodeOwnerMap = new Map();
profileRows.forEach((row) => {
const skuCode = normalizeSkuKey(row.skuCode);
const productCode = normalizeSkuKey(row.productCode);
if (!skuCode) {
return;
}
maySkuSet.add(skuCode);
if (!productCode) {
return;
}
if (!productCodeOwnerMap.has(productCode)) {
productCodeOwnerMap.set(productCode, new Set());
}
productCodeOwnerMap.get(productCode).add(skuCode);
});
const uniqueProductCodeMap = new Map();
productCodeOwnerMap.forEach((owners, productCode) => {
if (owners.size === 1) {
uniqueProductCodeMap.set(productCode, Array.from(owners)[0]);
}
});
return {
maySkuSet,
uniqueProductCodeMap,
};
}
async function resolveMayProductSalesSourceMappingsForStage(sourceSkuCodes = [], options = {}) {
const normalizedSourceSkuCodes = Array.from(new Set(sourceSkuCodes.map(normalizeSkuKey).filter(Boolean)));
if (normalizedSourceSkuCodes.length === 0) {
return {
mappingMap: new Map(),
totalSourceSkuCount: 0,
exactMatchedSkuCount: 0,
productCodeMatchedSkuCount: 0,
unresolvedSourceSkuCount: 0,
};
}
const profileRows = await queryMayProductSalesProfileRowsForStage();
const { maySkuSet, uniqueProductCodeMap } = buildUniqueMayProductCodeMap(profileRows);
const mappingMap = new Map();
const unresolvedSourceSkuCodes = [];
normalizedSourceSkuCodes.forEach((sourceSkuCode) => {
if (maySkuSet.has(sourceSkuCode)) {
mappingMap.set(sourceSkuCode, {
maySkuCode: sourceSkuCode,
matchMode: 'exact_profile_stage',
sourceTable: MAY_PRODUCT_PROFILE_STAGE_TABLE,
priority: 0,
sourceRowCount: 1,
});
return;
}
unresolvedSourceSkuCodes.push(sourceSkuCode);
});
let productCodeMatchedSkuCount = 0;
if (MAY_PROFILE_USE_EXTRA_ENRICHMENT && unresolvedSourceSkuCodes.length > 0) {
const profileLookupItems = unresolvedSourceSkuCodes.map((skuCode) => ({
skuCode,
productCode: deriveProductCodeFromSku(skuCode),
}));
const resolvedProfileMap = await withFallback(
queryBusinessSkuProfiles(profileLookupItems),
new Map(),
'读取MAY销售SKU补充映射资料',
MAY_INVENTORY_ENRICHMENT_TIMEOUT_MS
);
unresolvedSourceSkuCodes.forEach((sourceSkuCode) => {
const resolvedProfile = resolvedProfileMap.get(sourceSkuCode) || {};
const resolvedProductCode = normalizeSkuKey(
resolvedProfile.productCode || deriveProductCodeFromSku(sourceSkuCode)
);
const maySkuCode = uniqueProductCodeMap.get(resolvedProductCode);
if (!maySkuCode) {
return;
}
mappingMap.set(sourceSkuCode, {
maySkuCode,
matchMode: resolvedProfile.productCode ? 'unique_product_code_profile' : 'unique_product_code_derived',
sourceTable: resolvedProfile.productCode ? 'business_profile_lookup' : 'derived_product_code',
priority: resolvedProfile.productCode ? 1 : 2,
sourceRowCount: 1,
});
productCodeMatchedSkuCount += 1;
});
}
if (typeof options.onProgress === 'function') {
options.onProgress({
stage: 'resolve_sales_source_mapping',
totalSourceSkuCount: normalizedSourceSkuCodes.length,
exactMatchedSkuCount: mappingMap.size - productCodeMatchedSkuCount,
productCodeMatchedSkuCount,
unresolvedSourceSkuCount: normalizedSourceSkuCodes.length - mappingMap.size,
});
}
return {
mappingMap,
totalSourceSkuCount: normalizedSourceSkuCodes.length,
exactMatchedSkuCount: mappingMap.size - productCodeMatchedSkuCount,
productCodeMatchedSkuCount,
unresolvedSourceSkuCount: normalizedSourceSkuCodes.length - mappingMap.size,
};
}
async function refreshPlanningMaySalesSummaryStage(options = {}) {
await ensurePlanningMaySalesStageSchema();
const latestBizDate = await getLatestMaySalesBizDate();
if (!latestBizDate) {
throw new Error('销售明细源表暂无最新业务日期,无法刷新MAY销售预计算');
}
if (options.skipIfFresh) {
const [stageRow] = await planningDatabase.query(
`SELECT COUNT(*) AS total, MAX(calc_version) AS calcVersion
FROM ${escapeIdentifier(MAY_PRODUCT_SALES_SUMMARY_STAGE_TABLE)}
WHERE stat_date = ?`,
[latestBizDate]
);
const stageCount = Number(stageRow?.total || 0);
const versionMatches = normalizeText(stageRow?.calcVersion) === MAY_PRODUCT_SALES_STAGE_CALC_VERSION;
if (stageCount > 0 && versionMatches) {
return {
success: true,
skipped: true,
latestBizDate,
summaryRowCount: stageCount,
};
}
}
const startedAt = Date.now();
const sourceChunkSize = Math.max(Number(options.sourceChunkSize) || 200, 50);
let sourceSkuCodes = [];
let salesSourceDiscoveryMode = 'local_channel_daily_read';
try {
sourceSkuCodes = await queryAllLocalSalesSourceSkuCodes(latestBizDate);
} catch (error) {
console.warn('[PlanningMayProductData] 读取本地渠道源SKU失败,回退MAY精确SKU清单:', error.message || error);
sourceSkuCodes = await queryMayProductSalesSourceSkuCodesForStage();
salesSourceDiscoveryMode = 'may_profile_exact_only';
}
const resolvedMappings = await resolveMayProductSalesSourceMappingsForStage(sourceSkuCodes, options);
const mappedSourceSkuCodes = Array.from(resolvedMappings.mappingMap.keys());
const sourceChunks = mappedSourceSkuCodes.length > 0
? chunkArray(mappedSourceSkuCodes, sourceChunkSize)
: [[]];
const salesRows = [];
let salesSourceMode = 'sqlserver';
if (mappedSourceSkuCodes.length > 0) {
try {
for (let index = 0; index < sourceChunks.length; index += 1) {
const skuChunk = sourceChunks[index];
salesRows.push(...await querySalesSourceSummaryRows(latestBizDate, skuChunk));
if (typeof options.onProgress === 'function') {
options.onProgress({
stage: 'source_sqlserver',
salesSourceMode: 'sqlserver',
salesSourceDiscoveryMode,
chunkIndex: index + 1,
chunkCount: sourceChunks.length,
sourceChunkSize,
latestBizDate,
collectedRowCount: salesRows.length,
mappedSourceSkuCount: mappedSourceSkuCodes.length,
});
}
}
} catch (error) {
console.warn('[PlanningMayProductData] 读取MAY财务销售源失败,回退本地渠道日读库:', error.message || error);
salesRows.length = 0;
for (let index = 0; index < sourceChunks.length; index += 1) {
const skuChunk = sourceChunks[index];
salesRows.push(...await queryLocalSalesSourceSummaryRows(latestBizDate, skuChunk));
if (typeof options.onProgress === 'function') {
options.onProgress({
stage: 'source_local_fallback',
salesSourceMode: 'planning_channel_daily_read',
salesSourceDiscoveryMode,
chunkIndex: index + 1,
chunkCount: sourceChunks.length,
sourceChunkSize,
latestBizDate,
collectedRowCount: salesRows.length,
mappedSourceSkuCount: mappedSourceSkuCodes.length,
});
}
}
salesSourceMode = 'planning_channel_daily_read';
}
}
if (salesSourceMode === 'sqlserver' && mappedSourceSkuCodes.length > salesRows.length) {
const fetchedSourceSkuSet = new Set(salesRows.map((row) => normalizeSkuKey(row.skuCode)).filter(Boolean));
const missingSourceSkuCodes = mappedSourceSkuCodes.filter((skuCode) => !fetchedSourceSkuSet.has(skuCode));
if (missingSourceSkuCodes.length > 0) {
const localRows = [];
for (const skuChunk of chunkArray(missingSourceSkuCodes, sourceChunkSize)) {
localRows.push(...await queryLocalSalesSourceSummaryRows(latestBizDate, skuChunk));
}
salesRows.push(...localRows);
if (typeof options.onProgress === 'function') {
options.onProgress({
stage: 'source_local_patch_missing',
salesSourceMode: 'sqlserver+planning_channel_daily_read',
salesSourceDiscoveryMode,
latestBizDate,
patchedRowCount: localRows.length,
missingSourceSkuCount: missingSourceSkuCodes.length,
});
}
}
}
if (salesSourceMode === 'sqlserver' && salesRows.length > 0) {
const channelCoverageMap = await queryLocalChannelCoverageMap(
latestBizDate,
salesRows.map((row) => row.skuCode)
);
salesRows.forEach((row) => {
const localChannelCoverage = channelCoverageMap.get(normalizeSkuKey(row.skuCode));
if (!localChannelCoverage) {
return;
}
row.coveredChannels = localChannelCoverage.coveredChannels;
row.channelSalesAmounts = localChannelCoverage.channelSalesAmounts;
});
if (typeof options.onProgress === 'function') {
options.onProgress({
stage: 'local_channel_enrichment',
salesSourceMode,
latestBizDate,
coveredSkuCount: channelCoverageMap.size,
});
}
}
const refreshedAt = new Date();
const mappingRows = [];
const summaryMap = new Map();
let unmatchedSalesSkuCount = Math.max(
Number(resolvedMappings.unresolvedSourceSkuCount || 0),
0
);
salesRows.forEach((row) => {
const salesSkuCode = normalizeSkuKey(row.skuCode);
if (!salesSkuCode) {
return;
}
const resolvedMapping = resolvedMappings.mappingMap.get(salesSkuCode);
const mapping = resolvedMapping
? {
skuCode: resolvedMapping.maySkuCode,
sourceTable: resolvedMapping.sourceTable,
matchMode: resolvedMapping.matchMode,
priority: resolvedMapping.priority,
sourceRowCount: resolvedMapping.sourceRowCount,
}
: null;
if (!mapping) {
return;
}
const maySkuCode = normalizeSkuKey(mapping.skuCode || salesSkuCode);
if (!maySkuCode) {
unmatchedSalesSkuCount += 1;
return;
}
mappingRows.push({
stat_date: latestBizDate,
sales_sku_code: salesSkuCode,
may_sku_code: maySkuCode,
match_mode: mapping.matchMode,
source_table: mapping.sourceTable,
match_priority: mapping.priority,
source_row_count: mapping.sourceRowCount,
calc_version: MAY_PRODUCT_SALES_STAGE_CALC_VERSION,
refreshed_at: refreshedAt,
});
if (!summaryMap.has(maySkuCode)) {
summaryMap.set(maySkuCode, {
stat_date: latestBizDate,
may_sku_code: maySkuCode,
sourceSalesSkuCodes: new Set(),
matchModes: new Set(),
coveredChannels: new Set(),
channelAmounts: createChannelAmountMap(),
performanceAmount30d: 0,
transactionAmount30d: 0,
salesQty30d: 0,
salesQty7d: 0,
salesQtyLast15d: 0,
salesQtyPrevious15d: 0,
afterSaleAmount30d: 0,
grossCostAmount30d: 0,
promotionFee30d: 0,
goodsIdCount30d: 0,
activeSalesDays30d: 0,
latestBizDate: null,
});
}
const summary = summaryMap.get(maySkuCode);
summary.matchModes.add(mapping.matchMode);
addSalesSummaryMetric(summary, row);
});
const summaryRows = Array.from(summaryMap.values()).map((summary) => {
const channelRatios = buildChannelSalesRatios(summary.channelAmounts, summary.performanceAmount30d);
return {
stat_date: summary.stat_date,
may_sku_code: summary.may_sku_code,
source_sales_sku_count: summary.sourceSalesSkuCodes.size,
source_sales_sku_codes: Array.from(summary.sourceSalesSkuCodes).slice(0, 200).join('、'),
match_mode_summary: Array.from(summary.matchModes).join('、'),
performance_amount_30d: roundNumber(summary.performanceAmount30d, 2),
transaction_amount_30d: roundNumber(summary.transactionAmount30d, 2),
sales_qty_30d: roundNumber(summary.salesQty30d, 2),
sales_qty_7d: roundNumber(summary.salesQty7d, 2),
sales_qty_last15d: roundNumber(summary.salesQtyLast15d, 2),
sales_qty_previous15d: roundNumber(summary.salesQtyPrevious15d, 2),
after_sale_amount_30d: roundNumber(summary.afterSaleAmount30d, 2),
gross_cost_amount_30d: roundNumber(summary.grossCostAmount30d, 2),
promotion_fee_30d: roundNumber(summary.promotionFee30d, 2),
goods_id_count_30d: Math.round(summary.goodsIdCount30d),
covered_channels: Array.from(summary.coveredChannels).slice(0, 50).join('、'),
...Object.fromEntries(MAY_CHANNEL_RATIO_FIELDS.map((field) => [field.stageColumn, channelRatios[field.key] || 0])),
active_sales_days_30d: Math.min(Math.round(summary.activeSalesDays30d), 30),
latest_biz_date: summary.latestBizDate || latestBizDate,
calc_version: MAY_PRODUCT_SALES_STAGE_CALC_VERSION,
refreshed_at: refreshedAt,
};
});
await planningDatabase.withTransaction(async (connection) => {
await connection.query(
`DELETE FROM ${escapeIdentifier(MAY_PRODUCT_SALES_MAPPING_STAGE_TABLE)} WHERE stat_date = ?`,
[latestBizDate]
);
await connection.query(
`DELETE FROM ${escapeIdentifier(MAY_PRODUCT_SALES_SUMMARY_STAGE_TABLE)} WHERE stat_date = ?`,
[latestBizDate]
);
});
await insertPlanningRows(
MAY_PRODUCT_SALES_MAPPING_STAGE_TABLE,
[
'stat_date',
'sales_sku_code',
'may_sku_code',
'match_mode',
'source_table',
'match_priority',
'source_row_count',
'calc_version',
'refreshed_at',
],
mappingRows
);
await insertPlanningRows(
MAY_PRODUCT_SALES_SUMMARY_STAGE_TABLE,
[
'stat_date',
'may_sku_code',
'source_sales_sku_count',
'source_sales_sku_codes',
'match_mode_summary',
'performance_amount_30d',
'transaction_amount_30d',
'sales_qty_30d',
'sales_qty_7d',
'sales_qty_last15d',
'sales_qty_previous15d',
'after_sale_amount_30d',
'gross_cost_amount_30d',
'promotion_fee_30d',
'goods_id_count_30d',
'covered_channels',
...MAY_CHANNEL_RATIO_FIELDS.map((field) => field.stageColumn),
'active_sales_days_30d',
'latest_biz_date',
'calc_version',
'refreshed_at',
],
summaryRows
);
return {
success: true,
latestBizDate,
salesSourceMode,
salesSourceDiscoveryMode,
salesSourceSkuCount: Number(resolvedMappings.totalSourceSkuCount || 0),
mappedSalesSkuCount: mappingRows.length,
exactMatchedSalesSkuCount: Number(resolvedMappings.exactMatchedSkuCount || 0),
productCodeMatchedSalesSkuCount: Number(resolvedMappings.productCodeMatchedSkuCount || 0),
unmatchedSalesSkuCount,
summaryRowCount: summaryRows.length,
elapsedMs: Date.now() - startedAt,
};
}
async function querySkuSalesStageMap(skuCodes = []) {
const uniqueSkuCodes = Array.from(new Set(skuCodes.map(normalizeSkuKey).filter(Boolean)));
const salesMap = new Map();
if (uniqueSkuCodes.length === 0) {
return { ready: false, salesMap };
}
try {
await ensurePlanningMaySalesStageSchema();
const [latestRow] = await planningDatabase.query(
`SELECT DATE_FORMAT(MAX(stat_date), '%Y-%m-%d') AS latestBizDate
FROM ${escapeIdentifier(MAY_PRODUCT_SALES_SUMMARY_STAGE_TABLE)}
WHERE calc_version = ?`,
[MAY_PRODUCT_SALES_STAGE_CALC_VERSION]
);
const latestBizDate = normalizeText(latestRow?.latestBizDate);
if (!latestBizDate) {
return { ready: false, salesMap };
}
for (const skuChunk of chunkArray(uniqueSkuCodes, 500)) {
const rows = await planningDatabase.query(
`
SELECT
may_sku_code AS skuCode,
latest_biz_date AS latestBizDate,
performance_amount_30d AS performanceAmount30d,
transaction_amount_30d AS transactionAmount30d,
sales_qty_30d AS salesQty30d,
sales_qty_7d AS salesQty7d,
sales_qty_last15d AS salesQtyLast15d,
sales_qty_previous15d AS salesQtyPrevious15d,
after_sale_amount_30d AS afterSaleAmount30d,
gross_cost_amount_30d AS grossCostAmount30d,
promotion_fee_30d AS promotionFee30d,
goods_id_count_30d AS goodsIdCount30d,
covered_channels AS coveredChannels,
${MAY_CHANNEL_RATIO_FIELDS.map((field) => `${escapeIdentifier(field.stageColumn)} AS ${field.stageColumn}`).join(',\n ')},
active_sales_days_30d AS activeSalesDays30d
FROM ${escapeIdentifier(MAY_PRODUCT_SALES_SUMMARY_STAGE_TABLE)}
WHERE stat_date = ?
AND calc_version = ?
AND may_sku_code IN (${skuChunk.map(() => '?').join(', ')})
`,
[latestBizDate, MAY_PRODUCT_SALES_STAGE_CALC_VERSION, ...skuChunk]
);
rows.forEach((row) => {
const skuKey = normalizeSkuKey(row.skuCode);
if (!skuKey) {
return;
}
const performanceAmount30d = toNumber(row.performanceAmount30d, 0);
const transactionAmount30d = toNumber(row.transactionAmount30d, 0);
salesMap.set(skuKey, {
performanceAmount30d,
salesAmount30d: resolveMaySalesAmount30d(transactionAmount30d, performanceAmount30d),
transactionAmount30d,
salesQty30d: toNumber(row.salesQty30d, 0),
salesQty7d: toNumber(row.salesQty7d, 0),
salesQtyLast15d: toNumber(row.salesQtyLast15d, 0),
salesQtyPrevious15d: toNumber(row.salesQtyPrevious15d, 0),
afterSaleAmount30d: toNumber(row.afterSaleAmount30d, 0),
grossCostAmount30d: toNumber(row.grossCostAmount30d, 0),
goodsIdCount30d: toNumber(row.goodsIdCount30d, 0),
promotionFee30d: toNumber(row.promotionFee30d, 0),
coveredChannels: normalizeText(row.coveredChannels),
channelRatios: Object.fromEntries(MAY_CHANNEL_RATIO_FIELDS.map((field) => [
field.key,
toNumber(row[field.stageColumn], 0),
])),
activeSalesDays30d: toNumber(row.activeSalesDays30d, 0),
latestBizDate: row.latestBizDate || latestBizDate,
});
});
}
return { ready: true, salesMap, latestBizDate };
} catch (error) {
console.warn('[PlanningMayProductData] 读取MAY销售预计算失败,回退实时计算:', error.message || error);
return { ready: false, salesMap };
}
}
async function querySkuSalesMap(skuCodes = []) {
const uniqueSkuCodes = Array.from(new Set(skuCodes.map(normalizeSkuKey).filter(Boolean)));
const salesMap = new Map();
if (uniqueSkuCodes.length === 0) {
return salesMap;
}
const stagedSales = await querySkuSalesStageMap(uniqueSkuCodes);
if (stagedSales.ready) {
stagedSales.salesMap.forEach((value, key) => {
salesMap.set(key, value);
});
}
const lookupSkuCodes = stagedSales.ready
? uniqueSkuCodes.filter((skuCode) => !salesMap.has(skuCode))
: uniqueSkuCodes;
if (lookupSkuCodes.length === 0) {
return stagedSales.salesMap;
}
await Promise.all([
ensurePlanningChannelDailyReadSchema(),
ensurePlanningPddSkuDimensionSchema(),
]);
const latestBizDate = stagedSales.latestBizDate || await getLatestMaySalesBizDate();
if (!latestBizDate) {
return salesMap;
}
for (const skuChunk of chunkArray(lookupSkuCodes, 300)) {
const channelRows = await planningDatabase.query(
`
SELECT
UPPER(TRIM(d.sku_code)) AS skuCode,
MAX(d.stat_date) AS latestBizDate,
SUM(COALESCE(d.sales_amount, 0)) AS salesAmount30d,
SUM(COALESCE(d.ship_count, 0)) AS salesQty30d,
SUM(CASE WHEN d.stat_date BETWEEN DATE_SUB(?, INTERVAL 6 DAY) AND ? THEN COALESCE(d.ship_count, 0) ELSE 0 END) AS salesQty7d,
SUM(CASE WHEN d.stat_date BETWEEN DATE_SUB(?, INTERVAL 14 DAY) AND ? THEN COALESCE(d.ship_count, 0) ELSE 0 END) AS salesQtyLast15d,
SUM(CASE WHEN d.stat_date BETWEEN DATE_SUB(?, INTERVAL 29 DAY) AND DATE_SUB(?, INTERVAL 15 DAY) THEN COALESCE(d.ship_count, 0) ELSE 0 END) AS salesQtyPrevious15d,
SUM(COALESCE(d.after_sale_amount, 0)) AS afterSaleAmount30d,
COUNT(DISTINCT CASE WHEN COALESCE(d.sales_amount, 0) > 0 OR COALESCE(d.ship_count, 0) > 0 THEN d.stat_date END) AS activeSalesDays30d,
GROUP_CONCAT(DISTINCT NULLIF(TRIM(d.shop_name), '') SEPARATOR '、') AS coveredChannels
FROM ${escapeIdentifier(CHANNEL_READ_TABLE)} d
WHERE d.stat_date BETWEEN DATE_SUB(?, INTERVAL 29 DAY) AND ?
AND d.sku_code IN (${skuChunk.map(() => '?').join(', ')})
GROUP BY UPPER(TRIM(d.sku_code))
`,
[
latestBizDate, latestBizDate,
latestBizDate, latestBizDate,
latestBizDate, latestBizDate,
latestBizDate, latestBizDate,
...skuChunk,
]
);
channelRows.forEach((row) => {
const skuKey = normalizeSkuKey(row.skuCode);
if (!skuKey) {
return;
}
salesMap.set(skuKey, {
performanceAmount30d: toNumber(row.salesAmount30d, 0),
salesAmount30d: toNumber(row.salesAmount30d, 0),
transactionAmount30d: toNumber(row.salesAmount30d, 0),
salesQty30d: toNumber(row.salesQty30d, 0),
salesQty7d: toNumber(row.salesQty7d, 0),
salesQtyLast15d: toNumber(row.salesQtyLast15d, 0),
salesQtyPrevious15d: toNumber(row.salesQtyPrevious15d, 0),
afterSaleAmount30d: toNumber(row.afterSaleAmount30d, 0),
goodsIdCount30d: 0,
coveredChannels: normalizeText(row.coveredChannels),
activeSalesDays30d: toNumber(row.activeSalesDays30d, 0),
latestBizDate: row.latestBizDate || null,
});
});
const rows = await planningDatabase.query(
`
SELECT
UPPER(TRIM(s.skuCode)) AS skuCode,
MAX(s.endDate) AS latestBizDate,
SUM(COALESCE(s.payOrdrAmt, 0)) AS salesAmount30d,
SUM(COALESCE(s.payOrdrGoodsQty, 0)) AS salesQty30d,
0 AS salesQty7d,
0 AS salesQtyLast15d,
0 AS salesQtyPrevious15d,
SUM(COALESCE(s.payOrdrAmt, 0) * COALESCE(s.afterSaleRate, 0) / 100) AS afterSaleAmount30d,
SUM(COALESCE(s.goodsIdCount, 0)) AS goodsIdCount30d,
GROUP_CONCAT(DISTINCT NULLIF(TRIM(s.shopNameList), '') SEPARATOR '、') AS coveredChannels,
CASE WHEN SUM(COALESCE(s.payOrdrGoodsQty, 0)) > 0 THEN 30 ELSE 0 END AS activeSalesDays30d
FROM ${escapeIdentifier(PDD_SKU_DIMENSION_SKU_TABLE)} s
WHERE s.statDate = (
SELECT MAX(latest.statDate)
FROM ${escapeIdentifier(PDD_SKU_DIMENSION_SKU_TABLE)} latest
)
AND s.skuCode IN (${skuChunk.map(() => '?').join(', ')})
GROUP BY UPPER(TRIM(s.skuCode))
`,
skuChunk
);
rows.forEach((row) => {
const skuKey = normalizeSkuKey(row.skuCode);
const current = salesMap.get(skuKey);
const snapshotSummary = {
performanceAmount30d: toNumber(row.salesAmount30d, 0),
salesAmount30d: toNumber(row.salesAmount30d, 0),
transactionAmount30d: toNumber(row.salesAmount30d, 0),
salesQty30d: toNumber(row.salesQty30d, 0),
salesQty7d: toNumber(row.salesQty7d, 0),
salesQtyLast15d: toNumber(row.salesQtyLast15d, 0),
salesQtyPrevious15d: toNumber(row.salesQtyPrevious15d, 0),
afterSaleAmount30d: toNumber(row.afterSaleAmount30d, 0),
grossCostAmount30d: 0,
goodsIdCount30d: toNumber(row.goodsIdCount30d, 0),
promotionFee30d: 0,
coveredChannels: normalizeText(row.coveredChannels),
activeSalesDays30d: toNumber(row.activeSalesDays30d, 0),
latestBizDate: row.latestBizDate || null,
};
if (!current) {
salesMap.set(skuKey, snapshotSummary);
return;
}
salesMap.set(skuKey, {
...current,
goodsIdCount30d: snapshotSummary.goodsIdCount30d || current.goodsIdCount30d,
coveredChannels: current.coveredChannels || snapshotSummary.coveredChannels,
});
});
const financialRows = await withFallback(
querySalesSourceSummaryRows(latestBizDate, skuChunk),
[],
'获取渠道财务补充',
MAY_FINANCIAL_ENRICHMENT_TIMEOUT_MS
);
financialRows.forEach((row) => {
const skuKey = normalizeSkuKey(row.skuCode);
if (!skuKey) {
return;
}
const current = salesMap.get(skuKey) || buildDefaultSalesSummary();
const performanceAmount30d = toNumber(row.performanceAmount30d, 0);
const transactionAmount30d = toNumber(row.transactionAmount30d, 0);
salesMap.set(skuKey, {
...current,
performanceAmount30d,
salesAmount30d: resolveMaySalesAmount30d(transactionAmount30d, performanceAmount30d, current.salesAmount30d),
transactionAmount30d,
salesQty30d: toNumber(row.salesQty30d, 0),
salesQty7d: toNumber(row.salesQty7d, 0),
salesQtyLast15d: toNumber(row.salesQtyLast15d, 0),
salesQtyPrevious15d: toNumber(row.salesQtyPrevious15d, 0),
afterSaleAmount30d: toNumber(row.afterSaleAmount30d, 0),
grossCostAmount30d: toNumber(row.grossCostAmount30d, 0),
goodsIdCount30d: toNumber(row.goodsIdCount30d, 0) || current.goodsIdCount30d,
promotionFee30d: toNumber(row.promotionFee30d, 0),
activeSalesDays30d: toNumber(row.activeSalesDays30d, 0),
latestBizDate: row.latestBizDate || current.latestBizDate,
});
});
}
return salesMap;
}
async function querySkuPromotionFeeMap(skuCodes = []) {
const uniqueSkuCodes = Array.from(new Set(skuCodes.map(normalizeSkuKey).filter(Boolean)));
const promotionMap = new Map();
if (uniqueSkuCodes.length === 0) {
return promotionMap;
}
for (const skuChunk of chunkArray(uniqueSkuCodes, 300)) {
await ensurePlanningPddSkuDimensionSchema();
const rows = await planningDatabase.query(
`SELECT
UPPER(TRIM(s.skuCode)) AS skuCode,
SUM(COALESCE(s.spend, 0)) AS promotionFee30d
FROM ${escapeIdentifier(PDD_SKU_DIMENSION_SKU_TABLE)} s
WHERE s.statDate = (
SELECT MAX(latest.statDate)
FROM ${escapeIdentifier(PDD_SKU_DIMENSION_SKU_TABLE)} latest
)
AND s.skuCode IN (${skuChunk.map(() => '?').join(', ')})
GROUP BY UPPER(TRIM(s.skuCode))`,
skuChunk
);
rows.forEach((row) => {
const skuKey = normalizeSkuKey(row.skuCode);
if (!skuKey) {
return;
}
promotionMap.set(skuKey, toNumber(row.promotionFee30d, 0));
});
}
return promotionMap;
}
async function querySkuLimitPriceMap(baseRows = [], profileMap = new Map()) {
const rowCandidateMap = new Map();
baseRows.forEach((row) => {
const skuKey = normalizeSkuKey(row.skuCode);
if (!skuKey) {
return;
}
const profile = profileMap.get(skuKey) || {};
const candidates = buildLimitLookupCandidates(row, profile);
rowCandidateMap.set(skuKey, candidates);
});
const limitMap = new Map();
if (rowCandidateMap.size === 0) {
return limitMap;
}
try {
const skuKeys = Array.from(rowCandidateMap.keys());
for (const skuChunk of chunkArray(skuKeys, 500)) {
const rows = await planningDatabase.query(
`
SELECT sku_code AS skuCode, matched_code AS matchedCode, limit_price AS limitPrice
FROM planning_may_price_limit_stage
WHERE sku_code IN (${skuChunk.map(() => '?').join(', ')})
`,
skuChunk
);
rows.forEach((row) => {
const skuKey = normalizeSkuKey(row.skuCode);
const matchedCode = normalizeSkuKey(row.matchedCode);
const candidates = rowCandidateMap.get(skuKey) || [];
const limitPrice = toNullableNumber(row.limitPrice, 2);
const matchedCodeValid = !matchedCode || candidates.some((candidate) => normalizeSkuKey(candidate) === matchedCode);
if (skuKey && matchedCodeValid && limitPrice !== null && limitPrice > 0) {
limitMap.set(skuKey, limitPrice);
}
});
}
} catch (error) {
console.warn('[PlanningMayProductData] 读取最低限价stage失败,回退业务库:', error.message || error);
}
const pendingRowCandidateMap = new Map(
Array.from(rowCandidateMap.entries()).filter(([skuKey]) => !limitMap.has(skuKey))
);
const uniqueCandidates = Array.from(new Set(
Array.from(pendingRowCandidateMap.values())
.flat()
.map(normalizeText)
.filter(Boolean)
));
if (uniqueCandidates.length === 0) {
return limitMap;
}
const candidateLimitMap = new Map();
const assignCandidateLimit = (candidate, limitPrice) => {
const candidateKey = normalizeSkuKey(candidate);
const normalizedLimitPrice = toNullableNumber(limitPrice, 2);
if (!candidateKey || normalizedLimitPrice === null || normalizedLimitPrice <= 0) {
return;
}
const currentLimitPrice = candidateLimitMap.get(candidateKey);
if (!Number.isFinite(Number(currentLimitPrice)) || normalizedLimitPrice < Number(currentLimitPrice)) {
candidateLimitMap.set(candidateKey, normalizedLimitPrice);
}
};
for (const candidateChunk of chunkArray(uniqueCandidates, 300)) {
const params = {};
const candidatePlaceholders = candidateChunk.map((candidate, index) => {
const paramName = `candidate${index}`;
params[paramName] = candidate;
return `@${paramName}`;
}).join(', ');
const tableResults = await Promise.all(MAY_PRICE_LIMIT_TABLE_SPECS.map(async (spec) => {
const keySelectSql = spec.keyFields
.map((fieldName) => `NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), src.[${fieldName}]))), N'') AS [${fieldName}]`)
.join(',\n ');
const limitSelectSql = spec.limitFields
.map((fieldName) => `${buildPriceLimitValueExpression('src', fieldName)} AS [${fieldName}]`)
.join(',\n ');
const matchCondition = spec.keyFields
.map((fieldName) => `NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), src.[${fieldName}]))), N'') IN (${candidatePlaceholders})`)
.join('\n OR ');
const rows = await withFallback(
cloudDb.query(
`
SELECT
${keySelectSql},
${limitSelectSql}
FROM dbo.[${spec.tableName}] src WITH (NOLOCK)
WHERE ${matchCondition}
`,
params,
{ requestTimeout: Math.min(PRICE_REQUEST_TIMEOUT_MS, MAY_PRICE_LIMIT_TABLE_TIMEOUT_MS), maxAttempts: 1 }
),
[],
`获取最低限价:${spec.tableName}`,
MAY_PRICE_LIMIT_TABLE_TIMEOUT_MS
);
return { spec, rows };
}));
tableResults.forEach(({ spec, rows }) => {
rows.forEach((row) => {
const limitValues = spec.limitFields
.map((fieldName) => toNullableNumber(row[fieldName], 2))
.filter((value) => Number.isFinite(Number(value)) && value > 0);
if (limitValues.length === 0) {
return;
}
const lowestLimitPrice = Math.min(...limitValues);
spec.keyFields.forEach((fieldName) => {
assignCandidateLimit(row[fieldName], lowestLimitPrice);
});
});
});
}
pendingRowCandidateMap.forEach((candidates, skuKey) => {
candidates.forEach((candidate) => {
const candidateKey = normalizeSkuKey(candidate);
const lowestLimitPrice = candidateLimitMap.get(candidateKey);
if (!Number.isFinite(Number(lowestLimitPrice))) {
return;
}
const currentLimitPrice = limitMap.get(skuKey);
if (!Number.isFinite(Number(currentLimitPrice)) || Number(lowestLimitPrice) < Number(currentLimitPrice)) {
limitMap.set(skuKey, Number(lowestLimitPrice));
}
});
});
return limitMap;
}
function buildMayProductRow(baseRow = {}, maps = {}) {
const skuKey = normalizeSkuKey(baseRow.skuCode);
const enrichedProfile = maps.profileMap.get(skuKey) || {};
const profile = {
productCode: firstMeaningfulText(enrichedProfile.productCode, baseRow.productCode, baseRow.spuCode),
skuName: firstMeaningfulText(enrichedProfile.skuName, baseRow.skuName, baseRow.productName),
skuSize: firstMeaningfulText(enrichedProfile.skuSize, baseRow.skuSize),
material: firstMeaningfulText(enrichedProfile.material, baseRow.materialTag, extractMaterialFromText(baseRow.skuName || baseRow.productName)),
colorName: firstMeaningfulText(enrichedProfile.colorName, baseRow.colorTag, extractColorFromText(baseRow.skuName || baseRow.productName)),
productCost: Number.isFinite(Number(enrichedProfile.productCost)) ? enrichedProfile.productCost : baseRow.productCost,
salePrice: Number.isFinite(Number(enrichedProfile.salePrice)) ? enrichedProfile.salePrice : baseRow.salePrice,
saleStatus: resolveMaySaleStatus(enrichedProfile.saleStatus, baseRow.saleStatus),
virtualCategory: resolveMayVirtualCategory(enrichedProfile.virtualCategory, baseRow.virtualCategory),
skuImageUrl: firstMeaningfulText(enrichedProfile.skuImageUrl, baseRow.skuImageUrl, baseRow.imageUrl),
skuLength: Number.isFinite(Number(enrichedProfile.skuLength)) ? enrichedProfile.skuLength : baseRow.skuLength,
skuWidth: Number.isFinite(Number(enrichedProfile.skuWidth)) ? enrichedProfile.skuWidth : baseRow.skuWidth,
skuHeight: Number.isFinite(Number(enrichedProfile.skuHeight)) ? enrichedProfile.skuHeight : baseRow.skuHeight,
createdAt: enrichedProfile.createdAt || baseRow.createdAt,
};
const inventory = maps.inventoryMap.get(skuKey) || {
...createEmptyMayInventorySummary(),
inventoryScopeRemark: '',
};
const sales = maps.salesMap.get(skuKey) || buildDefaultSalesSummary();
const freight = maps.freightMap.get(skuKey) || {};
const listing = maps.listingMap.get(skuKey) || {};
const promotionFeeFromMap = maps.promotionFeeMap?.get(skuKey);
const promotionFee30d = Number.isFinite(Number(sales.promotionFee30d)) && Number(sales.promotionFee30d) > 0
? toNumber(sales.promotionFee30d, 0)
: toNumber(promotionFeeFromMap, 0);
const lowestLimitPrice = maps.limitPriceMap.get(skuKey) || null;
const skuName = firstMeaningfulText(profile.skuName, baseRow.productName);
const size = firstMeaningfulText(
profile.skuSize,
formatDimension(profile.skuLength, profile.skuWidth, profile.skuHeight),
extractSizeFromText(skuName)
);
const colorName = firstMeaningfulText(profile.colorName, baseRow.colorTag, extractColorFromText(skuName));
const resolvedCategoryName = resolveMayCategoryName(
enrichedProfile.categoryName,
baseRow.categoryName,
baseRow.categoryTag,
inferMayCategoryNameFromText(skuName, baseRow.productName, baseRow.skuName)
);
const resolvedSupplierName = resolveMaySupplierName(
enrichedProfile.supplierName,
baseRow.supplierName
);
const avgDailySales30d = sales.salesQty30d > 0 ? sales.salesQty30d / 30 : 0;
const avgDailySales7d = sales.salesQty7d > 0 ? sales.salesQty7d / 7 : 0;
const estimatedSalesDays = avgDailySales30d > 0 ? inventory.realCompanyStockQty / avgDailySales30d : null;
const salesGrowthRate15d = sales.salesQtyPrevious15d > 0
? ((sales.salesQtyLast15d - sales.salesQtyPrevious15d) / sales.salesQtyPrevious15d) * 100
: (sales.salesQtyLast15d > 0 ? 100 : 0);
const dStyleEfficiency = sales.goodsIdCount30d > 0
? sales.salesAmount30d / sales.goodsIdCount30d
: 0;
const afterSalesRatio = sales.salesAmount30d > 0
? (sales.afterSaleAmount30d / sales.salesAmount30d) * 100
: 0;
const activeSalesDayRatio = (sales.activeSalesDays30d / 30) * 100;
const unitCost = toNumber(profile.productCost, 0) + toNumber(freight.avgFreight, 0);
const avgSalesUnitPrice = sales.salesQty30d > 0 && sales.salesAmount30d > 0
? sales.salesAmount30d / sales.salesQty30d
: null;
const grossMarginRate = sales.salesAmount30d > 0 && sales.grossCostAmount30d > 0
? ((sales.salesAmount30d - sales.grossCostAmount30d) / sales.salesAmount30d) * 100
: (
avgSalesUnitPrice && unitCost > 0
? ((avgSalesUnitPrice - unitCost) / avgSalesUnitPrice) * 100
: null
);
const listingStartAt = listing.firstShopListingAt || null;
const listingDurationDays = getListingDurationDays(listingStartAt, sales.latestBizDate);
return {
may_appearance_recognition_code: firstMeaningfulText(baseRow.appearanceCode),
may_sku_image_url: firstMeaningfulText(baseRow.imageUrl, profile.skuImageUrl),
may_sku_code: firstMeaningfulText(baseRow.skuCode),
may_virtual_category: profile.virtualCategory || MAY_EMPTY_VALUE,
resolved_category_name: resolvedCategoryName,
resolved_supplier_name: resolvedSupplierName,
may_sales_status: profile.saleStatus || MAY_EMPTY_VALUE,
may_core_material: firstMeaningfulText(profile.material, baseRow.materialTag, extractMaterialFromText(skuName)),
may_min_limit_price: lowestLimitPrice ? formatNumberCell(lowestLimitPrice, 2) : MAY_EMPTY_VALUE,
may_size: size || MAY_EMPTY_VALUE,
may_color_name: colorName || MAY_EMPTY_VALUE,
may_performance_amount: formatNumberCell(sales.salesAmount30d, 2, 0),
may_goods_id_count: formatNumberCell(sales.goodsIdCount30d, 0, 0),
may_covered_channels: sales.coveredChannels || MAY_EMPTY_VALUE,
...Object.fromEntries(MAY_CHANNEL_RATIO_FIELDS.map((field) => [
field.snapshotColumn,
formatPercentCell(sales.channelRatios?.[field.key] || 0, 2),
])),
may_promotion_fee_30d: formatNumberCell(promotionFee30d, 2, 0),
may_d_style_efficiency: formatNumberCell(dStyleEfficiency, 2, 0),
may_inventory_quantity: formatNumberCell(inventory.stockQty, 0, 0),
may_real_company_inventory_quantity: formatNumberCell(inventory.realCompanyStockQty, 0, 0),
may_estimated_sales_days: estimatedSalesDays === null ? MAY_EMPTY_VALUE : formatNumberCell(estimatedSalesDays, 1, 0),
may_shipping_warehouse: inventory.warehouses.length > 0 ? inventory.warehouses.join('、') : MAY_EMPTY_VALUE,
may_inventory_scope_remark: inventory.inventoryScopeRemark || MAY_EMPTY_VALUE,
may_avg_daily_sales_30d: formatNumberCell(avgDailySales30d, 2, 0),
may_avg_daily_sales_7d: formatNumberCell(avgDailySales7d, 2, 0),
may_sales_growth_rate_15d: formatPercentCell(salesGrowthRate15d, 1),
may_listing_duration_days: listingDurationDays === null ? MAY_EMPTY_VALUE : `${listingDurationDays} 天`,
may_gross_margin_rate: grossMarginRate === null ? MAY_EMPTY_VALUE : formatPercentCell(grossMarginRate, 1),
may_after_sales_ratio: formatPercentCell(afterSalesRatio, 1),
may_active_sales_day_ratio_30d: formatPercentCell(activeSalesDayRatio, 1),
};
}
async function hydrateMayProductDisplayRows(baseRows = []) {
const skuCodes = baseRows.map((row) => row.skuCode);
const [profileMap, inventoryMap, salesMap, freightMap, listingMap, promotionFeeMap] = await Promise.all([
withFallback(queryBusinessSkuProfiles(baseRows), new Map(), '获取商品资料', MAY_INVENTORY_ENRICHMENT_TIMEOUT_MS),
withFallback(querySkuInventoryMap(baseRows), new Map(), '获取仓库库存', MAY_INVENTORY_ENRICHMENT_TIMEOUT_MS + 3000),
withFallback(querySkuSalesMap(skuCodes), new Map(), '获取近30天销售数据', MAY_SALES_ENRICHMENT_TIMEOUT_MS),
ENABLE_MAY_FREIGHT_ENRICHMENT
? withFallback(querySkuFreightMap(skuCodes), new Map(), '获取全国平均运费', OPTIONAL_ENRICHMENT_TIMEOUT_MS)
: Promise.resolve(new Map()),
withFallback(querySkuShopListingMap(skuCodes), new Map(), '获取店铺铺货时间', OPTIONAL_ENRICHMENT_TIMEOUT_MS),
withFallback(querySkuPromotionFeeMap(skuCodes), new Map(), '获取近30天推广费用', OPTIONAL_ENRICHMENT_TIMEOUT_MS),
]);
const limitPriceMap = await withFallback(
querySkuLimitPriceMap(baseRows, profileMap),
new Map(),
'获取最低限价',
CRITICAL_ENRICHMENT_TIMEOUT_MS
);
return baseRows.map((row) => buildMayProductRow(row, {
profileMap,
inventoryMap,
salesMap,
freightMap,
listingMap,
promotionFeeMap,
limitPriceMap,
}));
}
function normalizeSnapshotText(value) {
const text = normalizeText(value);
return text && text !== MAY_EMPTY_VALUE ? text : null;
}
function buildMayProductSnapshotRow(baseRow = {}, displayRow = {}, context = {}) {
const listingDurationDays = parseDisplayNumber(displayRow.may_listing_duration_days, null);
const resolvedCategoryName = resolveMayCategoryName(
displayRow.resolved_category_name,
baseRow.categoryName,
baseRow.categoryTag
);
const resolvedSupplierName = resolveMaySupplierName(
displayRow.resolved_supplier_name,
baseRow.supplierName
);
return {
stat_date: context.statDate,
may_sku_code: normalizeSkuKey(displayRow.may_sku_code || baseRow.skuCode),
may_virtual_category: normalizeSnapshotText(displayRow.may_virtual_category),
product_code: normalizeSnapshotText(baseRow.productCode),
spu_code: normalizeSnapshotText(baseRow.spuCode),
sku_name: normalizeSnapshotText(baseRow.skuName),
product_name: normalizeSnapshotText(baseRow.productName),
category_name: normalizeSnapshotText(resolvedCategoryName) || MAY_REVIEW_DIMENSION_FALLBACK_LABEL,
supplier_name: normalizeSnapshotText(resolvedSupplierName) || MAY_REVIEW_DIMENSION_FALLBACK_LABEL,
product_cost: Number.isFinite(Number(baseRow.productCost)) ? Number(baseRow.productCost) : null,
source_image_feature_id: baseRow.id || null,
may_appearance_recognition_code: normalizeSnapshotText(displayRow.may_appearance_recognition_code),
may_sku_image_url: normalizeSnapshotText(displayRow.may_sku_image_url),
may_sales_status: normalizeSnapshotText(displayRow.may_sales_status),
may_core_material: normalizeSnapshotText(displayRow.may_core_material),
may_min_limit_price: parseDisplayNumber(displayRow.may_min_limit_price, null),
may_size: normalizeSnapshotText(displayRow.may_size),
may_color_name: normalizeSnapshotText(displayRow.may_color_name),
may_performance_amount: parseDisplayNumber(displayRow.may_performance_amount, 0) || 0,
may_goods_id_count: Math.round(parseDisplayNumber(displayRow.may_goods_id_count, 0) || 0),
may_covered_channels: normalizeSnapshotText(displayRow.may_covered_channels),
...Object.fromEntries(MAY_CHANNEL_RATIO_FIELDS.map((field) => [
field.snapshotColumn,
parseDisplayNumber(displayRow[field.snapshotColumn], 0) || 0,
])),
may_promotion_fee_30d: parseDisplayNumber(displayRow.may_promotion_fee_30d, 0) || 0,
may_d_style_efficiency: parseDisplayNumber(displayRow.may_d_style_efficiency, 0) || 0,
may_inventory_quantity: parseDisplayNumber(displayRow.may_inventory_quantity, 0) || 0,
may_real_company_inventory_quantity: parseDisplayNumber(displayRow.may_real_company_inventory_quantity, 0) || 0,
may_estimated_sales_days: parseDisplayNumber(displayRow.may_estimated_sales_days, null),
may_shipping_warehouse: normalizeSnapshotText(displayRow.may_shipping_warehouse),
may_inventory_scope_remark: normalizeSnapshotText(displayRow.may_inventory_scope_remark),
may_avg_daily_sales_30d: parseDisplayNumber(displayRow.may_avg_daily_sales_30d, 0) || 0,
may_avg_daily_sales_7d: parseDisplayNumber(displayRow.may_avg_daily_sales_7d, 0) || 0,
may_sales_growth_rate_15d: parseDisplayNumber(displayRow.may_sales_growth_rate_15d, null),
may_listing_duration_days: Number.isFinite(Number(listingDurationDays)) ? Math.round(Number(listingDurationDays)) : null,
may_gross_margin_rate: parseDisplayNumber(displayRow.may_gross_margin_rate, null),
may_after_sales_ratio: parseDisplayNumber(displayRow.may_after_sales_ratio, null),
may_active_sales_day_ratio_30d: parseDisplayNumber(displayRow.may_active_sales_day_ratio_30d, null),
source_latest_biz_date: context.sourceLatestBizDate || null,
source_row_count: context.sourceRowCount || 0,
calc_version: MAY_PRODUCT_SNAPSHOT_CALC_VERSION,
snapshot_batch_id: context.batchId,
generated_at: context.generatedAt,
};
}
function buildSnapshotDisplayRow(row = {}) {
const categoryName = row.category_name || MAY_REVIEW_DIMENSION_FALLBACK_LABEL;
return {
may_appearance_recognition_code: row.may_appearance_recognition_code || '',
may_sku_image_url: row.may_sku_image_url || '',
may_sku_code: row.may_sku_code || '',
may_virtual_category: row.may_virtual_category || MAY_EMPTY_VALUE,
category_name: categoryName,
categoryName,
may_sales_status: row.may_sales_status || MAY_EMPTY_VALUE,
may_core_material: row.may_core_material || MAY_EMPTY_VALUE,
may_min_limit_price: displayNumberFromSnapshot(row.may_min_limit_price, 2),
may_size: row.may_size || MAY_EMPTY_VALUE,
may_color_name: row.may_color_name || MAY_EMPTY_VALUE,
may_performance_amount: displayNumberFromSnapshot(row.may_performance_amount, 2, 0),
may_goods_id_count: displayNumberFromSnapshot(row.may_goods_id_count, 0, 0),
may_covered_channels: row.may_covered_channels || MAY_EMPTY_VALUE,
...Object.fromEntries(MAY_CHANNEL_RATIO_FIELDS.map((field) => [
field.snapshotColumn,
displayPercentFromSnapshot(row[field.snapshotColumn], 2),
])),
may_promotion_fee_30d: displayNumberFromSnapshot(row.may_promotion_fee_30d, 2, 0),
may_d_style_efficiency: displayNumberFromSnapshot(row.may_d_style_efficiency, 2, 0),
may_inventory_quantity: displayNumberFromSnapshot(row.may_inventory_quantity, 0, 0),
may_real_company_inventory_quantity: displayNumberFromSnapshot(row.may_real_company_inventory_quantity, 0, 0),
may_estimated_sales_days: displayNumberFromSnapshot(row.may_estimated_sales_days, 1),
may_shipping_warehouse: row.may_shipping_warehouse || MAY_EMPTY_VALUE,
may_inventory_scope_remark: row.may_inventory_scope_remark || MAY_EMPTY_VALUE,
may_avg_daily_sales_30d: displayNumberFromSnapshot(row.may_avg_daily_sales_30d, 2, 0),
may_avg_daily_sales_7d: displayNumberFromSnapshot(row.may_avg_daily_sales_7d, 2, 0),
may_sales_growth_rate_15d: displayPercentFromSnapshot(row.may_sales_growth_rate_15d, 1),
may_listing_duration_days: displayDurationDaysFromSnapshot(row.may_listing_duration_days),
may_gross_margin_rate: displayPercentFromSnapshot(row.may_gross_margin_rate, 1),
may_after_sales_ratio: displayPercentFromSnapshot(row.may_after_sales_ratio, 1),
may_active_sales_day_ratio_30d: displayPercentFromSnapshot(row.may_active_sales_day_ratio_30d, 1),
};
}
function buildSnapshotUpsertSql(rowCount) {
const columnSql = MAY_PRODUCT_SNAPSHOT_COLUMNS.map((column) => escapeIdentifier(column)).join(', ');
const rowPlaceholder = `(${MAY_PRODUCT_SNAPSHOT_COLUMNS.map(() => '?').join(', ')})`;
const valuesSql = new Array(rowCount).fill(rowPlaceholder).join(', ');
const updateSql = MAY_PRODUCT_SNAPSHOT_COLUMNS
.filter((column) => !['stat_date', 'may_sku_code'].includes(column))
.map((column) => `${escapeIdentifier(column)} = VALUES(${escapeIdentifier(column)})`)
.join(', ');
return `
INSERT INTO ${escapeIdentifier(MAY_PRODUCT_SNAPSHOT_TABLE)} (${columnSql})
VALUES ${valuesSql}
ON DUPLICATE KEY UPDATE ${updateSql}
`;
}
async function upsertMayProductSnapshotRows(rows = []) {
if (!Array.isArray(rows) || rows.length === 0) {
return 0;
}
const sql = buildSnapshotUpsertSql(rows.length);
const params = [];
rows.forEach((row) => {
MAY_PRODUCT_SNAPSHOT_COLUMNS.forEach((column) => {
params.push(row[column] ?? null);
});
});
await planningDatabase.execute(sql, params);
return rows.length;
}
async function getLatestSnapshotMeta() {
await ensurePlanningMayProductSnapshotSchema();
const latestLog = await getLatestSuccessfulSnapshotLog();
if (!latestLog?.snapshotDate || !latestLog?.batchId) {
return null;
}
return getSnapshotMetaByBatch(latestLog.snapshotDate, latestLog.batchId, latestLog);
}
async function getSnapshotMetaByDate(snapshotDate) {
await ensurePlanningMayProductSnapshotSchema();
const latestLog = await getLatestSuccessfulSnapshotLog({ snapshotDate });
if (!latestLog?.snapshotDate || !latestLog?.batchId) {
return null;
}
return getSnapshotMetaByBatch(latestLog.snapshotDate, latestLog.batchId, latestLog);
}
async function getLatestSuccessfulSnapshotLog(options = {}) {
await ensurePlanningMayProductSnapshotSchema();
const normalizedSnapshotDate = formatDateOnly(options.snapshotDate);
const whereParts = ["status = 'success'", 'snapshot_row_count > 0'];
const params = [];
if (normalizedSnapshotDate) {
whereParts.push('stat_date = ?');
params.push(normalizedSnapshotDate);
}
const [row] = await planningDatabase.query(
`SELECT
id,
batch_id AS batchId,
DATE_FORMAT(stat_date, '%Y-%m-%d') AS snapshotDate,
DATE_FORMAT(source_latest_biz_date, '%Y-%m-%d') AS sourceLatestBizDate,
source_row_count AS sourceRowCount,
snapshot_row_count AS snapshotRowCount,
progress_pct AS progressPct,
calc_version AS calcVersion,
started_at AS startedAt,
heartbeat_at AS heartbeatAt,
finished_at AS finishedAt
FROM ${escapeIdentifier(MAY_PRODUCT_SNAPSHOT_SYNC_LOG_TABLE)}
WHERE ${whereParts.join(' AND ')}
ORDER BY stat_date DESC, finished_at DESC, id DESC
LIMIT 1`,
params
);
if (!row?.batchId || !row?.snapshotDate) {
return null;
}
return {
id: Number(row.id || 0),
batchId: normalizeText(row.batchId),
snapshotDate: normalizeText(row.snapshotDate),
sourceLatestBizDate: normalizeText(row.sourceLatestBizDate),
sourceRowCount: Number(row.sourceRowCount || 0),
snapshotRowCount: Number(row.snapshotRowCount || 0),
progressPct: Number(row.progressPct || 0),
calcVersion: normalizeText(row.calcVersion),
startedAt: row.startedAt || null,
heartbeatAt: row.heartbeatAt || null,
finishedAt: row.finishedAt || null,
status: 'success',
};
}
async function getLatestRunningSnapshotLog() {
await ensurePlanningMayProductSnapshotSchema();
const [metaRow] = await planningDatabase.query(
`SELECT
id,
batch_id AS batchId,
DATE_FORMAT(stat_date, '%Y-%m-%d') AS snapshotDate,
DATE_FORMAT(source_latest_biz_date, '%Y-%m-%d') AS sourceLatestBizDate,
source_row_count AS sourceRowCount,
snapshot_row_count AS snapshotRowCount,
progress_pct AS progressPct,
calc_version AS calcVersion,
started_at AS startedAt,
heartbeat_at AS heartbeatAt,
finished_at AS finishedAt,
error_message AS errorMessage
FROM ${escapeIdentifier(MAY_PRODUCT_SNAPSHOT_SYNC_LOG_TABLE)}
WHERE status = 'running'
ORDER BY started_at DESC, id DESC
LIMIT 1`
);
if (!metaRow?.batchId) {
return null;
}
return {
id: Number(metaRow.id || 0),
batchId: normalizeText(metaRow.batchId),
snapshotDate: normalizeText(metaRow.snapshotDate),
sourceLatestBizDate: normalizeText(metaRow.sourceLatestBizDate),
sourceRowCount: Number(metaRow.sourceRowCount || 0),
snapshotRowCount: Number(metaRow.snapshotRowCount || 0),
progressPct: Number(metaRow.progressPct || 0),
calcVersion: normalizeText(metaRow.calcVersion),
startedAt: metaRow.startedAt || null,
heartbeatAt: metaRow.heartbeatAt || null,
finishedAt: metaRow.finishedAt || null,
errorMessage: normalizeText(metaRow.errorMessage),
status: 'running',
};
}
async function getSnapshotMetaByBatch(snapshotDate, snapshotBatchId, fallbackMeta = null) {
const safeSnapshotDate = formatDateOnly(snapshotDate);
const safeBatchId = normalizeText(snapshotBatchId);
if (!safeSnapshotDate || !safeBatchId) {
return null;
}
const [metaRow] = await planningDatabase.query(
`SELECT
COUNT(*) AS totalRows,
DATE_FORMAT(MAX(source_latest_biz_date), '%Y-%m-%d') AS sourceLatestBizDate,
MAX(generated_at) AS generatedAt,
MAX(calc_version) AS calcVersion
FROM ${escapeIdentifier(MAY_PRODUCT_SNAPSHOT_TABLE)}
WHERE stat_date = ?
AND snapshot_batch_id = ?
AND ${buildMayNotDiscontinuedCondition('may_sales_status')}`,
[safeSnapshotDate, safeBatchId]
);
const totalRows = Number(metaRow?.totalRows || 0);
if (totalRows <= 0) {
return null;
}
return {
snapshotDate: safeSnapshotDate,
totalRows,
sourceLatestBizDate: normalizeText(metaRow?.sourceLatestBizDate) || normalizeText(fallbackMeta?.sourceLatestBizDate),
generatedAt: metaRow?.generatedAt || fallbackMeta?.finishedAt || fallbackMeta?.startedAt || null,
calcVersion: normalizeText(metaRow?.calcVersion) || normalizeText(fallbackMeta?.calcVersion),
snapshotBatchId: safeBatchId,
};
}
function pushReason(list, condition, text) {
if (condition) {
list.push(text);
}
}
function formatReviewPercent(value, digits = 1) {
return Number.isFinite(Number(value)) ? `${roundNumber(Number(value), digits)}%` : MAY_EMPTY_VALUE;
}
function formatReviewNumber(value, digits = 2, fallback = MAY_EMPTY_VALUE) {
return Number.isFinite(Number(value)) ? roundNumber(Number(value), digits) : fallback;
}
function evaluateMayProductReview(row = {}, categoryAverageDailySales30d = null) {
const salesQty30d = toNumber(row.sales_qty_30d, 0);
const salesQty7d = toNumber(row.sales_qty_7d, 0);
const avgDailySales30d = toNumber(row.may_avg_daily_sales_30d, salesQty30d / 30);
const avgDailySales7d = toNumber(row.may_avg_daily_sales_7d, salesQty7d / 7);
const shortTermGrowthRate = row.may_sales_growth_rate_15d === null || row.may_sales_growth_rate_15d === undefined
? null
: Number(row.may_sales_growth_rate_15d);
const inventoryQuantity = toNumber(row.may_real_company_inventory_quantity, 0);
const hasRealCompanyInventory = inventoryQuantity > 0;
const stockTurnoverDays = row.may_estimated_sales_days === null || row.may_estimated_sales_days === undefined
? null
: Number(row.may_estimated_sales_days);
const listingDurationDays = row.may_listing_duration_days === null || row.may_listing_duration_days === undefined
? null
: Number(row.may_listing_duration_days);
const grossMarginRate = row.may_gross_margin_rate === null || row.may_gross_margin_rate === undefined
? null
: Number(row.may_gross_margin_rate);
const activeSalesDayRatio30d = row.may_active_sales_day_ratio_30d === null || row.may_active_sales_day_ratio_30d === undefined
? null
: Number(row.may_active_sales_day_ratio_30d);
const performanceAmount30d = toNumber(row.may_performance_amount, 0);
const promotionFee30d = toNumber(row.may_promotion_fee_30d, 0);
const sales7dVs30dRatio = avgDailySales30d > 0 ? avgDailySales7d / avgDailySales30d : null;
const grossProfit30d = Number.isFinite(grossMarginRate)
? performanceAmount30d * grossMarginRate / 100
: null;
const promotionRoi = promotionFee30d > 0 && Number.isFinite(grossProfit30d)
? grossProfit30d / promotionFee30d
: null;
const missingMetrics = [];
if (!Number.isFinite(shortTermGrowthRate)) {
missingMetrics.push('短期增长率');
}
if (!Number.isFinite(stockTurnoverDays)) {
missingMetrics.push('库存周转天数');
}
if (!Number.isFinite(listingDurationDays)) {
missingMetrics.push('铺货时长');
}
if (!Number.isFinite(grossMarginRate)) {
missingMetrics.push('毛利率');
}
if (!Number.isFinite(activeSalesDayRatio30d)) {
missingMetrics.push('动销率');
}
const stopReasons = [];
pushReason(stopReasons, avgDailySales30d < 0.2, `近30天日销 ${formatReviewNumber(avgDailySales30d, 2, 0)} < 0.2`);
pushReason(stopReasons, Number.isFinite(shortTermGrowthRate) && shortTermGrowthRate < -10, `短期增长率 ${formatReviewPercent(shortTermGrowthRate)} < -10%`);
pushReason(stopReasons, Number.isFinite(stockTurnoverDays) && stockTurnoverDays > 90, `库存周转 ${formatReviewNumber(stockTurnoverDays, 1)} 天 > 90天`);
pushReason(stopReasons, Number.isFinite(listingDurationDays) && listingDurationDays > 365, `铺货 ${Math.round(listingDurationDays)} 天 > 365天`);
pushReason(stopReasons, Number.isFinite(grossMarginRate) && grossMarginRate < 20, `毛利率 ${formatReviewPercent(grossMarginRate)} < 20%`);
const focusReasons = [];
pushReason(focusReasons, Number.isFinite(shortTermGrowthRate) && shortTermGrowthRate >= 30, `短期增长率 ${formatReviewPercent(shortTermGrowthRate)} >= 30%`);
pushReason(focusReasons, Number.isFinite(sales7dVs30dRatio) && sales7dVs30dRatio >= 1.5, `近7日日销是近30天的 ${formatReviewNumber(sales7dVs30dRatio, 2)} 倍`);
pushReason(focusReasons, Number.isFinite(stockTurnoverDays) && stockTurnoverDays <= 60, `库存周转 ${formatReviewNumber(stockTurnoverDays, 1)} 天 <= 60天`);
pushReason(focusReasons, Number.isFinite(grossMarginRate) && grossMarginRate >= 40, `毛利率 ${formatReviewPercent(grossMarginRate)} >= 40%`);
const boostReasons = [];
pushReason(
boostReasons,
Number.isFinite(categoryAverageDailySales30d) && categoryAverageDailySales30d > 0 && avgDailySales30d >= categoryAverageDailySales30d * 2,
`近30天日销达到同类均值 ${formatReviewNumber(categoryAverageDailySales30d, 2)} 的2倍`
);
pushReason(boostReasons, Number.isFinite(shortTermGrowthRate) && shortTermGrowthRate >= 20, `短期增长率 ${formatReviewPercent(shortTermGrowthRate)} >= 20%`);
pushReason(boostReasons, Number.isFinite(stockTurnoverDays) && stockTurnoverDays <= 30, `库存周转 ${formatReviewNumber(stockTurnoverDays, 1)} 天 <= 30天`);
pushReason(boostReasons, Number.isFinite(grossMarginRate) && grossMarginRate >= 40, `毛利率 ${formatReviewPercent(grossMarginRate)} >= 40%`);
pushReason(boostReasons, Number.isFinite(activeSalesDayRatio30d) && activeSalesDayRatio30d >= 60, `动销率 ${formatReviewPercent(activeSalesDayRatio30d)} >= 60%`);
let reviewAction = MAY_PRODUCT_REVIEW_ACTIONS.observe;
let reviewTags = ['观察优化'];
let reviewReasons = ['未达到直接停售、重点运营或爆款扶持阈值,建议继续观察30天。'];
if (missingMetrics.length >= 3 && avgDailySales30d <= 0 && performanceAmount30d <= 0) {
reviewAction = MAY_PRODUCT_REVIEW_ACTIONS.data_missing;
reviewTags = ['数据不足'];
reviewReasons = ['关键指标不足,暂不做强结论。'];
} else if (hasRealCompanyInventory && stopReasons.length >= 5) {
reviewAction = MAY_PRODUCT_REVIEW_ACTIONS.stop;
reviewTags = ['滞销', '下滑', '压货', '老款', '低毛利'];
reviewReasons = stopReasons;
} else if (boostReasons.length >= 3) {
reviewAction = MAY_PRODUCT_REVIEW_ACTIONS.boost;
reviewTags = ['爆款扶持', '高增长', '高效率'];
reviewReasons = boostReasons;
} else if (focusReasons.length >= 2) {
reviewAction = MAY_PRODUCT_REVIEW_ACTIONS.focus;
reviewTags = ['保留重点运营', '增长/健康'];
reviewReasons = focusReasons;
}
return {
reviewAction,
reviewTags: reviewTags.join('、'),
reviewReasons: reviewReasons.join(';'),
missingMetrics: missingMetrics.length > 0 ? missingMetrics.join('、') : '',
salesQty30d,
salesQty7d,
avgDailySales30d,
shortTermGrowthRate,
shortTermGrowthBasis: Number.isFinite(shortTermGrowthRate) ? '近15天环比(当前预计算口径)' : '',
sales7dVs30dRatio,
inventoryQuantity,
stockTurnoverDays,
listingDurationDays: Number.isFinite(listingDurationDays) ? Math.round(listingDurationDays) : null,
grossMarginRate,
grossProfit30d,
promotionRoi,
activeSalesDayRatio30d,
categoryAverageDailySales30d: Number.isFinite(categoryAverageDailySales30d) ? categoryAverageDailySales30d : null,
};
}
function buildMayProductReviewInsertSql(rowCount) {
const columns = [
'stat_date',
'snapshot_batch_id',
'may_sku_code',
'category_name',
'review_action',
'review_tags',
'review_reasons',
'missing_metrics',
'sales_qty_30d',
'sales_qty_7d',
'avg_daily_sales_30d',
'short_term_growth_rate',
'short_term_growth_basis',
'sales_7d_vs_30d_ratio',
'inventory_quantity',
'stock_turnover_days',
'listing_duration_days',
'gross_margin_rate',
'gross_profit_30d',
'promotion_roi',
'active_sales_day_ratio_30d',
'category_avg_daily_sales_30d',
'generated_at',
];
const columnSql = columns.map((column) => escapeIdentifier(column)).join(', ');
const rowPlaceholder = `(${columns.map(() => '?').join(', ')})`;
const valuesSql = new Array(rowCount).fill(rowPlaceholder).join(', ');
const updateSql = columns
.filter((column) => !['stat_date', 'snapshot_batch_id', 'may_sku_code'].includes(column))
.map((column) => `${escapeIdentifier(column)} = VALUES(${escapeIdentifier(column)})`)
.join(', ');
return {
columns,
sql: `INSERT INTO ${escapeIdentifier(MAY_PRODUCT_REVIEW_SNAPSHOT_TABLE)} (${columnSql}) VALUES ${valuesSql} ON DUPLICATE KEY UPDATE ${updateSql}`,
};
}
async function refreshPlanningMayProductReviewSnapshot(snapshotMetaOrDate) {
await ensurePlanningMayProductSnapshotSchema();
await ensurePlanningMayProductReviewSnapshotSchema();
await ensurePlanningMaySalesStageSchema();
const snapshotMeta = typeof snapshotMetaOrDate === 'string'
? await getSnapshotMetaByDate(snapshotMetaOrDate)
: snapshotMetaOrDate;
if (!snapshotMeta?.snapshotDate || !snapshotMeta?.snapshotBatchId) {
return {
success: false,
tableName: MAY_PRODUCT_REVIEW_SNAPSHOT_TABLE,
reviewRowCount: 0,
message: 'MAY商品快照尚未生成,无法计算复盘规则。',
};
}
const rows = await planningDatabase.query(
`SELECT
s.*,
COALESCE(ss.sales_qty_30d, s.may_avg_daily_sales_30d * 30, 0) AS sales_qty_30d,
COALESCE(ss.sales_qty_7d, s.may_avg_daily_sales_7d * 7, 0) AS sales_qty_7d
FROM ${escapeIdentifier(MAY_PRODUCT_SNAPSHOT_TABLE)} s
LEFT JOIN ${escapeIdentifier(MAY_PRODUCT_SALES_SUMMARY_STAGE_TABLE)} ss
ON ss.may_sku_code = s.may_sku_code
AND ss.stat_date = COALESCE(s.source_latest_biz_date, s.stat_date)
AND ss.calc_version = ?
WHERE s.stat_date = ?
AND s.snapshot_batch_id = ?
AND ${buildMayNotDiscontinuedCondition('s.may_sales_status')}
ORDER BY s.may_sku_code ASC`,
[MAY_PRODUCT_SALES_STAGE_CALC_VERSION, snapshotMeta.snapshotDate, snapshotMeta.snapshotBatchId]
);
const categoryStats = new Map();
rows.forEach((row) => {
const categoryName = resolveMayCategoryName(row.category_name);
const avgDaily = toNumber(row.may_avg_daily_sales_30d, 0);
const previous = categoryStats.get(categoryName) || { total: 0, count: 0 };
previous.total += avgDaily;
previous.count += 1;
categoryStats.set(categoryName, previous);
});
const generatedAt = new Date();
const reviewRows = rows.map((row) => {
const categoryName = resolveMayCategoryName(row.category_name);
const categoryStat = categoryStats.get(categoryName);
const categoryAverage = categoryStat?.count > 0 ? categoryStat.total / categoryStat.count : null;
const review = evaluateMayProductReview(row, categoryAverage);
return {
stat_date: snapshotMeta.snapshotDate,
snapshot_batch_id: snapshotMeta.snapshotBatchId,
may_sku_code: row.may_sku_code,
category_name: categoryName,
review_action: review.reviewAction,
review_tags: review.reviewTags,
review_reasons: review.reviewReasons,
missing_metrics: review.missingMetrics,
sales_qty_30d: review.salesQty30d,
sales_qty_7d: review.salesQty7d,
avg_daily_sales_30d: review.avgDailySales30d,
short_term_growth_rate: review.shortTermGrowthRate,
short_term_growth_basis: review.shortTermGrowthBasis,
sales_7d_vs_30d_ratio: review.sales7dVs30dRatio,
inventory_quantity: review.inventoryQuantity,
stock_turnover_days: review.stockTurnoverDays,
listing_duration_days: review.listingDurationDays,
gross_margin_rate: review.grossMarginRate,
gross_profit_30d: review.grossProfit30d,
promotion_roi: review.promotionRoi,
active_sales_day_ratio_30d: review.activeSalesDayRatio30d,
category_avg_daily_sales_30d: review.categoryAverageDailySales30d,
generated_at: generatedAt,
};
});
await planningDatabase.execute(
`DELETE FROM ${escapeIdentifier(MAY_PRODUCT_REVIEW_SNAPSHOT_TABLE)}
WHERE stat_date = ?
AND snapshot_batch_id = ?`,
[snapshotMeta.snapshotDate, snapshotMeta.snapshotBatchId]
);
await planningDatabase.execute(
`DELETE FROM ${escapeIdentifier(MAY_PRODUCT_REVIEW_SNAPSHOT_TABLE)}
WHERE stat_date = ?
AND snapshot_batch_id <> ?`,
[snapshotMeta.snapshotDate, snapshotMeta.snapshotBatchId]
);
let reviewRowCount = 0;
for (const chunk of chunkArray(reviewRows, 500)) {
if (chunk.length === 0) {
continue;
}
const { columns, sql } = buildMayProductReviewInsertSql(chunk.length);
const params = [];
chunk.forEach((row) => {
columns.forEach((column) => {
params.push(row[column] ?? null);
});
});
await planningDatabase.execute(sql, params);
reviewRowCount += chunk.length;
}
return {
success: true,
tableName: MAY_PRODUCT_REVIEW_SNAPSHOT_TABLE,
statDate: snapshotMeta.snapshotDate,
snapshotBatchId: snapshotMeta.snapshotBatchId,
reviewRowCount,
};
}
async function ensureLatestMayProductReviewSnapshot(snapshotMeta) {
await ensurePlanningMayProductReviewSnapshotSchema();
if (!snapshotMeta?.snapshotDate || !snapshotMeta?.snapshotBatchId) {
return null;
}
const [row] = await planningDatabase.query(
`SELECT COUNT(*) AS totalRows, MAX(r.generated_at) AS generatedAt
FROM ${escapeIdentifier(MAY_PRODUCT_REVIEW_SNAPSHOT_TABLE)} r
INNER JOIN ${escapeIdentifier(MAY_PRODUCT_SNAPSHOT_TABLE)} s
ON s.stat_date = r.stat_date
AND s.snapshot_batch_id = r.snapshot_batch_id
AND s.may_sku_code = r.may_sku_code
WHERE r.stat_date = ?
AND r.snapshot_batch_id = ?
AND ${buildMayNotDiscontinuedCondition('s.may_sales_status')}`,
[snapshotMeta.snapshotDate, snapshotMeta.snapshotBatchId]
);
const totalRows = Number(row?.totalRows || 0);
if (totalRows >= Number(snapshotMeta.totalRows || 0) && totalRows > 0) {
return {
totalRows,
generatedAt: row?.generatedAt || null,
};
}
await refreshPlanningMayProductReviewSnapshot(snapshotMeta);
const [freshRow] = await planningDatabase.query(
`SELECT COUNT(*) AS totalRows, MAX(r.generated_at) AS generatedAt
FROM ${escapeIdentifier(MAY_PRODUCT_REVIEW_SNAPSHOT_TABLE)} r
INNER JOIN ${escapeIdentifier(MAY_PRODUCT_SNAPSHOT_TABLE)} s
ON s.stat_date = r.stat_date
AND s.snapshot_batch_id = r.snapshot_batch_id
AND s.may_sku_code = r.may_sku_code
WHERE r.stat_date = ?
AND r.snapshot_batch_id = ?
AND ${buildMayNotDiscontinuedCondition('s.may_sales_status')}`,
[snapshotMeta.snapshotDate, snapshotMeta.snapshotBatchId]
);
return {
totalRows: Number(freshRow?.totalRows || 0),
generatedAt: freshRow?.generatedAt || null,
};
}
function createEmptyMayProductReviewSummary() {
return {
skuCount: 0,
salesQty30d: 0,
salesAmount30d: 0,
inventoryQuantity: 0,
realCompanyInventoryQuantity: 0,
inventoryAmount: 0,
grossProfit30d: 0,
categoryCount: 0,
supplierCount: 0,
missingMetricSkuCount: 0,
};
}
function buildMayProductReviewSummary(row = {}) {
return {
skuCount: Number(row.skuCount || 0),
salesQty30d: roundNumber(Number(row.salesQty30d || 0), 0),
salesAmount30d: roundNumber(Number(row.salesAmount30d || 0), 2),
inventoryQuantity: roundNumber(Number(row.inventoryQuantity || 0), 0),
realCompanyInventoryQuantity: roundNumber(Number(row.realCompanyInventoryQuantity || 0), 0),
inventoryAmount: roundNumber(Number(row.inventoryAmount || 0), 2),
grossProfit30d: roundNumber(Number(row.grossProfit30d || 0), 2),
categoryCount: Number(row.categoryCount || 0),
supplierCount: Number(row.supplierCount || 0),
missingMetricSkuCount: Number(row.missingMetricSkuCount || 0),
};
}
function resolveMayProductReviewActionKey(label = '') {
const normalizedLabel = normalizeText(label);
const matchedEntry = Object.entries(MAY_PRODUCT_REVIEW_ACTIONS).find(([, value]) => value === normalizedLabel);
return matchedEntry?.[0] || normalizedLabel || 'observe';
}
function buildMayProductReviewDashboardWhere(snapshotMeta, reviewAction = '', options = {}) {
const whereParts = [
'r.stat_date = ?',
'r.snapshot_batch_id = ?',
buildMayNotDiscontinuedCondition('s.may_sales_status'),
];
const params = [snapshotMeta.snapshotDate, snapshotMeta.snapshotBatchId];
const normalizedReviewAction = resolveReviewActionFilter(reviewAction);
const virtualCategory = appendMayVirtualCategoryWhere(
whereParts,
params,
's.may_virtual_category',
options.virtualCategory || options.mode
);
const categoryName = appendMayReviewDimensionWhere(
whereParts,
params,
's.category_name',
options.categoryName || options.category || options.category_name
);
const supplierName = appendMayReviewDimensionWhere(
whereParts,
params,
's.supplier_name',
options.supplierName || options.supplier || options.supplier_name
);
const missingMetric = appendMayReviewMissingMetricWhere(
whereParts,
params,
options.missingMetric || options.missing_metric
);
const inventoryScope = appendMayReviewInventoryScopeWhere(
whereParts,
params,
options.inventoryScope || options.inventory_scope || options.stockPath || options.stock_path
);
const { brandName, brandGroup } = appendMayPlanningBrandWhere(whereParts, params, options);
if (normalizedReviewAction) {
whereParts.push('r.review_action = ?');
params.push(normalizedReviewAction);
}
return {
normalizedReviewAction,
virtualCategory,
categoryName,
supplierName,
missingMetric,
inventoryScope,
brandName,
brandGroup,
whereSql: whereParts.join(' AND '),
params,
};
}
function normalizeReviewDashboardDimensionName(value, fallback) {
return normalizeMayDimensionValue(value) || fallback;
}
function buildMayProductReviewMissingMetricBreakdown(rows = []) {
const metricCounts = new Map();
rows.forEach((row) => {
const comboCount = Number(row.skuCount || 0) || 0;
const metrics = normalizeText(row.missingMetrics)
.split(/[、,,]/u)
.map((item) => item.trim())
.filter(Boolean);
metrics.forEach((metric) => {
metricCounts.set(metric, (metricCounts.get(metric) || 0) + comboCount);
});
});
return Array.from(metricCounts.entries())
.map(([metric, skuCount]) => ({ metric, skuCount }))
.sort((left, right) => {
if (right.skuCount !== left.skuCount) {
return right.skuCount - left.skuCount;
}
return left.metric.localeCompare(right.metric, 'zh-CN');
});
}
function addMayReviewSummaryValues(target, row = {}) {
target.skuCount += Number(row.skuCount || 0);
target.salesQty30d += Number(row.salesQty30d || 0);
target.salesAmount30d += Number(row.salesAmount30d || 0);
target.inventoryQuantity += Number(row.inventoryQuantity || 0);
target.realCompanyInventoryQuantity += Number(row.realCompanyInventoryQuantity || 0);
target.inventoryAmount += Number(row.inventoryAmount || 0);
target.grossProfit30d += Number(row.grossProfit30d || 0);
target.missingMetricSkuCount += Number(row.missingMetricSkuCount || 0);
}
function buildMayProductReviewInventoryScopeBreakdown(rows = []) {
const scopeMap = new Map();
rows.forEach((row) => {
const scopes = splitMayInventoryScopeRemark(row.inventoryScope);
scopes.forEach((scope) => {
if (!scopeMap.has(scope)) {
scopeMap.set(scope, {
inventoryScope: scope,
...createEmptyMayProductReviewSummary(),
});
}
addMayReviewSummaryValues(scopeMap.get(scope), row);
});
});
return Array.from(scopeMap.values())
.map((row) => ({
inventoryScope: row.inventoryScope,
...buildMayProductReviewSummary(row),
}))
.sort((left, right) => {
const leftIndex = Object.values(MAY_INVENTORY_SCOPE_LABELS).indexOf(left.inventoryScope);
const rightIndex = Object.values(MAY_INVENTORY_SCOPE_LABELS).indexOf(right.inventoryScope);
return leftIndex - rightIndex;
});
}
function buildMayProductReviewRouteBreakdown(rows = []) {
const routeMap = new Map();
rows.forEach((row) => {
const virtualCategory = resolveMayVirtualCategory(row.virtualCategory, '未配置');
const scopes = splitMayInventoryScopeRemark(row.inventoryScope);
const routeScopes = scopes.length > 0 ? scopes : ['未配置'];
routeScopes.forEach((scope) => {
const routeKey = `${virtualCategory}::${scope}`;
if (!routeMap.has(routeKey)) {
routeMap.set(routeKey, {
virtualCategory,
inventoryScope: scope,
...createEmptyMayProductReviewSummary(),
});
}
addMayReviewSummaryValues(routeMap.get(routeKey), row);
});
});
const modeOrder = ['OEM', 'ODM', '未配置'];
const scopeOrder = [...Object.values(MAY_INVENTORY_SCOPE_LABELS), '未配置'];
return Array.from(routeMap.values())
.map((row) => ({
virtualCategory: row.virtualCategory,
inventoryScope: row.inventoryScope,
...buildMayProductReviewSummary(row),
}))
.sort((left, right) => {
const leftModeIndex = modeOrder.indexOf(left.virtualCategory);
const rightModeIndex = modeOrder.indexOf(right.virtualCategory);
if (leftModeIndex !== rightModeIndex) {
return leftModeIndex - rightModeIndex;
}
return scopeOrder.indexOf(left.inventoryScope) - scopeOrder.indexOf(right.inventoryScope);
});
}
function createEmptyMayBrandSummary(group, brand = null) {
return {
brandGroup: group.key,
brandGroupLabel: group.label,
brandName: brand?.name || '',
skuCount: 0,
salesQty30d: 0,
salesAmount30d: 0,
inventoryQuantity: 0,
realCompanyInventoryQuantity: 0,
inventoryAmount: 0,
grossProfit30d: 0,
missingMetricSkuCount: 0,
};
}
function buildMayProductReviewBrandSalesBreakdown(rows = [], options = {}) {
const groupMap = new Map();
MAY_PLANNING_BRAND_GROUPS.forEach((group) => {
groupMap.set(group.key, {
...createEmptyMayBrandSummary(group),
brands: group.brands.map((brand) => ({
...createEmptyMayBrandSummary(group, brand),
})),
});
});
rows.forEach((row) => {
const matchedBrands = getMayPlanningBrandMatchesFromText(row.coveredChannels, options);
const matchedGroupKeys = new Set();
matchedBrands.forEach((brand) => {
const groupSummary = groupMap.get(brand.groupKey);
if (!groupSummary) {
return;
}
const brandSummary = groupSummary.brands.find((item) => item.brandName === brand.name);
if (!brandSummary) {
return;
}
matchedGroupKeys.add(brand.groupKey);
addMayReviewSummaryValues(brandSummary, row);
});
matchedGroupKeys.forEach((groupKey) => {
const groupSummary = groupMap.get(groupKey);
if (groupSummary) {
addMayReviewSummaryValues(groupSummary, row);
}
});
});
const brandGroup = resolveMayPlanningBrandGroupFilter(options.brandGroup || options.brand_group);
const brandName = resolveMayPlanningBrandNameFilter(options.brandName || options.brand || options.brand_name);
return Array.from(groupMap.values())
.filter((group) => !brandGroup || group.brandGroup === brandGroup)
.map((group) => ({
...buildMayProductReviewSummary(group),
brandGroup: group.brandGroup,
brandGroupLabel: group.brandGroupLabel,
brands: group.brands
.filter((brand) => !brandName || brand.brandName === brandName)
.map((brand) => ({
...buildMayProductReviewSummary(brand),
brandGroup: brand.brandGroup,
brandGroupLabel: brand.brandGroupLabel,
brandName: brand.brandName,
})),
}))
.filter((group) => group.brands.length > 0);
}
async function queryMayProductReviewBreakdown(snapshotMeta, options = {}) {
const {
fieldName,
fallbackLabel,
reviewAction,
limit = 8,
} = options;
const { whereSql, params } = buildMayProductReviewDashboardWhere(snapshotMeta, reviewAction, options);
const escapedField = escapeIdentifier(fieldName);
const rows = await planningDatabase.query(
`SELECT
COALESCE(NULLIF(TRIM(s.${escapedField}), ''), ?) AS dimensionLabel,
COUNT(*) AS skuCount,
SUM(COALESCE(s.may_performance_amount, 0)) AS salesAmount30d,
SUM(COALESCE(r.sales_qty_30d, 0)) AS salesQty30d,
SUM(COALESCE(r.inventory_quantity, 0)) AS inventoryQuantity,
SUM(COALESCE(s.may_real_company_inventory_quantity, 0)) AS realCompanyInventoryQuantity,
SUM(COALESCE(s.product_cost, 0) * COALESCE(s.may_real_company_inventory_quantity, 0)) AS inventoryAmount,
SUM(COALESCE(r.gross_profit_30d, 0)) AS grossProfit30d
FROM ${escapeIdentifier(MAY_PRODUCT_REVIEW_SNAPSHOT_TABLE)} r
INNER JOIN ${escapeIdentifier(MAY_PRODUCT_SNAPSHOT_TABLE)} s
ON s.stat_date = r.stat_date
AND s.snapshot_batch_id = r.snapshot_batch_id
AND s.may_sku_code = r.may_sku_code
WHERE ${whereSql}
GROUP BY dimensionLabel
ORDER BY inventoryAmount DESC, salesAmount30d DESC, skuCount DESC
LIMIT ?`,
[fallbackLabel, ...params, limit]
);
return rows.map((row) => ({
dimensionLabel: normalizeReviewDashboardDimensionName(row.dimensionLabel, fallbackLabel),
...buildMayProductReviewSummary(row),
}));
}
async function queryMayProductReviewOverview(snapshotMeta, reviewAction = '', options = {}) {
const { whereSql, params } = buildMayProductReviewDashboardWhere(snapshotMeta, reviewAction, options);
const [row] = await planningDatabase.query(
`SELECT
COUNT(*) AS skuCount,
COUNT(DISTINCT NULLIF(TRIM(s.category_name), '')) AS categoryCount,
COUNT(DISTINCT NULLIF(TRIM(s.supplier_name), '')) AS supplierCount,
SUM(COALESCE(s.may_performance_amount, 0)) AS salesAmount30d,
SUM(COALESCE(r.sales_qty_30d, 0)) AS salesQty30d,
SUM(COALESCE(r.inventory_quantity, 0)) AS inventoryQuantity,
SUM(COALESCE(s.may_real_company_inventory_quantity, 0)) AS realCompanyInventoryQuantity,
SUM(COALESCE(s.product_cost, 0) * COALESCE(s.may_real_company_inventory_quantity, 0)) AS inventoryAmount,
SUM(COALESCE(r.gross_profit_30d, 0)) AS grossProfit30d,
SUM(CASE WHEN COALESCE(r.missing_metrics, '') <> '' THEN 1 ELSE 0 END) AS missingMetricSkuCount
FROM ${escapeIdentifier(MAY_PRODUCT_REVIEW_SNAPSHOT_TABLE)} r
INNER JOIN ${escapeIdentifier(MAY_PRODUCT_SNAPSHOT_TABLE)} s
ON s.stat_date = r.stat_date
AND s.snapshot_batch_id = r.snapshot_batch_id
AND s.may_sku_code = r.may_sku_code
WHERE ${whereSql}`,
params
);
return buildMayProductReviewSummary(row || {});
}
async function queryMayProductReviewActionSummaries(snapshotMeta, options = {}) {
const { whereSql, params } = buildMayProductReviewDashboardWhere(snapshotMeta, '', options);
const rows = await planningDatabase.query(
`SELECT
r.review_action AS action,
COUNT(*) AS skuCount,
SUM(COALESCE(s.may_performance_amount, 0)) AS salesAmount30d,
SUM(COALESCE(r.sales_qty_30d, 0)) AS salesQty30d,
SUM(COALESCE(r.inventory_quantity, 0)) AS inventoryQuantity,
SUM(COALESCE(s.may_real_company_inventory_quantity, 0)) AS realCompanyInventoryQuantity,
SUM(COALESCE(s.product_cost, 0) * COALESCE(s.may_real_company_inventory_quantity, 0)) AS inventoryAmount,
SUM(COALESCE(r.gross_profit_30d, 0)) AS grossProfit30d
FROM ${escapeIdentifier(MAY_PRODUCT_REVIEW_SNAPSHOT_TABLE)} r
INNER JOIN ${escapeIdentifier(MAY_PRODUCT_SNAPSHOT_TABLE)} s
ON s.stat_date = r.stat_date
AND s.snapshot_batch_id = r.snapshot_batch_id
AND s.may_sku_code = r.may_sku_code
WHERE ${whereSql}
GROUP BY r.review_action`,
params
);
const summaries = {};
Object.values(MAY_PRODUCT_REVIEW_ACTIONS).forEach((label) => {
if (label !== MAY_PRODUCT_REVIEW_ACTIONS.all) {
summaries[label] = createEmptyMayProductReviewSummary();
}
});
rows.forEach((row) => {
const label = normalizeText(row.action) || MAY_PRODUCT_REVIEW_ACTIONS.observe;
summaries[label] = buildMayProductReviewSummary(row);
});
return summaries;
}
async function queryMayProductReviewMissingMetrics(snapshotMeta, reviewAction = '', options = {}) {
const { whereSql, params } = buildMayProductReviewDashboardWhere(snapshotMeta, reviewAction, options);
const rows = await planningDatabase.query(
`SELECT
r.missing_metrics AS missingMetrics,
COUNT(*) AS skuCount
FROM ${escapeIdentifier(MAY_PRODUCT_REVIEW_SNAPSHOT_TABLE)} r
INNER JOIN ${escapeIdentifier(MAY_PRODUCT_SNAPSHOT_TABLE)} s
ON s.stat_date = r.stat_date
AND s.snapshot_batch_id = r.snapshot_batch_id
AND s.may_sku_code = r.may_sku_code
WHERE ${whereSql}
AND COALESCE(r.missing_metrics, '') <> ''
GROUP BY r.missing_metrics`,
params
);
return buildMayProductReviewMissingMetricBreakdown(rows);
}
async function queryMayProductReviewInventoryScopes(snapshotMeta, reviewAction = '', options = {}) {
const { whereSql, params } = buildMayProductReviewDashboardWhere(snapshotMeta, reviewAction, options);
const rows = await planningDatabase.query(
`SELECT
s.may_inventory_scope_remark AS inventoryScope,
COUNT(*) AS skuCount,
SUM(COALESCE(s.may_performance_amount, 0)) AS salesAmount30d,
SUM(COALESCE(r.sales_qty_30d, 0)) AS salesQty30d,
SUM(COALESCE(r.inventory_quantity, 0)) AS inventoryQuantity,
SUM(COALESCE(s.may_real_company_inventory_quantity, 0)) AS realCompanyInventoryQuantity,
SUM(COALESCE(s.product_cost, 0) * COALESCE(s.may_real_company_inventory_quantity, 0)) AS inventoryAmount,
SUM(COALESCE(r.gross_profit_30d, 0)) AS grossProfit30d,
SUM(CASE WHEN COALESCE(r.missing_metrics, '') <> '' THEN 1 ELSE 0 END) AS missingMetricSkuCount
FROM ${escapeIdentifier(MAY_PRODUCT_REVIEW_SNAPSHOT_TABLE)} r
INNER JOIN ${escapeIdentifier(MAY_PRODUCT_SNAPSHOT_TABLE)} s
ON s.stat_date = r.stat_date
AND s.snapshot_batch_id = r.snapshot_batch_id
AND s.may_sku_code = r.may_sku_code
WHERE ${whereSql}
AND COALESCE(s.may_inventory_scope_remark, '') <> ''
GROUP BY s.may_inventory_scope_remark`,
params
);
return buildMayProductReviewInventoryScopeBreakdown(rows);
}
async function queryMayProductReviewRoutes(snapshotMeta, reviewAction = '', options = {}) {
const routeOptions = {
...options,
virtualCategory: '',
mode: '',
inventoryScope: '',
inventory_scope: '',
stockPath: '',
stock_path: '',
};
const { whereSql, params } = buildMayProductReviewDashboardWhere(snapshotMeta, reviewAction, routeOptions);
const rows = await planningDatabase.query(
`SELECT
s.may_virtual_category AS virtualCategory,
s.may_inventory_scope_remark AS inventoryScope,
COUNT(*) AS skuCount,
SUM(COALESCE(s.may_performance_amount, 0)) AS salesAmount30d,
SUM(COALESCE(r.sales_qty_30d, 0)) AS salesQty30d,
SUM(COALESCE(r.inventory_quantity, 0)) AS inventoryQuantity,
SUM(COALESCE(s.may_real_company_inventory_quantity, 0)) AS realCompanyInventoryQuantity,
SUM(COALESCE(s.product_cost, 0) * COALESCE(s.may_real_company_inventory_quantity, 0)) AS inventoryAmount,
SUM(COALESCE(r.gross_profit_30d, 0)) AS grossProfit30d,
SUM(CASE WHEN COALESCE(r.missing_metrics, '') <> '' THEN 1 ELSE 0 END) AS missingMetricSkuCount
FROM ${escapeIdentifier(MAY_PRODUCT_REVIEW_SNAPSHOT_TABLE)} r
INNER JOIN ${escapeIdentifier(MAY_PRODUCT_SNAPSHOT_TABLE)} s
ON s.stat_date = r.stat_date
AND s.snapshot_batch_id = r.snapshot_batch_id
AND s.may_sku_code = r.may_sku_code
WHERE ${whereSql}
GROUP BY s.may_virtual_category, s.may_inventory_scope_remark`,
params
);
return buildMayProductReviewRouteBreakdown(rows);
}
async function queryMayProductReviewBrandSales(snapshotMeta, reviewAction = '', options = {}) {
const { whereSql, params } = buildMayProductReviewDashboardWhere(snapshotMeta, reviewAction, options);
const rows = await planningDatabase.query(
`SELECT
s.may_covered_channels AS coveredChannels,
COUNT(*) AS skuCount,
SUM(COALESCE(s.may_performance_amount, 0)) AS salesAmount30d,
SUM(COALESCE(r.sales_qty_30d, 0)) AS salesQty30d,
SUM(COALESCE(r.inventory_quantity, 0)) AS inventoryQuantity,
SUM(COALESCE(s.may_real_company_inventory_quantity, 0)) AS realCompanyInventoryQuantity,
SUM(COALESCE(s.product_cost, 0) * COALESCE(s.may_real_company_inventory_quantity, 0)) AS inventoryAmount,
SUM(COALESCE(r.gross_profit_30d, 0)) AS grossProfit30d,
SUM(CASE WHEN COALESCE(r.missing_metrics, '') <> '' THEN 1 ELSE 0 END) AS missingMetricSkuCount
FROM ${escapeIdentifier(MAY_PRODUCT_REVIEW_SNAPSHOT_TABLE)} r
INNER JOIN ${escapeIdentifier(MAY_PRODUCT_SNAPSHOT_TABLE)} s
ON s.stat_date = r.stat_date
AND s.snapshot_batch_id = r.snapshot_batch_id
AND s.may_sku_code = r.may_sku_code
WHERE ${whereSql}
AND COALESCE(s.may_covered_channels, '') <> ''
GROUP BY s.may_covered_channels`,
params
);
return buildMayProductReviewBrandSalesBreakdown(rows, options);
}
async function getMayProductReviewDashboard(snapshotMeta, options = {}) {
const reviewMeta = await ensureLatestMayProductReviewSnapshot(snapshotMeta);
if (!reviewMeta) {
return null;
}
const selectedAction = normalizeText(options.reviewAction || 'all') || 'all';
const virtualCategory = resolveMayVirtualCategoryFilter(options.virtualCategory || options.mode);
const dashboardFilters = {
virtualCategory,
categoryName: resolveMayReviewDimensionFilter(options.categoryName || options.category || options.category_name),
supplierName: resolveMayReviewDimensionFilter(options.supplierName || options.supplier || options.supplier_name),
missingMetric: resolveMayReviewMissingMetricFilter(options.missingMetric || options.missing_metric),
inventoryScope: resolveMayReviewInventoryScopeFilter(
options.inventoryScope || options.inventory_scope || options.stockPath || options.stock_path
),
brandGroup: resolveMayPlanningBrandGroupFilter(options.brandGroup || options.brand_group),
brandName: resolveMayPlanningBrandNameFilter(options.brandName || options.brand || options.brand_name),
};
const activeActionLabel = selectedAction === 'all'
? MAY_PRODUCT_REVIEW_ACTIONS.all
: resolveReviewActionFilter(selectedAction) || MAY_PRODUCT_REVIEW_ACTIONS.all;
const [
allSummary,
filteredSummary,
actionSummaries,
categoryBreakdown,
supplierBreakdown,
missingMetricBreakdown,
inventoryScopeBreakdown,
routeBreakdown,
brandSalesBreakdown,
] = await Promise.all([
queryMayProductReviewOverview(snapshotMeta, '', dashboardFilters),
queryMayProductReviewOverview(snapshotMeta, selectedAction, dashboardFilters),
queryMayProductReviewActionSummaries(snapshotMeta, dashboardFilters),
queryMayProductReviewBreakdown(snapshotMeta, {
fieldName: 'category_name',
fallbackLabel: MAY_REVIEW_DIMENSION_FALLBACK_LABEL,
reviewAction: selectedAction,
...dashboardFilters,
limit: 8,
}),
queryMayProductReviewBreakdown(snapshotMeta, {
fieldName: 'supplier_name',
fallbackLabel: MAY_REVIEW_DIMENSION_FALLBACK_LABEL,
reviewAction: selectedAction,
...dashboardFilters,
limit: 8,
}),
queryMayProductReviewMissingMetrics(snapshotMeta, selectedAction, dashboardFilters),
queryMayProductReviewInventoryScopes(snapshotMeta, selectedAction, dashboardFilters),
queryMayProductReviewRoutes(snapshotMeta, selectedAction, dashboardFilters),
queryMayProductReviewBrandSales(snapshotMeta, selectedAction, dashboardFilters),
]);
return {
total: allSummary.skuCount,
generatedAt: reviewMeta.generatedAt,
snapshotDate: snapshotMeta.snapshotDate,
sourceLatestBizDate: snapshotMeta.sourceLatestBizDate,
activeAction: selectedAction,
activeActionLabel,
virtualCategory,
filters: {
reviewAction: selectedAction,
...dashboardFilters,
},
overview: filteredSummary,
actions: actionSummaries,
categoryBreakdown,
supplierBreakdown,
missingMetricBreakdown,
inventoryScopeBreakdown,
routeBreakdown,
brandSalesBreakdown,
cards: [
{
key: 'all',
label: MAY_PRODUCT_REVIEW_ACTIONS.all,
count: allSummary.skuCount,
...allSummary,
},
{
key: resolveMayProductReviewActionKey(MAY_PRODUCT_REVIEW_ACTIONS.stop),
label: MAY_PRODUCT_REVIEW_ACTIONS.stop,
count: actionSummaries[MAY_PRODUCT_REVIEW_ACTIONS.stop].skuCount,
...actionSummaries[MAY_PRODUCT_REVIEW_ACTIONS.stop],
},
{
key: resolveMayProductReviewActionKey(MAY_PRODUCT_REVIEW_ACTIONS.focus),
label: MAY_PRODUCT_REVIEW_ACTIONS.focus,
count: actionSummaries[MAY_PRODUCT_REVIEW_ACTIONS.focus].skuCount,
...actionSummaries[MAY_PRODUCT_REVIEW_ACTIONS.focus],
},
{
key: resolveMayProductReviewActionKey(MAY_PRODUCT_REVIEW_ACTIONS.boost),
label: MAY_PRODUCT_REVIEW_ACTIONS.boost,
count: actionSummaries[MAY_PRODUCT_REVIEW_ACTIONS.boost].skuCount,
...actionSummaries[MAY_PRODUCT_REVIEW_ACTIONS.boost],
},
{
key: resolveMayProductReviewActionKey(MAY_PRODUCT_REVIEW_ACTIONS.observe),
label: MAY_PRODUCT_REVIEW_ACTIONS.observe,
count: actionSummaries[MAY_PRODUCT_REVIEW_ACTIONS.observe].skuCount,
...actionSummaries[MAY_PRODUCT_REVIEW_ACTIONS.observe],
},
{
key: resolveMayProductReviewActionKey(MAY_PRODUCT_REVIEW_ACTIONS.data_missing),
label: MAY_PRODUCT_REVIEW_ACTIONS.data_missing,
count: actionSummaries[MAY_PRODUCT_REVIEW_ACTIONS.data_missing].skuCount,
...actionSummaries[MAY_PRODUCT_REVIEW_ACTIONS.data_missing],
},
],
};
}
function resolveReviewActionFilter(value) {
const normalized = normalizeText(value || 'all');
if (!normalized || normalized === 'all' || normalized === MAY_PRODUCT_REVIEW_ACTIONS.all) {
return '';
}
return MAY_PRODUCT_REVIEW_ACTIONS[normalized] || normalized;
}
async function countMayProductSnapshotRowsByReview(snapshotMeta, options = {}) {
const reviewAction = resolveReviewActionFilter(options.reviewAction);
const virtualCategory = resolveMayVirtualCategoryFilter(options.virtualCategory || options.mode);
const categoryName = resolveMayReviewDimensionFilter(options.categoryName || options.category || options.category_name);
const supplierName = resolveMayReviewDimensionFilter(options.supplierName || options.supplier || options.supplier_name);
const missingMetric = resolveMayReviewMissingMetricFilter(options.missingMetric || options.missing_metric);
const inventoryScope = resolveMayReviewInventoryScopeFilter(
options.inventoryScope || options.inventory_scope || options.stockPath || options.stock_path
);
const brandGroup = resolveMayPlanningBrandGroupFilter(options.brandGroup || options.brand_group);
const brandName = resolveMayPlanningBrandNameFilter(options.brandName || options.brand || options.brand_name);
if (
!reviewAction
&& !virtualCategory
&& !categoryName
&& !supplierName
&& !missingMetric
&& !inventoryScope
&& !brandGroup
&& !brandName
) {
return Number(snapshotMeta?.totalRows || 0);
}
const { whereSql, params } = buildMayProductReviewDashboardWhere(snapshotMeta, reviewAction, {
...options,
virtualCategory,
categoryName,
supplierName,
missingMetric,
inventoryScope,
brandGroup,
brandName,
});
const [row] = await planningDatabase.query(
`SELECT COUNT(*) AS totalRows
FROM ${escapeIdentifier(MAY_PRODUCT_REVIEW_SNAPSHOT_TABLE)} r
INNER JOIN ${escapeIdentifier(MAY_PRODUCT_SNAPSHOT_TABLE)} s
ON s.stat_date = r.stat_date
AND s.snapshot_batch_id = r.snapshot_batch_id
AND s.may_sku_code = r.may_sku_code
WHERE ${whereSql}`,
params
);
return Number(row?.totalRows || 0);
}
function buildReviewDisplayRow(row = {}) {
return {
review_action: row.review_action || MAY_PRODUCT_REVIEW_ACTIONS.observe,
review_tags: row.review_tags || MAY_EMPTY_VALUE,
review_reasons: row.review_reasons || MAY_EMPTY_VALUE,
missing_metrics: row.missing_metrics || MAY_EMPTY_VALUE,
review_avg_daily_sales_30d: displayNumberFromSnapshot(row.avg_daily_sales_30d, 2, 0),
review_short_term_growth_rate: displayPercentFromSnapshot(row.short_term_growth_rate, 1),
review_short_term_growth_basis: row.short_term_growth_basis || MAY_EMPTY_VALUE,
review_sales_7d_vs_30d_ratio: displayNumberFromSnapshot(row.sales_7d_vs_30d_ratio, 2),
review_stock_turnover_days: displayNumberFromSnapshot(row.stock_turnover_days, 1),
review_listing_duration_days: displayDurationDaysFromSnapshot(row.listing_duration_days),
review_gross_margin_rate: displayPercentFromSnapshot(row.gross_margin_rate, 1),
review_gross_profit_30d: displayNumberFromSnapshot(row.gross_profit_30d, 2),
review_promotion_roi: displayNumberFromSnapshot(row.promotion_roi, 2),
review_active_sales_day_ratio_30d: displayPercentFromSnapshot(row.active_sales_day_ratio_30d, 1),
};
}
async function queryMayProductSnapshotRows(snapshotMetaOrDate, page = 1, pageSize = 20, options = {}) {
const snapshotMeta = typeof snapshotMetaOrDate === 'string'
? await getSnapshotMetaByDate(snapshotMetaOrDate)
: snapshotMetaOrDate;
const snapshotDate = snapshotMeta?.snapshotDate || formatDateOnly(snapshotMetaOrDate);
const offset = (Math.max(Number(page) || 1, 1) - 1) * pageSize;
const { whereSql, params } = buildMayProductReviewDashboardWhere(
{ ...snapshotMeta, snapshotDate },
options.reviewAction,
options
);
params.push(pageSize, offset);
const rows = await planningDatabase.query(
`SELECT
s.*,
r.review_action,
r.review_tags,
r.review_reasons,
r.missing_metrics,
r.avg_daily_sales_30d,
r.short_term_growth_rate,
r.short_term_growth_basis,
r.sales_7d_vs_30d_ratio,
r.stock_turnover_days,
r.listing_duration_days,
r.gross_margin_rate,
r.gross_profit_30d,
r.promotion_roi,
r.active_sales_day_ratio_30d
FROM ${escapeIdentifier(MAY_PRODUCT_SNAPSHOT_TABLE)} s
LEFT JOIN ${escapeIdentifier(MAY_PRODUCT_REVIEW_SNAPSHOT_TABLE)} r
ON r.stat_date = s.stat_date
AND r.snapshot_batch_id = s.snapshot_batch_id
AND r.may_sku_code = s.may_sku_code
WHERE ${whereSql}
ORDER BY FIELD(r.review_action, ?, ?, ?, ?, ?), s.may_sku_code ASC
LIMIT ? OFFSET ?`,
[
...params.slice(0, -2),
MAY_PRODUCT_REVIEW_ACTIONS.stop,
MAY_PRODUCT_REVIEW_ACTIONS.boost,
MAY_PRODUCT_REVIEW_ACTIONS.focus,
MAY_PRODUCT_REVIEW_ACTIONS.observe,
MAY_PRODUCT_REVIEW_ACTIONS.data_missing,
...params.slice(-2),
]
);
return rows.map((row) => ({
...buildReviewDisplayRow(row),
...buildSnapshotDisplayRow(row),
}));
}
async function startSnapshotSyncLog(payload = {}) {
const result = await planningDatabase.execute(
`INSERT INTO ${escapeIdentifier(MAY_PRODUCT_SNAPSHOT_SYNC_LOG_TABLE)} (
batch_id,
stat_date,
source_latest_biz_date,
source_row_count,
snapshot_row_count,
progress_pct,
status,
calc_version,
started_at,
heartbeat_at
) VALUES (?, ?, ?, ?, ?, ?, 'running', ?, NOW(), NOW())`,
[
payload.batchId,
payload.statDate,
payload.sourceLatestBizDate || null,
payload.sourceRowCount || 0,
0,
0,
MAY_PRODUCT_SNAPSHOT_CALC_VERSION,
]
);
return result.insertId;
}
async function updateSnapshotSyncLogProgress(logId, payload = {}) {
if (!logId) {
return;
}
const sourceRowCount = Math.max(Number(payload.sourceRowCount) || 0, 0);
const snapshotRowCount = Math.max(Number(payload.snapshotRowCount) || 0, 0);
const progressPct = sourceRowCount > 0
? Math.min(100, roundNumber(snapshotRowCount * 100 / sourceRowCount, 4))
: 0;
await planningDatabase.execute(
`UPDATE ${escapeIdentifier(MAY_PRODUCT_SNAPSHOT_SYNC_LOG_TABLE)}
SET source_row_count = ?,
snapshot_row_count = ?,
progress_pct = ?,
heartbeat_at = NOW()
WHERE id = ?`,
[sourceRowCount, snapshotRowCount, progressPct, logId]
);
}
async function finishSnapshotSyncLog(logId, payload = {}) {
if (!logId) {
return;
}
const sourceRowCount = Math.max(Number(payload.sourceRowCount) || 0, 0);
const snapshotRowCount = Math.max(Number(payload.snapshotRowCount) || 0, 0);
const progressPct = sourceRowCount > 0
? Math.min(100, roundNumber(snapshotRowCount * 100 / sourceRowCount, 4))
: payload.status === 'success'
? 100
: 0;
await planningDatabase.execute(
`UPDATE ${escapeIdentifier(MAY_PRODUCT_SNAPSHOT_SYNC_LOG_TABLE)}
SET source_row_count = ?,
snapshot_row_count = ?,
progress_pct = ?,
status = ?,
duration_ms = ?,
error_message = ?,
heartbeat_at = NOW(),
finished_at = NOW()
WHERE id = ?`,
[
sourceRowCount,
snapshotRowCount,
progressPct,
payload.status,
payload.durationMs || null,
payload.errorMessage ? String(payload.errorMessage).slice(0, 2000) : null,
logId,
]
);
}
async function cleanupMayProductSnapshotBatch(statDate, batchId) {
const normalizedStatDate = formatDateOnly(statDate);
const normalizedBatchId = normalizeText(batchId);
if (!normalizedStatDate || !normalizedBatchId) {
return;
}
await planningDatabase.execute(
`DELETE FROM ${escapeIdentifier(MAY_PRODUCT_REVIEW_SNAPSHOT_TABLE)}
WHERE stat_date = ?
AND snapshot_batch_id = ?`,
[normalizedStatDate, normalizedBatchId]
);
await planningDatabase.execute(
`DELETE FROM ${escapeIdentifier(MAY_PRODUCT_SNAPSHOT_TABLE)}
WHERE stat_date = ?
AND snapshot_batch_id = ?`,
[normalizedStatDate, normalizedBatchId]
);
}
async function getPlanningMayProductDataSummary() {
try {
const [snapshotMeta, runningLog] = await Promise.all([
getLatestSnapshotMeta(),
getLatestRunningSnapshotLog(),
]);
if (snapshotMeta) {
return {
tableExists: true,
physicalTableName: MAY_PRODUCT_SNAPSHOT_TABLE,
sourcePhysicalTableName: BUSINESS_SKU_DIMENSION_TABLE,
totalRows: snapshotMeta.totalRows,
databaseConfigured: true,
snapshotDate: snapshotMeta.snapshotDate,
sourceLatestBizDate: snapshotMeta.sourceLatestBizDate,
generatedAt: snapshotMeta.generatedAt,
calcVersion: snapshotMeta.calcVersion,
sourceMode: 'snapshot',
snapshotBatchId: snapshotMeta.snapshotBatchId,
runningSync: runningLog,
};
}
const totalRows = await countMayProductSkuRows();
return {
tableExists: true,
physicalTableName: BUSINESS_SKU_DIMENSION_TABLE,
totalRows,
databaseConfigured: true,
sourceMode: 'realtime',
runningSync: runningLog,
message: 'MAY 商品快照尚未生成,当前仍从业务源表实时计算。',
};
} catch (error) {
console.warn('[PlanningMayProductData] 获取 MAY 商品数据数量失败:', error.message || error);
return {
tableExists: false,
physicalTableName: BUSINESS_SKU_DIMENSION_TABLE,
totalRows: 0,
databaseConfigured: false,
message: error.message || String(error),
};
}
}
async function getPlanningMayProductDataTableDataRealtime(template, page = 1, pageSize = 20, options = {}) {
const virtualCategory = resolveMayVirtualCategoryFilter(options.virtualCategory || options.mode);
const total = await countMayProductSkuRows({ virtualCategory });
const baseRows = await queryMayProductBaseRows(page, pageSize, { virtualCategory });
const rows = await hydrateMayProductDisplayRows(baseRows);
return {
template: {
...template,
totalRows: total,
tableExists: true,
physicalTableName: BUSINESS_SKU_DIMENSION_TABLE,
databaseConfigured: true,
sourceMode: 'realtime',
virtualCategory,
},
columns: MAY_PRODUCT_COLUMNS,
rows,
total,
page,
pageSize,
};
}
async function getPlanningMayProductDataTableData(template, page = 1, pageSize = 20, options = {}) {
const forceRealtime = String(options.realtime || options.forceRealtime || '') === '1';
const virtualCategory = resolveMayVirtualCategoryFilter(options.virtualCategory || options.mode);
if (!forceRealtime) {
const requestedSnapshotDate = formatDateOnly(options.snapshotDate);
const snapshotMeta = requestedSnapshotDate
? await getSnapshotMetaByDate(requestedSnapshotDate)
: await getLatestSnapshotMeta();
if (snapshotMeta) {
await ensureLatestMayProductReviewSnapshot(snapshotMeta);
const reviewDashboard = await getMayProductReviewDashboard(snapshotMeta, options);
const total = await countMayProductSnapshotRowsByReview(snapshotMeta, options);
const rows = await queryMayProductSnapshotRows(snapshotMeta, page, pageSize, options);
return {
template: {
...template,
totalRows: total,
tableExists: true,
physicalTableName: MAY_PRODUCT_SNAPSHOT_TABLE,
reviewPhysicalTableName: MAY_PRODUCT_REVIEW_SNAPSHOT_TABLE,
sourcePhysicalTableName: BUSINESS_SKU_DIMENSION_TABLE,
databaseConfigured: true,
sourceMode: 'snapshot',
snapshotDate: snapshotMeta.snapshotDate,
sourceLatestBizDate: snapshotMeta.sourceLatestBizDate,
generatedAt: snapshotMeta.generatedAt,
calcVersion: snapshotMeta.calcVersion,
virtualCategory,
},
columns: [...MAY_PRODUCT_REVIEW_COLUMNS, ...MAY_PRODUCT_COLUMNS],
rows,
total,
page,
pageSize,
sourceMode: 'snapshot',
snapshotDate: snapshotMeta.snapshotDate,
sourceLatestBizDate: snapshotMeta.sourceLatestBizDate,
generatedAt: snapshotMeta.generatedAt,
virtualCategory,
reviewAction: normalizeText(options.reviewAction || 'all') || 'all',
reviewDashboard,
};
}
}
return getPlanningMayProductDataTableDataRealtime(template, page, pageSize, { ...options, virtualCategory });
}
async function refreshPlanningMayProductSnapshot(options = {}) {
await ensurePlanningMayProductSnapshotSchema();
const startedAt = Date.now();
const chunkSize = Math.max(Number(options.chunkSize) || MAY_PRODUCT_SNAPSHOT_DEFAULT_CHUNK_SIZE, 20);
const profileStageChunkSize = Math.max(
Number(options.profileStageChunkSize) || Math.min(chunkSize, 100),
100
);
let profileStageResult = null;
if (options.refreshProfileStage !== false) {
profileStageResult = await refreshPlanningMayProductProfileStage({
skipIfFresh: true,
chunkSize: profileStageChunkSize,
onProgress: typeof options.onProgress === 'function'
? (payload) => options.onProgress({ stage: 'profile_stage', ...payload })
: undefined,
});
}
let salesStageResult = null;
if (options.refreshSalesStage !== false) {
salesStageResult = await refreshPlanningMaySalesSummaryStage({
skipIfFresh: true,
onProgress: typeof options.onProgress === 'function'
? (payload) => options.onProgress({ stage: 'sales_stage', ...payload })
: undefined,
});
}
const statDate = await resolveSnapshotStatDate(options);
const sourceLatestBizDate = await getLatestMaySalesBizDate();
const batchId = options.batchId || createSnapshotBatchId();
const generatedAt = new Date();
const total = await countMayProductSkuRows();
const profileStageReadiness = await getMayProductProfileStageReadyState({
totalSkuCount: profileStageResult?.totalSkuCount || total,
force: true,
});
if (!profileStageReadiness.ready) {
throw new Error(
`MAY商品资料预计算未完成,当前仅 ${profileStageReadiness.stageCount}/${profileStageReadiness.totalSkuCount} 行,禁止生成快照`
);
}
if (options.refreshSalesStage !== false && salesStageResult?.success !== true) {
throw new Error('MAY销售预计算未完成,禁止生成快照');
}
const logId = await startSnapshotSyncLog({
batchId,
statDate,
sourceLatestBizDate,
sourceRowCount: total,
});
let snapshotRowCount = 0;
try {
const pageCount = Math.max(Math.ceil(total / chunkSize), 1);
for (let page = 1; page <= pageCount; page += 1) {
const baseRows = await queryMayProductBaseRowsFromProfileStage(page, chunkSize);
if (baseRows.length === 0) {
continue;
}
const displayRows = await hydrateMayProductDisplayRows(baseRows);
const snapshotRows = displayRows.map((displayRow, index) => buildMayProductSnapshotRow(
baseRows[index],
displayRow,
{
statDate,
sourceLatestBizDate,
sourceRowCount: total,
batchId,
generatedAt,
}
));
snapshotRowCount += await upsertMayProductSnapshotRows(snapshotRows);
await updateSnapshotSyncLogProgress(logId, {
sourceRowCount: total,
snapshotRowCount,
});
if (typeof options.onProgress === 'function') {
options.onProgress({
page,
pageCount,
chunkSize,
total,
snapshotRowCount,
});
}
}
assertMayProductSnapshotRowsReady(snapshotRowCount, total);
await planningDatabase.execute(
`DELETE FROM ${escapeIdentifier(MAY_PRODUCT_SNAPSHOT_TABLE)}
WHERE stat_date = ?
AND snapshot_batch_id <> ?`,
[statDate, batchId]
);
const reviewResult = await refreshPlanningMayProductReviewSnapshot({
snapshotDate: statDate,
totalRows: snapshotRowCount,
sourceLatestBizDate,
generatedAt,
calcVersion: MAY_PRODUCT_SNAPSHOT_CALC_VERSION,
snapshotBatchId: batchId,
});
const durationMs = Date.now() - startedAt;
await finishSnapshotSyncLog(logId, {
sourceRowCount: total,
snapshotRowCount,
status: 'success',
durationMs,
});
return {
success: true,
tableName: MAY_PRODUCT_SNAPSHOT_TABLE,
statDate,
sourceLatestBizDate,
sourceRowCount: total,
snapshotRowCount,
reviewRowCount: reviewResult.reviewRowCount || 0,
calcVersion: MAY_PRODUCT_SNAPSHOT_CALC_VERSION,
batchId,
elapsedMs: durationMs,
};
} catch (error) {
await finishSnapshotSyncLog(logId, {
sourceRowCount: total,
snapshotRowCount,
status: 'failed',
durationMs: Date.now() - startedAt,
errorMessage: error.message || String(error),
});
try {
await cleanupMayProductSnapshotBatch(statDate, batchId);
} catch (cleanupError) {
console.warn('[PlanningMayProductData] 清理失败快照批次失败:', cleanupError.message || cleanupError);
}
throw error;
}
}
async function getPlanningMayProductSnapshotStatus() {
const [latestSuccess, latestRunning] = await Promise.all([
getLatestSnapshotMeta(),
getLatestRunningSnapshotLog(),
]);
return {
latestSuccess,
latestRunning,
};
}
async function getPlanningMayProductSnapshotCacheVersion(options = {}) {
const requestedSnapshotDate = formatDateOnly(options.snapshotDate);
const snapshotMeta = requestedSnapshotDate
? await getSnapshotMetaByDate(requestedSnapshotDate)
: await getLatestSnapshotMeta();
if (!snapshotMeta?.snapshotBatchId) {
return requestedSnapshotDate
? `snapshot:${requestedSnapshotDate}:none`
: 'snapshot:latest:none';
}
return `snapshot:${snapshotMeta.snapshotDate}:${snapshotMeta.snapshotBatchId}:${snapshotMeta.calcVersion || 'unknown'}`;
}
module.exports = {
getPlanningMayProductDataSummary,
getPlanningMayProductDataTableData,
getPlanningMayProductSnapshotStatus,
getPlanningMayProductSnapshotCacheVersion,
refreshPlanningMayProductSnapshot,
refreshPlanningMayProductReviewSnapshot,
refreshPlanningMayProductProfileStage,
refreshPlanningMaySalesSummaryStage,
ensurePlanningMayProductSnapshotSchema,
ensurePlanningMayProductReviewSnapshotSchema,
ensurePlanningMayProductProfileStageSchema,
ensurePlanningMaySalesStageSchema,
MAY_PRODUCT_SNAPSHOT_TABLE,
MAY_PRODUCT_REVIEW_SNAPSHOT_TABLE,
MAY_PRODUCT_PROFILE_STAGE_TABLE,
MAY_PRODUCT_SALES_SUMMARY_STAGE_TABLE,
MAY_PRODUCT_SALES_MAPPING_STAGE_TABLE,
};