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