6700 lines
256 KiB
JavaScript
6700 lines
256 KiB
JavaScript
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,
|
||
};
|