10376 lines
392 KiB
JavaScript
10376 lines
392 KiB
JavaScript
const crypto = require('crypto');
|
||
const fs = require('fs');
|
||
const os = require('os');
|
||
const path = require('path');
|
||
const dayjs = require('dayjs');
|
||
const cloudDb = require('../config/cloudDatabase');
|
||
const database = require('../config/database');
|
||
const redis = require('../config/redis');
|
||
const dataPageCache = require('./dataPageCache');
|
||
const planningDatabase = require('./planningDatabase');
|
||
const {
|
||
ensurePlanningChannelDailyReadSchema,
|
||
ensurePlanningChannelIdDailyReadSchema,
|
||
} = require('./planningSupportSchema');
|
||
|
||
const channelReadDb = planningDatabase;
|
||
|
||
const DEFAULT_PAGE_SIZE = 20;
|
||
const MAX_PAGE_SIZE = 100;
|
||
const DEFAULT_LOOKBACK_DAYS = 30;
|
||
const CHANNEL_EXPORT_MAX_ROWS = Math.max(
|
||
Number.parseInt(process.env.CHANNEL_EXPORT_MAX_ROWS || '200000', 10) || 200000,
|
||
1000
|
||
);
|
||
const CHANNEL_EXPORT_BATCH_SIZE = Math.min(
|
||
Math.max(Number.parseInt(process.env.CHANNEL_EXPORT_BATCH_SIZE || '200', 10) || 200, 50),
|
||
500
|
||
);
|
||
const CHANNEL_EXPORT_FULL_ENRICH_MAX_ROWS = Math.max(
|
||
Number.parseInt(process.env.CHANNEL_EXPORT_FULL_ENRICH_MAX_ROWS || '1000', 10) || 1000,
|
||
0
|
||
);
|
||
const CHANNEL_EXPORT_JOB_RETENTION_MS = Math.max(
|
||
Number.parseInt(process.env.CHANNEL_EXPORT_JOB_RETENTION_MINUTES || `${24 * 60}`, 10) || 24 * 60,
|
||
10
|
||
) * 60 * 1000;
|
||
const CHANNEL_EXPORT_JOB_DIR = process.env.CHANNEL_EXPORT_JOB_DIR
|
||
|| path.join(os.tmpdir(), 'maixiang-channel-export-jobs');
|
||
const CHANNEL_SOURCE_TABLE = '[JSDataWarehouse].[dws].[dws_sn_sales_full_info_di]';
|
||
const CHANNEL_READ_TABLE = 'channel_daily_read';
|
||
const CHANNEL_SYNC_TABLE = 'channel_daily_read_sync';
|
||
const CHANNEL_ID_READ_TABLE = 'channel_id_daily_read';
|
||
const CHANNEL_ID_SYNC_TABLE = 'channel_id_daily_read_sync';
|
||
const CHANNEL_LARGE_CUSTOMER_ORDER_SOURCE_TABLE = '[dbo].[jst_qmcust_orderlist]';
|
||
const CHANNEL_LARGE_CUSTOMER_ORDER_ITEM_SOURCE_TABLE = '[dbo].[jst_qmcust_orderlist_item]';
|
||
const CHANNEL_LARGE_CUSTOMER_LEGACY_ORDER_SOURCE_TABLE = '[JSDataWarehouse].[dwm].[dwm_ope_trade_order_di]';
|
||
const STOCK_MONITOR_BACKUP_TABLE = '[rpt].[pm_stockMonitor]';
|
||
const STOCK_MONITOR_SKU_EXPR_SQL = "UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), [sku]))))";
|
||
const CHANNEL_LARGE_CUSTOMER_READ_TABLE = 'channel_large_customer_monthly_read';
|
||
const CHANNEL_LARGE_CUSTOMER_ORDER_TABLE = 'channel_large_customer_order_read';
|
||
const CHANNEL_LARGE_CUSTOMER_SYNC_TABLE = 'channel_large_customer_sync';
|
||
const EXCLUDED_SKU_CODES = ['X00005', 'X00006', 'FUWU', 'TCXJ'];
|
||
const CHANNEL_NON_VIRTUAL_SOURCE_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 CHANNEL_RUNTIME_MEMORY_CACHE_TTL_SECONDS = Math.max(
|
||
Number.parseInt(process.env.CHANNEL_RUNTIME_MEMORY_CACHE_TTL_SECONDS || '82800', 10) || 82800,
|
||
120
|
||
);
|
||
const CHANNEL_RUNTIME_REDIS_CACHE_TTL_SECONDS = Math.max(
|
||
Number.parseInt(process.env.CHANNEL_RUNTIME_REDIS_CACHE_TTL_SECONDS || '82800', 10) || 82800,
|
||
CHANNEL_RUNTIME_MEMORY_CACHE_TTL_SECONDS
|
||
);
|
||
const CHANNEL_LIST_CACHE_TTL_SECONDS = CHANNEL_RUNTIME_REDIS_CACHE_TTL_SECONDS;
|
||
const CHANNEL_LIST_MEMORY_TTL_SECONDS = CHANNEL_RUNTIME_MEMORY_CACHE_TTL_SECONDS;
|
||
const CHANNEL_DASHBOARD_CACHE_TTL_SECONDS = CHANNEL_RUNTIME_REDIS_CACHE_TTL_SECONDS;
|
||
const CHANNEL_DASHBOARD_MEMORY_TTL_SECONDS = CHANNEL_RUNTIME_MEMORY_CACHE_TTL_SECONDS;
|
||
const CHANNEL_DASHBOARD_STALE_REDIS_TTL_SECONDS = Math.max(
|
||
Number.parseInt(process.env.CHANNEL_DASHBOARD_STALE_REDIS_TTL_SECONDS || `${7 * 24 * 60 * 60}`, 10) || 7 * 24 * 60 * 60,
|
||
CHANNEL_DASHBOARD_CACHE_TTL_SECONDS
|
||
);
|
||
const CHANNEL_DASHBOARD_STALE_MEMORY_TTL_SECONDS = Math.max(
|
||
Number.parseInt(process.env.CHANNEL_DASHBOARD_STALE_MEMORY_TTL_SECONDS || `${6 * 60 * 60}`, 10) || 6 * 60 * 60,
|
||
60
|
||
);
|
||
const CHANNEL_LATEST_BIZ_DATE_CACHE_TTL_SECONDS = CHANNEL_RUNTIME_REDIS_CACHE_TTL_SECONDS;
|
||
const CHANNEL_LATEST_BIZ_DATE_MEMORY_TTL_SECONDS = CHANNEL_RUNTIME_MEMORY_CACHE_TTL_SECONDS;
|
||
const CHANNEL_LIST_CACHE_VERSION = 'v6';
|
||
const CHANNEL_DASHBOARD_CACHE_VERSION = 'v1';
|
||
const CHANNEL_SPU_LIST_CACHE_VERSION = 'v14-stock-monitor-source-table';
|
||
const CHANNEL_STOCK_MONITOR_DASHBOARD_CACHE_VERSION = 'v6-stock-monitor-fallback-trend';
|
||
const CHANNEL_ID_ANALYSIS_DASHBOARD_CACHE_VERSION = 'v2-stale-coverage';
|
||
const CHANNEL_LARGE_CUSTOMER_CACHE_VERSION = 'v5';
|
||
const CHANNEL_LARGE_CUSTOMER_DASHBOARD_CACHE_VERSION = 'v1';
|
||
const CHANNEL_DETAIL_CACHE_VERSION = 'v2';
|
||
const CHANNEL_LARGE_CUSTOMER_DETAIL_CACHE_VERSION = 'v1';
|
||
const CHANNEL_LARGE_CUSTOMER_SYNC_VERSION = 'qmcust-order-detail-v1';
|
||
const CHANNEL_LARGE_CUSTOMER_LEGACY_SYNC_VERSION = 'legacy-dwm-v2';
|
||
const CHANNEL_LARGE_CUSTOMER_LEGACY_SOURCE_END_MONTH = '2025-04';
|
||
const CHANNEL_LARGE_CUSTOMER_CACHE_TTL_SECONDS = CHANNEL_RUNTIME_REDIS_CACHE_TTL_SECONDS;
|
||
const CHANNEL_LARGE_CUSTOMER_MEMORY_TTL_SECONDS = CHANNEL_RUNTIME_MEMORY_CACHE_TTL_SECONDS;
|
||
const CHANNEL_LARGE_CUSTOMER_DASHBOARD_CACHE_TTL_SECONDS = CHANNEL_RUNTIME_REDIS_CACHE_TTL_SECONDS;
|
||
const CHANNEL_LARGE_CUSTOMER_DASHBOARD_MEMORY_TTL_SECONDS = CHANNEL_RUNTIME_MEMORY_CACHE_TTL_SECONDS;
|
||
const CHANNEL_LARGE_CUSTOMER_DETAIL_CACHE_TTL_SECONDS = CHANNEL_RUNTIME_REDIS_CACHE_TTL_SECONDS;
|
||
const CHANNEL_LARGE_CUSTOMER_DETAIL_MEMORY_TTL_SECONDS = CHANNEL_RUNTIME_MEMORY_CACHE_TTL_SECONDS;
|
||
const CHANNEL_LARGE_CUSTOMER_DEFAULT_MIN_QTY = 5;
|
||
const CHANNEL_LARGE_CUSTOMER_MAX_MONTHS = 24;
|
||
const CHANNEL_LARGE_CUSTOMER_CURRENT_MONTH_SYNC_MAX_AGE_MINUTES = 60;
|
||
const CHANNEL_SPU_LIST_CACHE_TTL_SECONDS = CHANNEL_RUNTIME_REDIS_CACHE_TTL_SECONDS;
|
||
const CHANNEL_SPU_LIST_MEMORY_TTL_SECONDS = CHANNEL_RUNTIME_MEMORY_CACHE_TTL_SECONDS;
|
||
const CHANNEL_ID_ANALYSIS_CACHE_TTL_SECONDS = CHANNEL_RUNTIME_REDIS_CACHE_TTL_SECONDS;
|
||
const CHANNEL_ID_ANALYSIS_MEMORY_TTL_SECONDS = CHANNEL_RUNTIME_MEMORY_CACHE_TTL_SECONDS;
|
||
const CHANNEL_ID_ANALYSIS_DASHBOARD_CACHE_TTL_SECONDS = CHANNEL_RUNTIME_REDIS_CACHE_TTL_SECONDS;
|
||
const CHANNEL_ID_ANALYSIS_DASHBOARD_MEMORY_TTL_SECONDS = CHANNEL_RUNTIME_MEMORY_CACHE_TTL_SECONDS;
|
||
const CHANNEL_ID_ANALYSIS_READ_QUERY_TIMEOUT_MS = Number.parseInt(
|
||
process.env.CHANNEL_ID_ANALYSIS_READ_QUERY_TIMEOUT_MS || '12000',
|
||
10
|
||
);
|
||
const CHANNEL_ID_ANALYSIS_PURCHASER_RANK_TIMEOUT_MS = Number.parseInt(
|
||
process.env.CHANNEL_ID_ANALYSIS_PURCHASER_RANK_TIMEOUT_MS || '8000',
|
||
10
|
||
);
|
||
const CHANNEL_ID_ANALYSIS_DASHBOARD_QUERY_TIMEOUT_MS = Number.parseInt(
|
||
process.env.CHANNEL_ID_ANALYSIS_DASHBOARD_QUERY_TIMEOUT_MS || '12000',
|
||
10
|
||
);
|
||
const CHANNEL_DETAIL_CACHE_TTL_SECONDS = CHANNEL_RUNTIME_REDIS_CACHE_TTL_SECONDS;
|
||
const CHANNEL_DETAIL_MEMORY_TTL_SECONDS = CHANNEL_RUNTIME_MEMORY_CACHE_TTL_SECONDS;
|
||
const CHANNEL_SPU_ENRICHMENT_REQUEST_TIMEOUT_MS = Number.parseInt(
|
||
process.env.CHANNEL_SPU_ENRICHMENT_REQUEST_TIMEOUT_MS || '10000',
|
||
10
|
||
);
|
||
const CHANNEL_SPU_ENRICHMENT_MAX_SKU_CODES_PER_SPU = Number.parseInt(
|
||
process.env.CHANNEL_SPU_ENRICHMENT_MAX_SKU_CODES_PER_SPU || '80',
|
||
10
|
||
);
|
||
const CHANNEL_SPU_ENRICHMENT_QUERY_OPTIONS = {
|
||
requestTimeout: Number.isFinite(CHANNEL_SPU_ENRICHMENT_REQUEST_TIMEOUT_MS)
|
||
? CHANNEL_SPU_ENRICHMENT_REQUEST_TIMEOUT_MS
|
||
: 6000,
|
||
maxAttempts: 1,
|
||
};
|
||
const CHANNEL_STOCK_MONITOR_QUERY_TIMEOUT_MS = Number.parseInt(
|
||
process.env.CHANNEL_STOCK_MONITOR_QUERY_TIMEOUT_MS || '30000',
|
||
10
|
||
);
|
||
const CHANNEL_STOCK_MONITOR_QUERY_OPTIONS = {
|
||
requestTimeout: Number.isFinite(CHANNEL_STOCK_MONITOR_QUERY_TIMEOUT_MS)
|
||
? CHANNEL_STOCK_MONITOR_QUERY_TIMEOUT_MS
|
||
: 30000,
|
||
maxAttempts: 1,
|
||
};
|
||
const CHANNEL_ID_ANALYSIS_READ_QUERY_OPTIONS = {
|
||
requestTimeout: Number.isFinite(CHANNEL_ID_ANALYSIS_READ_QUERY_TIMEOUT_MS)
|
||
? CHANNEL_ID_ANALYSIS_READ_QUERY_TIMEOUT_MS
|
||
: 12000,
|
||
maxAttempts: 1,
|
||
};
|
||
const CHANNEL_ID_ANALYSIS_PURCHASER_RANK_QUERY_OPTIONS = {
|
||
requestTimeout: Number.isFinite(CHANNEL_ID_ANALYSIS_PURCHASER_RANK_TIMEOUT_MS)
|
||
? CHANNEL_ID_ANALYSIS_PURCHASER_RANK_TIMEOUT_MS
|
||
: 8000,
|
||
maxAttempts: 1,
|
||
};
|
||
const CHANNEL_ID_ANALYSIS_DASHBOARD_QUERY_OPTIONS = {
|
||
requestTimeout: Number.isFinite(CHANNEL_ID_ANALYSIS_DASHBOARD_QUERY_TIMEOUT_MS)
|
||
? CHANNEL_ID_ANALYSIS_DASHBOARD_QUERY_TIMEOUT_MS
|
||
: 12000,
|
||
maxAttempts: 1,
|
||
};
|
||
const CHANNEL_SPU_ENRICHMENT_SKU_LIMIT_PER_SPU = Number.isFinite(CHANNEL_SPU_ENRICHMENT_MAX_SKU_CODES_PER_SPU)
|
||
? Math.max(20, CHANNEL_SPU_ENRICHMENT_MAX_SKU_CODES_PER_SPU)
|
||
: 80;
|
||
const STOCK_MONITOR_MONTH_COUNT = 12;
|
||
const STOCK_MONITOR_WARNING_TEXT = '停推广退出活动做预售';
|
||
const CHANNEL_READ_SYNC_VERSION = 'non-virtual-order-v1';
|
||
const CHANNEL_ID_READ_SYNC_VERSION = 'id-daily-read-v3';
|
||
const CHANNEL_RECENT_RESYNC_DAYS = 2;
|
||
const CHANNEL_RECENT_RESYNC_MAX_AGE_MINUTES = 30;
|
||
const CHANNEL_SYNC_CHUNK_DAYS = 7;
|
||
const CHANNEL_ID_SYNC_CHUNK_DAYS = 3;
|
||
const CHANNEL_LATEST_BIZ_DATE_CACHE_KEY = 'channel:latest-biz-date:v1';
|
||
const CHANNEL_ID_ANALYSIS_CACHE_VERSION = 'v8-stale-coverage';
|
||
const CHANNEL_PREWARM_LOOKBACK_DAYS = 30;
|
||
const CHANNEL_PREWARM_PAGE_SIZE = 20;
|
||
const CHANNEL_PREWARM_MAX_PAGES = 5;
|
||
const CHANNEL_ID_ANALYSIS_PREWARM_MAX_PAGES = 3;
|
||
const CHANNEL_PREWARM_SORT_FIELD = 'salesAmount';
|
||
const CHANNEL_PREWARM_SORT_ORDER = 'descend';
|
||
const CHANNEL_ZONE_NAMES = ['一区', '二区', '三区', '四区'];
|
||
const CHANNEL_DEFAULT_SNAPSHOT_MEMORY_STALE_SECONDS = CHANNEL_RUNTIME_MEMORY_CACHE_TTL_SECONDS;
|
||
const CHANNEL_DEFAULT_SNAPSHOT_MEMORY_EXPIRE_SECONDS = CHANNEL_RUNTIME_MEMORY_CACHE_TTL_SECONDS;
|
||
const CHANNEL_DEFAULT_SNAPSHOT_REDIS_TTL_SECONDS = CHANNEL_RUNTIME_REDIS_CACHE_TTL_SECONDS;
|
||
const CHANNEL_DEFAULT_SNAPSHOT_ROLLING_TTL_SECONDS = Math.max(
|
||
Number.parseInt(process.env.CHANNEL_DEFAULT_SNAPSHOT_ROLLING_TTL_SECONDS || '259200', 10) || 259200,
|
||
CHANNEL_DEFAULT_SNAPSHOT_REDIS_TTL_SECONDS
|
||
);
|
||
|
||
const channelDefaultSnapshotStore = new Map();
|
||
const channelExportJobStore = new Map();
|
||
let latestChannelBizDateMemory = null;
|
||
|
||
const PRODUCT_CODE_EXPR_SQL = `
|
||
CASE
|
||
WHEN j.other_1 IS NULL OR LTRIM(RTRIM(j.other_1)) = '' THEN NULL
|
||
WHEN CHARINDEX('K', j.other_1) > 0 THEN LEFT(j.other_1, CHARINDEX('K', j.other_1) - 1)
|
||
ELSE j.other_1
|
||
END
|
||
`;
|
||
|
||
const PURCHASER_SQL = `COALESCE(NULLIF(md.purchaser, N''), NULLIF(mv.purchaser, N''), NULLIF(ml.purchaser, N''), NULLIF(mc.purchaser, N''), N'未配置')`;
|
||
const CHANNEL_ID_OPERATOR_SQL = `COALESCE(NULLIF(md.operatorName, N''), NULLIF(mv.operatorName, N''), NULLIF(ml.operatorName, N''), NULLIF(mc.operatorName, N''), N'未配置')`;
|
||
|
||
const FREIGHT_ZONE_CASE_SQL = `
|
||
CASE
|
||
WHEN f.receiver_state LIKE N'%江苏%' OR f.receiver_state LIKE N'%浙江%' OR f.receiver_state LIKE N'%上海%' OR f.receiver_state LIKE N'%安徽%' THEN N'一区'
|
||
WHEN f.receiver_state LIKE N'%北京%' OR f.receiver_state LIKE N'%天津%' OR f.receiver_state LIKE N'%福建%' OR f.receiver_state LIKE N'%广东%' OR f.receiver_state LIKE N'%河北%' OR f.receiver_state LIKE N'%河南%' OR f.receiver_state LIKE N'%湖北%' OR f.receiver_state LIKE N'%湖南%' OR f.receiver_state LIKE N'%江西%' OR f.receiver_state LIKE N'%山东%' THEN N'二区'
|
||
WHEN f.receiver_state LIKE N'%广西%' OR f.receiver_state LIKE N'%贵州%' OR f.receiver_state LIKE N'%黑龙%' OR f.receiver_state LIKE N'%吉林%' OR f.receiver_state LIKE N'%辽宁%' OR f.receiver_state LIKE N'%山西%' OR f.receiver_state LIKE N'%陕西%' OR f.receiver_state LIKE N'%四川%' OR f.receiver_state LIKE N'%云南%' OR f.receiver_state LIKE N'%重庆%' THEN N'三区'
|
||
WHEN f.receiver_state LIKE N'%甘肃%' OR f.receiver_state LIKE N'%海南%' OR f.receiver_state LIKE N'%内蒙%' OR f.receiver_state LIKE N'%宁夏%' OR f.receiver_state LIKE N'%青海%' THEN N'四区'
|
||
WHEN f.receiver_state LIKE N'%西藏%' OR f.receiver_state LIKE N'%新疆%' THEN N'五区'
|
||
ELSE NULL
|
||
END
|
||
`;
|
||
|
||
const ORDER_ZONE_CASE_SQL = `
|
||
CASE
|
||
WHEN a.undefined74 LIKE N'%江苏%' OR a.undefined74 LIKE N'%浙江%' OR a.undefined74 LIKE N'%上海%' OR a.undefined74 LIKE N'%安徽%' THEN N'一区'
|
||
WHEN a.undefined74 LIKE N'%北京%' OR a.undefined74 LIKE N'%天津%' OR a.undefined74 LIKE N'%福建%' OR a.undefined74 LIKE N'%广东%' OR a.undefined74 LIKE N'%河北%' OR a.undefined74 LIKE N'%河南%' OR a.undefined74 LIKE N'%湖北%' OR a.undefined74 LIKE N'%湖南%' OR a.undefined74 LIKE N'%江西%' OR a.undefined74 LIKE N'%山东%' THEN N'二区'
|
||
WHEN a.undefined74 LIKE N'%广西%' OR a.undefined74 LIKE N'%贵州%' OR a.undefined74 LIKE N'%黑龙%' OR a.undefined74 LIKE N'%吉林%' OR a.undefined74 LIKE N'%辽宁%' OR a.undefined74 LIKE N'%山西%' OR a.undefined74 LIKE N'%陕西%' OR a.undefined74 LIKE N'%四川%' OR a.undefined74 LIKE N'%云南%' OR a.undefined74 LIKE N'%重庆%' THEN N'三区'
|
||
WHEN a.undefined74 LIKE N'%甘肃%' OR a.undefined74 LIKE N'%海南%' OR a.undefined74 LIKE N'%内蒙%' OR a.undefined74 LIKE N'%宁夏%' OR a.undefined74 LIKE N'%青海%' THEN N'四区'
|
||
WHEN a.undefined74 LIKE N'%西藏%' OR a.undefined74 LIKE N'%新疆%' THEN N'五区'
|
||
ELSE NULL
|
||
END
|
||
`;
|
||
|
||
const CHANNEL_ZONE_SELECT_SQL = CHANNEL_ZONE_NAMES.map(
|
||
(zone, index) => `
|
||
SUM(CASE WHEN oz.zone_name = N'${zone}' THEN ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.sales_amount), 0) ELSE 0 END) AS sales_amount_zone${index + 1},
|
||
SUM(CASE WHEN oz.zone_name = N'${zone}' THEN ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.after_sale_amount), 0) ELSE 0 END) AS after_sale_amount_zone${index + 1}`
|
||
).join(',\n');
|
||
|
||
const CHANNEL_ZONE_INSERT_COLUMNS = CHANNEL_ZONE_NAMES.flatMap((_, index) => [
|
||
`sales_amount_zone${index + 1}`,
|
||
`after_sale_amount_zone${index + 1}`,
|
||
]);
|
||
|
||
const CHANNEL_ZONE_READ_SUM_SQL = CHANNEL_ZONE_NAMES.map(
|
||
(_, index) => `
|
||
ROUND(SUM(sales_amount_zone${index + 1}), 2) AS salesAmountZone${index + 1},
|
||
ROUND(SUM(after_sale_amount_zone${index + 1}), 2) AS afterSaleAmountZone${index + 1},
|
||
ROUND(CASE WHEN SUM(sales_amount_zone${index + 1}) > 0 THEN SUM(after_sale_amount_zone${index + 1}) * 100.0 / SUM(sales_amount_zone${index + 1}) ELSE 0 END, 2) AS afterSaleAmountRateZone${index + 1}`
|
||
).join(',\n');
|
||
|
||
function safePage(value, fallback = 1) {
|
||
const parsed = Number.parseInt(value, 10);
|
||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||
}
|
||
|
||
function safePageSize(value, fallback = DEFAULT_PAGE_SIZE) {
|
||
const parsed = Number.parseInt(value, 10);
|
||
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
|
||
return Math.min(parsed, MAX_PAGE_SIZE);
|
||
}
|
||
|
||
function toNumber(value, digits = 2) {
|
||
const numeric = Number(value);
|
||
if (!Number.isFinite(numeric)) return 0;
|
||
return Number(numeric.toFixed(digits));
|
||
}
|
||
|
||
function toNullableNumber(value, digits = 2) {
|
||
const numeric = Number(value);
|
||
if (!Number.isFinite(numeric)) return null;
|
||
return Number(numeric.toFixed(digits));
|
||
}
|
||
|
||
function toOptionalNumber(value, digits = 2) {
|
||
if (value === null || value === undefined || value === '') return null;
|
||
return toNullableNumber(value, digits);
|
||
}
|
||
|
||
function normalizeRatioValue(value, digits = 6) {
|
||
const numeric = Number(value);
|
||
if (!Number.isFinite(numeric)) return null;
|
||
const normalized = Math.abs(numeric) > 1 ? numeric / 100 : numeric;
|
||
return Number(normalized.toFixed(digits));
|
||
}
|
||
|
||
function normalizePddTargetRoiValue(value, digits = 2) {
|
||
const numeric = Number(value);
|
||
if (!Number.isFinite(numeric)) return null;
|
||
const normalized = Math.abs(numeric) >= 1000 ? numeric / 10000 : numeric;
|
||
return Number(normalized.toFixed(digits));
|
||
}
|
||
|
||
function normalizeSkuCodeKey(value) {
|
||
return String(value || '').trim().toUpperCase();
|
||
}
|
||
|
||
function normalizeText(value) {
|
||
return String(value || '').trim();
|
||
}
|
||
|
||
function stripSkuVariantSuffix(value) {
|
||
return normalizeText(value).replace(/-\d+$/u, '');
|
||
}
|
||
|
||
function deriveProductCodeFromSku(value) {
|
||
const skuCode = stripSkuVariantSuffix(value);
|
||
const match = skuCode.match(/^(.+?)N\d/u);
|
||
return match ? match[1] : '';
|
||
}
|
||
|
||
function deriveStyleCodeFromProductCode(value) {
|
||
const productCode = normalizeText(value);
|
||
if (!productCode || productCode.length <= 2) return '';
|
||
return /\d{2}$/u.test(productCode) ? productCode.slice(0, -2) : '';
|
||
}
|
||
|
||
function addUniqueSkuKey(list, value) {
|
||
const skuKey = normalizeSkuCodeKey(value);
|
||
if (skuKey && !list.includes(skuKey)) {
|
||
list.push(skuKey);
|
||
}
|
||
}
|
||
|
||
function buildStockMonitorLookupCandidates(value) {
|
||
const text = normalizeText(value);
|
||
const candidates = [];
|
||
if (!text) return candidates;
|
||
|
||
const stripped = stripSkuVariantSuffix(text);
|
||
const productCode = deriveProductCodeFromSku(stripped);
|
||
const productOrStripped = productCode || stripped;
|
||
|
||
addUniqueSkuKey(candidates, text);
|
||
addUniqueSkuKey(candidates, stripped);
|
||
addUniqueSkuKey(candidates, productCode);
|
||
if (/\d$/u.test(productOrStripped)) {
|
||
addUniqueSkuKey(candidates, productOrStripped.slice(0, -1));
|
||
}
|
||
|
||
return candidates;
|
||
}
|
||
|
||
function buildStockMonitorRowLookupCandidates(row = {}) {
|
||
const candidates = [];
|
||
[row.skuCode, row.skuCodeText, row.spu].forEach((value) => {
|
||
buildStockMonitorLookupCandidates(value).forEach((candidate) => addUniqueSkuKey(candidates, candidate));
|
||
});
|
||
return candidates;
|
||
}
|
||
|
||
function uniqueText(values = []) {
|
||
const seen = new Set();
|
||
const result = [];
|
||
values.forEach((value) => {
|
||
const text = normalizeText(value);
|
||
if (!text || seen.has(text)) return;
|
||
seen.add(text);
|
||
result.push(text);
|
||
});
|
||
return result;
|
||
}
|
||
|
||
function formatLimitedList(values = [], limit = 3, fallback = '') {
|
||
const list = uniqueText(values);
|
||
if (!list.length) return fallback;
|
||
const visible = list.slice(0, limit).join(' / ');
|
||
return list.length > limit ? `${visible} 等${list.length}项` : visible;
|
||
}
|
||
|
||
function averageNumbers(values = [], digits = 2) {
|
||
const numericValues = values
|
||
.map((value) => Number(value))
|
||
.filter((value) => Number.isFinite(value) && value > 0);
|
||
if (!numericValues.length) return null;
|
||
const sum = numericValues.reduce((total, value) => total + value, 0);
|
||
return toNullableNumber(sum / numericValues.length, digits);
|
||
}
|
||
|
||
function buildStockMonitorMonthBuckets(anchorDate, count = STOCK_MONITOR_MONTH_COUNT) {
|
||
const anchor = dayjs(anchorDate).isValid() ? dayjs(anchorDate) : dayjs();
|
||
const anchorMonth = anchor.startOf('month');
|
||
return Array.from({ length: count }, (_, index) => {
|
||
const month = anchorMonth.subtract(count - index - 1, 'month');
|
||
return {
|
||
key: month.format('YYYY-MM'),
|
||
compactKey: month.format('YYYYMM'),
|
||
label: month.format('YYYY年M月'),
|
||
shortLabel: month.format('YY-MM'),
|
||
};
|
||
});
|
||
}
|
||
|
||
function buildStockMonitorQueryWindow(months = []) {
|
||
if (!Array.isArray(months) || months.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
const firstMonth = months[0]?.key;
|
||
const lastMonth = months[months.length - 1]?.key;
|
||
if (!firstMonth || !lastMonth) {
|
||
return null;
|
||
}
|
||
|
||
const startDate = `${firstMonth}-01`;
|
||
const endExclusive = dayjs(`${lastMonth}-01`).add(1, 'month').format('YYYY-MM-DD');
|
||
|
||
return {
|
||
startDate,
|
||
endExclusive,
|
||
startDateValue: new Date(`${startDate}T00:00:00.000Z`),
|
||
endExclusiveValue: new Date(`${endExclusive}T00:00:00.000Z`),
|
||
};
|
||
}
|
||
|
||
function createEmptyStockMonitorMonthlyMap(months = []) {
|
||
return Object.fromEntries((months || []).map((month) => [
|
||
month.key,
|
||
{
|
||
monthKey: month.key,
|
||
label: month.label,
|
||
shortLabel: month.shortLabel,
|
||
stockOutCount: 0,
|
||
warningCount: 0,
|
||
monthlySalesQty: 0,
|
||
},
|
||
]));
|
||
}
|
||
|
||
function parseListParam(value) {
|
||
if (!value) return [];
|
||
if (Array.isArray(value)) {
|
||
return value.map((item) => String(item).trim()).filter(Boolean);
|
||
}
|
||
return String(value)
|
||
.split(',')
|
||
.map((item) => item.trim())
|
||
.filter(Boolean);
|
||
}
|
||
|
||
function parseNumericFilter(value) {
|
||
if (value === null || value === undefined || value === '') {
|
||
return null;
|
||
}
|
||
|
||
const parsed = Number(value);
|
||
return Number.isFinite(parsed) ? parsed : null;
|
||
}
|
||
|
||
function normalizeDateValue(value) {
|
||
if (!value) return null;
|
||
const parsed = dayjs(value);
|
||
return parsed.isValid() ? parsed.format('YYYY-MM-DD') : null;
|
||
}
|
||
|
||
function listDatesInRange(startDate, endDate) {
|
||
const dates = [];
|
||
let cursor = dayjs(startDate);
|
||
const end = dayjs(endDate);
|
||
while (!cursor.isAfter(end, 'day')) {
|
||
dates.push(cursor.format('YYYY-MM-DD'));
|
||
cursor = cursor.add(1, 'day');
|
||
}
|
||
return dates;
|
||
}
|
||
|
||
function splitDatesIntoRanges(dates, maxDays = CHANNEL_SYNC_CHUNK_DAYS) {
|
||
if (!dates.length) return [];
|
||
|
||
const sorted = [...dates].sort();
|
||
const ranges = [];
|
||
let rangeStart = dayjs(sorted[0]);
|
||
let rangeEnd = dayjs(sorted[0]);
|
||
|
||
for (let index = 1; index < sorted.length; index += 1) {
|
||
const current = dayjs(sorted[index]);
|
||
const isContinuous = current.diff(rangeEnd, 'day') === 1;
|
||
const rangeLength = rangeEnd.diff(rangeStart, 'day') + 1;
|
||
|
||
if (isContinuous && rangeLength < maxDays) {
|
||
rangeEnd = current;
|
||
continue;
|
||
}
|
||
|
||
ranges.push({
|
||
startDate: rangeStart.format('YYYY-MM-DD'),
|
||
endDate: rangeEnd.format('YYYY-MM-DD'),
|
||
});
|
||
rangeStart = current;
|
||
rangeEnd = current;
|
||
}
|
||
|
||
ranges.push({
|
||
startDate: rangeStart.format('YYYY-MM-DD'),
|
||
endDate: rangeEnd.format('YYYY-MM-DD'),
|
||
});
|
||
|
||
return ranges;
|
||
}
|
||
|
||
function chunkArray(values, chunkSize = 200) {
|
||
if (!Array.isArray(values) || values.length === 0) return [];
|
||
const chunks = [];
|
||
for (let index = 0; index < values.length; index += chunkSize) {
|
||
chunks.push(values.slice(index, index + chunkSize));
|
||
}
|
||
return chunks;
|
||
}
|
||
|
||
function buildExcludedSkuInClause(params) {
|
||
const placeholders = EXCLUDED_SKU_CODES.map((value, index) => {
|
||
const key = `excludedSku${index}`;
|
||
params[key] = String(value).toUpperCase();
|
||
return `@${key}`;
|
||
});
|
||
return placeholders.join(', ');
|
||
}
|
||
|
||
function addListFilter(whereParts, params, values, columnSql, prefix) {
|
||
if (!Array.isArray(values) || values.length === 0) {
|
||
return;
|
||
}
|
||
|
||
const placeholders = values.map((value, index) => {
|
||
const key = `${prefix}${index}`;
|
||
params[key] = value;
|
||
return `@${key}`;
|
||
});
|
||
|
||
whereParts.push(`${columnSql} IN (${placeholders.join(', ')})`);
|
||
}
|
||
|
||
function addNumericRangeFilter(whereParts, params, minValue, maxValue, columnSql, prefix) {
|
||
const min = parseNumericFilter(minValue);
|
||
const max = parseNumericFilter(maxValue);
|
||
|
||
if (min !== null) {
|
||
const key = `${prefix}Min`;
|
||
params[key] = min;
|
||
whereParts.push(`${columnSql} >= @${key}`);
|
||
}
|
||
|
||
if (max !== null) {
|
||
const key = `${prefix}Max`;
|
||
params[key] = max;
|
||
whereParts.push(`${columnSql} <= @${key}`);
|
||
}
|
||
}
|
||
|
||
function addMysqlListFilter(whereParts, params, values, columnSql) {
|
||
if (!Array.isArray(values) || values.length === 0) {
|
||
return;
|
||
}
|
||
|
||
whereParts.push(`${columnSql} IN (${values.map(() => '?').join(', ')})`);
|
||
params.push(...values);
|
||
}
|
||
|
||
function addMysqlNumericRangeFilter(whereParts, params, minValue, maxValue, columnSql) {
|
||
const min = parseNumericFilter(minValue);
|
||
const max = parseNumericFilter(maxValue);
|
||
|
||
if (min !== null) {
|
||
whereParts.push(`${columnSql} >= ?`);
|
||
params.push(min);
|
||
}
|
||
|
||
if (max !== null) {
|
||
whereParts.push(`${columnSql} <= ?`);
|
||
params.push(max);
|
||
}
|
||
}
|
||
|
||
function resolveSortExpression(sortField) {
|
||
const sortMap = {
|
||
channelName: `bm.channel_name`,
|
||
shopName: `bm.shop_name`,
|
||
spu: `COALESCE(NULLIF(sk.product_code, N''), bm.sku_code)`,
|
||
skuCode: `bm.sku_code`,
|
||
saleStatus: `COALESCE(NULLIF(sk.sale_status, N''), N'未配置')`,
|
||
salesAmount: `bm.sales_amount`,
|
||
afterSaleAmount: `bm.after_sale_amount`,
|
||
afterSaleAmountRate: `CASE WHEN bm.sales_amount > 0 THEN bm.after_sale_amount * 100.0 / bm.sales_amount ELSE 0 END`,
|
||
shipCount: `bm.ship_count`,
|
||
refundCount: `bm.refund_count`,
|
||
refundRate: `CASE WHEN bm.ship_count > 0 THEN bm.refund_count * 100.0 / bm.ship_count ELSE 0 END`,
|
||
purchaserName: PURCHASER_SQL,
|
||
};
|
||
|
||
return sortMap[sortField] || sortMap.salesAmount;
|
||
}
|
||
|
||
function resolveBaseSortExpression(sortField) {
|
||
const sortMap = {
|
||
channelName: `bm.channel_name`,
|
||
shopName: `bm.shop_name`,
|
||
skuCode: `bm.sku_code`,
|
||
salesAmount: `bm.sales_amount`,
|
||
afterSaleAmount: `bm.after_sale_amount`,
|
||
afterSaleAmountRate: `CASE WHEN bm.sales_amount > 0 THEN bm.after_sale_amount * 100.0 / bm.sales_amount ELSE 0 END`,
|
||
shipCount: `bm.ship_count`,
|
||
refundCount: `bm.refund_count`,
|
||
refundRate: `CASE WHEN bm.ship_count > 0 THEN bm.refund_count * 100.0 / bm.ship_count ELSE 0 END`,
|
||
};
|
||
|
||
return sortMap[sortField] || sortMap.salesAmount;
|
||
}
|
||
|
||
function resolveReadSortExpression(sortField) {
|
||
const sortMap = {
|
||
channelName: 'base.channelName',
|
||
shopName: 'base.shopName',
|
||
spu: 'base.spu',
|
||
skuCode: 'base.skuCode',
|
||
saleStatus: 'base.saleStatus',
|
||
salesAmount: 'base.salesAmount',
|
||
afterSaleAmount: 'base.afterSaleAmount',
|
||
afterSaleAmountRate: 'base.afterSaleAmountRate',
|
||
shipCount: 'base.shipCount',
|
||
refundCount: 'base.refundCount',
|
||
refundRate: 'base.refundRate',
|
||
purchaserName: 'base.purchaserName',
|
||
};
|
||
|
||
return sortMap[sortField] || sortMap.salesAmount;
|
||
}
|
||
|
||
function resolveSpuReadSortExpression(sortField) {
|
||
const sortMap = {
|
||
spu: 'base.spu',
|
||
skuCode: 'base.skuCode',
|
||
skuCodeText: 'base.skuCodeText',
|
||
skuCount: 'base.skuCount',
|
||
saleStatus: 'base.saleStatus',
|
||
salesAmount: 'base.salesAmount',
|
||
afterSaleAmount: 'base.afterSaleAmount',
|
||
afterSaleAmountRate: 'base.afterSaleAmountRate',
|
||
shipCount: 'base.shipCount',
|
||
refundCount: 'base.refundCount',
|
||
refundRate: 'base.refundRate',
|
||
purchaserName: 'base.purchaserName',
|
||
};
|
||
|
||
return sortMap[sortField] || sortMap.salesAmount;
|
||
}
|
||
|
||
function resolveChannelIdSortExpression(sortField) {
|
||
const sortMap = {
|
||
channelName: 'fr.channelName',
|
||
shopName: 'fr.shopName',
|
||
purchaserName: 'fr.purchaserName',
|
||
itemId: 'fr.itemId',
|
||
performanceAmount: 'fr.performanceAmount',
|
||
feeRatio: 'fr.feeRatio',
|
||
idAfterSaleRate: 'fr.idAfterSaleRate',
|
||
performanceRefundRate: 'fr.performanceRefundRate',
|
||
goodsCount: 'fr.goodsCount',
|
||
refundCount: 'fr.refundCount',
|
||
};
|
||
|
||
return sortMap[sortField] || sortMap.performanceAmount;
|
||
}
|
||
|
||
function resolveChannelIdReadSortExpression(sortField) {
|
||
const sortMap = {
|
||
channelName: 'base.channelName',
|
||
shopName: 'base.shopName',
|
||
purchaserName: 'base.purchaserName',
|
||
itemId: 'base.itemId',
|
||
primarySkuCode: 'base.primarySkuCode',
|
||
primarySpu: 'base.primarySpu',
|
||
saleStatus: 'base.saleStatus',
|
||
skuCount: 'base.skuCount',
|
||
performanceAmount: 'base.performanceAmount',
|
||
feeRatio: 'base.feeRatio',
|
||
idAfterSaleRate: 'base.idAfterSaleRate',
|
||
performanceRefundRate: 'base.performanceRefundRate',
|
||
goodsCount: 'base.goodsCount',
|
||
refundCount: 'base.refundCount',
|
||
};
|
||
|
||
return sortMap[sortField] || sortMap.performanceAmount;
|
||
}
|
||
|
||
function resolveLargeCustomerSortExpression(sortField) {
|
||
const sortMap = {
|
||
buyerId: 'base.buyerId',
|
||
buyerName: 'base.buyerName',
|
||
channelName: 'base.channelName',
|
||
shopName: 'base.shopName',
|
||
totalAmount: 'base.totalAmount',
|
||
totalGoodsCount: 'base.totalGoodsCount',
|
||
orderCount: 'base.orderCount',
|
||
firstOrderDate: 'base.firstOrderDate',
|
||
lastOrderDate: 'base.lastOrderDate',
|
||
};
|
||
|
||
return sortMap[sortField] || sortMap.totalAmount;
|
||
}
|
||
|
||
function resolveSortOrder(value) {
|
||
if (value === 'ascend' || value === 'asc') return 'ASC';
|
||
return 'DESC';
|
||
}
|
||
|
||
function buildChannelListCacheKey(payload) {
|
||
const normalizedPayload = {
|
||
startDate: payload.startDate,
|
||
endDate: payload.endDate,
|
||
page: payload.page,
|
||
pageSize: payload.pageSize,
|
||
keyword: String(payload.keyword || '').trim(),
|
||
sortField: payload.sortField || 'salesAmount',
|
||
sortOrder: payload.sortOrder || 'descend',
|
||
channels: [...parseListParam(payload.channels)].sort(),
|
||
statuses: [...parseListParam(payload.statuses)].sort(),
|
||
purchasers: [...parseListParam(payload.purchasers)].sort(),
|
||
minAfterSaleAmountRate: parseNumericFilter(payload.minAfterSaleAmountRate),
|
||
maxAfterSaleAmountRate: parseNumericFilter(payload.maxAfterSaleAmountRate),
|
||
};
|
||
|
||
const hash = crypto.createHash('md5').update(JSON.stringify(normalizedPayload)).digest('hex');
|
||
return `channel:list:${CHANNEL_LIST_CACHE_VERSION}:${hash}`;
|
||
}
|
||
|
||
function buildChannelDashboardCacheKey(payload) {
|
||
const normalizedPayload = {
|
||
startDate: payload.startDate,
|
||
endDate: payload.endDate,
|
||
latestBizDate: payload.latestBizDate,
|
||
keyword: String(payload.keyword || '').trim(),
|
||
limit: payload.limit,
|
||
};
|
||
|
||
const hash = crypto.createHash('md5').update(JSON.stringify(normalizedPayload)).digest('hex');
|
||
return `channel:dashboard:${CHANNEL_DASHBOARD_CACHE_VERSION}:${hash}`;
|
||
}
|
||
|
||
function buildChannelSpuListCacheKey(payload) {
|
||
const normalizedPayload = {
|
||
startDate: payload.startDate,
|
||
endDate: payload.endDate,
|
||
page: payload.page,
|
||
pageSize: payload.pageSize,
|
||
keyword: String(payload.keyword || '').trim(),
|
||
sortField: payload.sortField || 'salesAmount',
|
||
sortOrder: payload.sortOrder || 'descend',
|
||
statuses: [...parseListParam(payload.statuses)].sort(),
|
||
purchasers: [...parseListParam(payload.purchasers)].sort(),
|
||
minAfterSaleAmountRate: parseNumericFilter(payload.minAfterSaleAmountRate),
|
||
maxAfterSaleAmountRate: parseNumericFilter(payload.maxAfterSaleAmountRate),
|
||
};
|
||
|
||
const hash = crypto.createHash('md5').update(JSON.stringify(normalizedPayload)).digest('hex');
|
||
return `channel:spu-list:${CHANNEL_SPU_LIST_CACHE_VERSION}:${hash}`;
|
||
}
|
||
|
||
function buildChannelIdAnalysisCacheKey(payload) {
|
||
const normalizedPayload = {
|
||
startDate: payload.startDate,
|
||
endDate: payload.endDate,
|
||
page: payload.page,
|
||
pageSize: payload.pageSize,
|
||
keyword: String(payload.keyword || '').trim(),
|
||
sortField: payload.sortField || 'performanceAmount',
|
||
sortOrder: payload.sortOrder || 'descend',
|
||
};
|
||
|
||
const hash = crypto.createHash('md5').update(JSON.stringify(normalizedPayload)).digest('hex');
|
||
return `channel:id-analysis:${CHANNEL_ID_ANALYSIS_CACHE_VERSION}:${hash}`;
|
||
}
|
||
|
||
function buildChannelIdAnalysisRollingSnapshotKey(payload) {
|
||
return dataPageCache.buildHashedCacheKey(
|
||
'channel:id-analysis:default-snapshot:rolling',
|
||
{
|
||
page: payload.page,
|
||
pageSize: payload.pageSize,
|
||
keyword: String(payload.keyword || '').trim(),
|
||
sortField: payload.sortField || 'performanceAmount',
|
||
sortOrder: payload.sortOrder || 'descend',
|
||
syncVersion: CHANNEL_ID_READ_SYNC_VERSION,
|
||
},
|
||
CHANNEL_ID_ANALYSIS_CACHE_VERSION
|
||
);
|
||
}
|
||
|
||
function buildChannelIdAnalysisDashboardCacheKey(payload) {
|
||
const normalizedPayload = {
|
||
startDate: payload.startDate,
|
||
endDate: payload.endDate,
|
||
latestBizDate: payload.latestBizDate,
|
||
keyword: String(payload.keyword || '').trim(),
|
||
limit: payload.limit,
|
||
};
|
||
|
||
const hash = crypto.createHash('md5').update(JSON.stringify(normalizedPayload)).digest('hex');
|
||
return `channel:id-analysis-dashboard:${CHANNEL_ID_ANALYSIS_DASHBOARD_CACHE_VERSION}:${hash}`;
|
||
}
|
||
|
||
function buildChannelIdAnalysisDashboardRollingSnapshotKey(payload) {
|
||
return dataPageCache.buildHashedCacheKey(
|
||
'channel:id-analysis-dashboard:default-snapshot:rolling',
|
||
{
|
||
keyword: String(payload.keyword || '').trim(),
|
||
limit: payload.limit,
|
||
syncVersion: CHANNEL_ID_READ_SYNC_VERSION,
|
||
},
|
||
CHANNEL_ID_ANALYSIS_DASHBOARD_CACHE_VERSION
|
||
);
|
||
}
|
||
|
||
function buildChannelDetailCacheKey(payload) {
|
||
const normalizedPayload = {
|
||
startDate: payload.startDate,
|
||
endDate: payload.endDate,
|
||
channelName: String(payload.channelName || '').trim(),
|
||
shopName: String(payload.shopName || '').trim(),
|
||
skuCode: String(payload.skuCode || '').trim(),
|
||
};
|
||
|
||
const hash = crypto.createHash('md5').update(JSON.stringify(normalizedPayload)).digest('hex');
|
||
return `channel:detail:${CHANNEL_DETAIL_CACHE_VERSION}:${hash}`;
|
||
}
|
||
|
||
function isPddChannelName(value) {
|
||
const text = String(value || '').trim().toLowerCase();
|
||
return text.includes('拼多多') || text.includes('pinduoduo');
|
||
}
|
||
|
||
function getChannelIdFeeThreshold(channelName) {
|
||
const text = String(channelName || '').trim().toLowerCase();
|
||
if (text.includes('得物')) return 0.74;
|
||
if (text.includes('1688')) return 0.8;
|
||
if (text.includes('唯品会')) return 0.75;
|
||
if (text.includes('京东') || text.includes('jd')) return 0.75;
|
||
if (text.includes('天猫') || text.includes('tmall')) return 0.75;
|
||
if (text.includes('拼多多') || text.includes('pinduoduo')) return 0.75;
|
||
return 0.75;
|
||
}
|
||
|
||
function buildChannelIdPeriodType(dateRange) {
|
||
const start = dayjs(dateRange?.startDate);
|
||
const end = dayjs(dateRange?.endDate);
|
||
const latest = dayjs(dateRange?.latestBizDate);
|
||
if (!start.isValid() || !end.isValid()) return '区间汇总';
|
||
|
||
const isNaturalMonthStart = start.date() === 1;
|
||
const isNaturalMonthEnd = end.isSame(start.endOf('month'), 'day');
|
||
const isCurrentPartialMonth = start.isSame(end, 'month') && end.isSame(latest, 'day');
|
||
if (start.isSame(end, 'month') && isNaturalMonthStart && (isNaturalMonthEnd || isCurrentPartialMonth)) {
|
||
return '月汇总';
|
||
}
|
||
|
||
if (end.diff(start, 'day') <= 7) {
|
||
return '周维度';
|
||
}
|
||
|
||
return '区间汇总';
|
||
}
|
||
|
||
function buildChannelIdAnomalyTag({ channelName, feeRatio, idAfterSaleRate }) {
|
||
const tags = [];
|
||
const feeThreshold = getChannelIdFeeThreshold(channelName);
|
||
|
||
if (Number.isFinite(feeRatio) && feeRatio > feeThreshold) {
|
||
tags.push('费比超标');
|
||
}
|
||
|
||
if (Number.isFinite(idAfterSaleRate) && idAfterSaleRate > 0.05) {
|
||
tags.push('售后占比异常');
|
||
}
|
||
|
||
return tags.join(' / ');
|
||
}
|
||
|
||
function buildChannelIdHeadTailLabel(totalAmount, amountBeforeCurrent) {
|
||
const total = Number(totalAmount);
|
||
const before = Number(amountBeforeCurrent);
|
||
if (!Number.isFinite(total) || total <= 0) return '尾部';
|
||
|
||
const shareBefore = Number.isFinite(before) && before > 0 ? before / total : 0;
|
||
if (shareBefore < 0.3) return '头部';
|
||
if (shareBefore < 0.7) return '腰部';
|
||
return '尾部';
|
||
}
|
||
|
||
function normalizeMonthValue(value) {
|
||
if (!value) return null;
|
||
const text = String(value).trim();
|
||
const parsed = dayjs(text.length === 7 ? `${text}-01` : text);
|
||
return parsed.isValid() ? parsed.format('YYYY-MM') : null;
|
||
}
|
||
|
||
function listMonthsInRange(startMonth, endMonth) {
|
||
const months = [];
|
||
let cursor = dayjs(`${startMonth}-01`).startOf('month');
|
||
const end = dayjs(`${endMonth}-01`).startOf('month');
|
||
|
||
while (!cursor.isAfter(end, 'month')) {
|
||
months.push(cursor.format('YYYY-MM'));
|
||
cursor = cursor.add(1, 'month');
|
||
}
|
||
|
||
return months;
|
||
}
|
||
|
||
function resolveLargeCustomerMonthRange(startMonth, endMonth) {
|
||
const currentMonth = dayjs().format('YYYY-MM');
|
||
let resolvedEnd = normalizeMonthValue(endMonth) || currentMonth;
|
||
if (dayjs(`${resolvedEnd}-01`).isAfter(dayjs(`${currentMonth}-01`), 'month')) {
|
||
resolvedEnd = currentMonth;
|
||
}
|
||
|
||
let resolvedStart = normalizeMonthValue(startMonth)
|
||
|| dayjs().subtract(1, 'year').startOf('year').format('YYYY-MM');
|
||
|
||
if (dayjs(`${resolvedStart}-01`).isAfter(dayjs(`${resolvedEnd}-01`), 'month')) {
|
||
resolvedStart = dayjs(`${resolvedEnd}-01`).startOf('year').format('YYYY-MM');
|
||
}
|
||
|
||
let months = listMonthsInRange(resolvedStart, resolvedEnd);
|
||
if (months.length > CHANNEL_LARGE_CUSTOMER_MAX_MONTHS) {
|
||
months = months.slice(-CHANNEL_LARGE_CUSTOMER_MAX_MONTHS);
|
||
resolvedStart = months[0];
|
||
}
|
||
|
||
return {
|
||
startMonth: resolvedStart,
|
||
endMonth: resolvedEnd,
|
||
startDate: dayjs(`${resolvedStart}-01`).startOf('month').format('YYYY-MM-DD'),
|
||
endDate: dayjs(`${resolvedEnd}-01`).endOf('month').format('YYYY-MM-DD'),
|
||
months,
|
||
};
|
||
}
|
||
|
||
function safeMinSingleOrderQty(value) {
|
||
const parsed = Number.parseInt(value, 10);
|
||
return Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, 999) : CHANNEL_LARGE_CUSTOMER_DEFAULT_MIN_QTY;
|
||
}
|
||
|
||
function buildChannelLargeCustomerCacheKey(payload) {
|
||
const normalizedPayload = {
|
||
startMonth: payload.startMonth,
|
||
endMonth: payload.endMonth,
|
||
page: payload.page,
|
||
pageSize: payload.pageSize,
|
||
keyword: String(payload.keyword || '').trim(),
|
||
sortField: payload.sortField || 'totalAmount',
|
||
sortOrder: payload.sortOrder || 'descend',
|
||
minSingleOrderQty: safeMinSingleOrderQty(payload.minSingleOrderQty),
|
||
channels: [...parseListParam(payload.channels)].sort(),
|
||
shops: [...parseListParam(payload.shops)].sort(),
|
||
};
|
||
|
||
const hash = crypto.createHash('md5').update(JSON.stringify(normalizedPayload)).digest('hex');
|
||
return `channel:large-customers:${CHANNEL_LARGE_CUSTOMER_CACHE_VERSION}:${hash}`;
|
||
}
|
||
|
||
function buildChannelLargeCustomerDashboardCacheKey(payload) {
|
||
const normalizedPayload = {
|
||
startMonth: payload.startMonth,
|
||
endMonth: payload.endMonth,
|
||
keyword: String(payload.keyword || '').trim(),
|
||
minSingleOrderQty: safeMinSingleOrderQty(payload.minSingleOrderQty),
|
||
channels: [...parseListParam(payload.channels)].sort(),
|
||
shops: [...parseListParam(payload.shops)].sort(),
|
||
limit: payload.limit,
|
||
};
|
||
|
||
const hash = crypto.createHash('md5').update(JSON.stringify(normalizedPayload)).digest('hex');
|
||
return `channel:large-customers:dashboard:${CHANNEL_LARGE_CUSTOMER_DASHBOARD_CACHE_VERSION}:${hash}`;
|
||
}
|
||
|
||
function buildChannelLargeCustomerDetailCacheKey(payload) {
|
||
const normalizedPayload = {
|
||
startMonth: payload.startMonth,
|
||
endMonth: payload.endMonth,
|
||
buyerId: String(payload.buyerId || '').trim(),
|
||
channelName: String(payload.channelName || '').trim(),
|
||
shopName: String(payload.shopName || '').trim(),
|
||
minSingleOrderQty: safeMinSingleOrderQty(payload.minSingleOrderQty),
|
||
limit: payload.limit,
|
||
channels: [...parseListParam(payload.channels)].sort(),
|
||
shops: [...parseListParam(payload.shops)].sort(),
|
||
};
|
||
|
||
const hash = crypto.createHash('md5').update(JSON.stringify(normalizedPayload)).digest('hex');
|
||
return `channel:large-customers:detail:${CHANNEL_LARGE_CUSTOMER_DETAIL_CACHE_VERSION}:${hash}`;
|
||
}
|
||
|
||
function buildChannelDefaultSnapshotConfig(latestBizDate, page = 1, pageSize = CHANNEL_PREWARM_PAGE_SIZE) {
|
||
const endDate = dayjs(latestBizDate).format('YYYY-MM-DD');
|
||
const startDate = dayjs(latestBizDate)
|
||
.subtract(CHANNEL_PREWARM_LOOKBACK_DAYS - 1, 'day')
|
||
.format('YYYY-MM-DD');
|
||
|
||
return {
|
||
latestBizDate: endDate,
|
||
startDate,
|
||
endDate,
|
||
lookbackStartDate: startDate,
|
||
page,
|
||
pageSize,
|
||
keyword: '',
|
||
sortField: CHANNEL_PREWARM_SORT_FIELD,
|
||
sortOrder: CHANNEL_PREWARM_SORT_ORDER,
|
||
channels: [],
|
||
statuses: [],
|
||
purchasers: [],
|
||
minAfterSaleAmountRate: null,
|
||
maxAfterSaleAmountRate: null,
|
||
};
|
||
}
|
||
|
||
function buildChannelIdDefaultSnapshotConfig(
|
||
latestBizDate,
|
||
page = 1,
|
||
pageSize = CHANNEL_PREWARM_PAGE_SIZE
|
||
) {
|
||
const endDate = dayjs(latestBizDate).format('YYYY-MM-DD');
|
||
const startDate = dayjs(latestBizDate)
|
||
.subtract(CHANNEL_PREWARM_LOOKBACK_DAYS - 1, 'day')
|
||
.format('YYYY-MM-DD');
|
||
|
||
return {
|
||
latestBizDate: endDate,
|
||
startDate,
|
||
endDate,
|
||
lookbackStartDate: startDate,
|
||
page,
|
||
pageSize,
|
||
keyword: '',
|
||
sortField: 'performanceAmount',
|
||
sortOrder: 'descend',
|
||
};
|
||
}
|
||
|
||
function buildChannelIdDashboardDefaultSnapshotConfig(latestBizDate, limit = 50) {
|
||
const endDate = dayjs(latestBizDate).format('YYYY-MM-DD');
|
||
const startDate = dayjs(latestBizDate)
|
||
.subtract(CHANNEL_PREWARM_LOOKBACK_DAYS - 1, 'day')
|
||
.format('YYYY-MM-DD');
|
||
|
||
return {
|
||
latestBizDate: endDate,
|
||
startDate,
|
||
endDate,
|
||
lookbackStartDate: startDate,
|
||
keyword: '',
|
||
limit: normalizeChannelIdDashboardLimit(limit),
|
||
};
|
||
}
|
||
|
||
function buildChannelStockMonitorDashboardDefaultSnapshotConfig(latestBizDate, limit = 50) {
|
||
const endDate = dayjs(latestBizDate).format('YYYY-MM-DD');
|
||
const startDate = dayjs(latestBizDate)
|
||
.subtract(CHANNEL_PREWARM_LOOKBACK_DAYS - 1, 'day')
|
||
.format('YYYY-MM-DD');
|
||
|
||
return {
|
||
latestBizDate: endDate,
|
||
startDate,
|
||
endDate,
|
||
lookbackStartDate: startDate,
|
||
keyword: '',
|
||
limit: safePageSize(limit, 50),
|
||
statuses: [],
|
||
purchasers: [],
|
||
minAfterSaleAmountRate: null,
|
||
maxAfterSaleAmountRate: null,
|
||
};
|
||
}
|
||
|
||
function buildChannelDefaultSnapshotKey(config) {
|
||
return dataPageCache.buildHashedCacheKey(
|
||
'channel:default-snapshot',
|
||
{
|
||
...config,
|
||
syncVersion: CHANNEL_READ_SYNC_VERSION,
|
||
},
|
||
CHANNEL_LIST_CACHE_VERSION
|
||
);
|
||
}
|
||
|
||
function buildChannelDefaultRollingSnapshotKey(config) {
|
||
return dataPageCache.buildHashedCacheKey(
|
||
'channel:default-snapshot:rolling',
|
||
{
|
||
page: config.page,
|
||
pageSize: config.pageSize,
|
||
keyword: config.keyword,
|
||
sortField: config.sortField,
|
||
sortOrder: config.sortOrder,
|
||
channels: config.channels,
|
||
statuses: config.statuses,
|
||
purchasers: config.purchasers,
|
||
minAfterSaleAmountRate: config.minAfterSaleAmountRate,
|
||
maxAfterSaleAmountRate: config.maxAfterSaleAmountRate,
|
||
syncVersion: CHANNEL_READ_SYNC_VERSION,
|
||
},
|
||
CHANNEL_LIST_CACHE_VERSION
|
||
);
|
||
}
|
||
|
||
function buildChannelIdDefaultSnapshotKey(config) {
|
||
return dataPageCache.buildHashedCacheKey(
|
||
'channel:id-analysis:default-snapshot',
|
||
{
|
||
...config,
|
||
syncVersion: CHANNEL_ID_READ_SYNC_VERSION,
|
||
},
|
||
CHANNEL_ID_ANALYSIS_CACHE_VERSION
|
||
);
|
||
}
|
||
|
||
function buildChannelIdDashboardDefaultSnapshotKey(config) {
|
||
return dataPageCache.buildHashedCacheKey(
|
||
'channel:id-analysis-dashboard:default-snapshot',
|
||
{
|
||
...config,
|
||
syncVersion: CHANNEL_ID_READ_SYNC_VERSION,
|
||
},
|
||
CHANNEL_ID_ANALYSIS_DASHBOARD_CACHE_VERSION
|
||
);
|
||
}
|
||
|
||
function buildChannelStockMonitorDashboardDefaultSnapshotKey(config) {
|
||
return dataPageCache.buildHashedCacheKey(
|
||
'channel:stock-monitor-dashboard:default-snapshot',
|
||
{
|
||
...config,
|
||
syncVersion: CHANNEL_READ_SYNC_VERSION,
|
||
},
|
||
CHANNEL_STOCK_MONITOR_DASHBOARD_CACHE_VERSION
|
||
);
|
||
}
|
||
|
||
function buildChannelStockMonitorDashboardRollingSnapshotKey(config) {
|
||
return dataPageCache.buildHashedCacheKey(
|
||
'channel:stock-monitor-dashboard:default-snapshot:rolling',
|
||
{
|
||
keyword: config.keyword,
|
||
limit: config.limit,
|
||
statuses: config.statuses,
|
||
purchasers: config.purchasers,
|
||
minAfterSaleAmountRate: config.minAfterSaleAmountRate,
|
||
maxAfterSaleAmountRate: config.maxAfterSaleAmountRate,
|
||
syncVersion: CHANNEL_READ_SYNC_VERSION,
|
||
},
|
||
CHANNEL_STOCK_MONITOR_DASHBOARD_CACHE_VERSION
|
||
);
|
||
}
|
||
|
||
function getChannelDefaultSnapshotState(key) {
|
||
const state = channelDefaultSnapshotStore.get(key);
|
||
if (!state) {
|
||
return null;
|
||
}
|
||
|
||
if (state.expiresAt <= Date.now() && !state.refreshPromise) {
|
||
channelDefaultSnapshotStore.delete(key);
|
||
return null;
|
||
}
|
||
|
||
return state;
|
||
}
|
||
|
||
async function clearChannelDefaultSnapshotCaches() {
|
||
channelDefaultSnapshotStore.clear();
|
||
await redis.delPattern('channel:default-snapshot:*');
|
||
await redis.delPattern('channel:default-snapshot:rolling:*');
|
||
await redis.delPattern('channel:id-analysis:default-snapshot:*');
|
||
await redis.delPattern('channel:id-analysis:default-snapshot:rolling:*');
|
||
await redis.delPattern('channel:id-analysis-dashboard:default-snapshot:*');
|
||
await redis.delPattern('channel:id-analysis-dashboard:default-snapshot:rolling:*');
|
||
await redis.delPattern('channel:stock-monitor-dashboard:default-snapshot:*');
|
||
await redis.delPattern('channel:stock-monitor-dashboard:default-snapshot:rolling:*');
|
||
}
|
||
|
||
async function clearChannelReadCaches() {
|
||
latestChannelBizDateMemory = null;
|
||
await redis.del(CHANNEL_LATEST_BIZ_DATE_CACHE_KEY);
|
||
await dataPageCache.deleteByPrefix(`channel:list:${CHANNEL_LIST_CACHE_VERSION}:`);
|
||
await dataPageCache.deleteByPrefix(`channel:dashboard:${CHANNEL_DASHBOARD_CACHE_VERSION}:`);
|
||
await dataPageCache.deleteByPrefix(`channel:spu-list:${CHANNEL_SPU_LIST_CACHE_VERSION}:`);
|
||
await dataPageCache.deleteByPrefix(`channel:stock-monitor-dashboard:${CHANNEL_STOCK_MONITOR_DASHBOARD_CACHE_VERSION}:`);
|
||
await dataPageCache.deleteByPrefix(`channel:id-analysis:${CHANNEL_ID_ANALYSIS_CACHE_VERSION}:`);
|
||
await dataPageCache.deleteByPrefix(`channel:id-analysis-dashboard:${CHANNEL_ID_ANALYSIS_DASHBOARD_CACHE_VERSION}:`);
|
||
await dataPageCache.deleteByPrefix(`channel:detail:${CHANNEL_DETAIL_CACHE_VERSION}:`);
|
||
await clearChannelDefaultSnapshotCaches();
|
||
}
|
||
|
||
async function clearChannelIdAnalysisCaches() {
|
||
for (const key of channelDefaultSnapshotStore.keys()) {
|
||
if (
|
||
key.startsWith('channel:id-analysis:default-snapshot:')
|
||
|| key.startsWith('channel:id-analysis:default-snapshot:rolling:')
|
||
|| key.startsWith('channel:id-analysis-dashboard:default-snapshot:')
|
||
|| key.startsWith('channel:id-analysis-dashboard:default-snapshot:rolling:')
|
||
) {
|
||
channelDefaultSnapshotStore.delete(key);
|
||
}
|
||
}
|
||
await dataPageCache.deleteByPrefix(`channel:id-analysis:${CHANNEL_ID_ANALYSIS_CACHE_VERSION}:`);
|
||
await dataPageCache.deleteByPrefix(`channel:id-analysis-dashboard:${CHANNEL_ID_ANALYSIS_DASHBOARD_CACHE_VERSION}:`);
|
||
await redis.delPattern('channel:id-analysis:default-snapshot:*');
|
||
await redis.delPattern('channel:id-analysis:default-snapshot:rolling:*');
|
||
await redis.delPattern('channel:id-analysis-dashboard:default-snapshot:*');
|
||
await redis.delPattern('channel:id-analysis-dashboard:default-snapshot:rolling:*');
|
||
}
|
||
|
||
async function clearChannelLargeCustomerCaches() {
|
||
await dataPageCache.deleteByPrefix(`channel:large-customers:${CHANNEL_LARGE_CUSTOMER_CACHE_VERSION}:`);
|
||
await dataPageCache.deleteByPrefix(`channel:large-customers:dashboard:${CHANNEL_LARGE_CUSTOMER_DASHBOARD_CACHE_VERSION}:`);
|
||
await dataPageCache.deleteByPrefix(`channel:large-customers:detail:${CHANNEL_LARGE_CUSTOMER_DETAIL_CACHE_VERSION}:`);
|
||
}
|
||
|
||
function setChannelDefaultSnapshotState(
|
||
key,
|
||
value,
|
||
{
|
||
staleTtlSeconds = CHANNEL_DEFAULT_SNAPSHOT_MEMORY_STALE_SECONDS,
|
||
expireTtlSeconds = CHANNEL_DEFAULT_SNAPSHOT_MEMORY_EXPIRE_SECONDS,
|
||
} = {}
|
||
) {
|
||
const now = Date.now();
|
||
const current = channelDefaultSnapshotStore.get(key) || {};
|
||
channelDefaultSnapshotStore.set(key, {
|
||
...current,
|
||
value,
|
||
staleAt: now + staleTtlSeconds * 1000,
|
||
expiresAt: now + expireTtlSeconds * 1000,
|
||
refreshPromise: null,
|
||
});
|
||
}
|
||
|
||
function getSnapshotDateRange(value) {
|
||
return {
|
||
startDate: normalizeDateValue(value?.dateRange?.startDate),
|
||
endDate: normalizeDateValue(value?.dateRange?.endDate),
|
||
latestBizDate: normalizeDateValue(value?.dateRange?.latestBizDate),
|
||
};
|
||
}
|
||
|
||
function isSameSnapshotWindow(value, expectedRange) {
|
||
const current = getSnapshotDateRange(value);
|
||
return (
|
||
current.startDate === normalizeDateValue(expectedRange?.startDate)
|
||
&& current.endDate === normalizeDateValue(expectedRange?.endDate)
|
||
&& (
|
||
!normalizeDateValue(expectedRange?.latestBizDate)
|
||
|| current.latestBizDate === normalizeDateValue(expectedRange?.latestBizDate)
|
||
)
|
||
);
|
||
}
|
||
|
||
function withSnapshotResponseMeta(value, overrides = {}) {
|
||
return {
|
||
...value,
|
||
meta: {
|
||
...(value?.meta || {}),
|
||
...overrides,
|
||
},
|
||
};
|
||
}
|
||
|
||
function triggerSnapshotRefresh(refreshSnapshot, logLabel) {
|
||
refreshSnapshot().catch((error) => {
|
||
console.warn(`[ChannelCache] ${logLabel}后台刷新失败:`, error.message);
|
||
});
|
||
}
|
||
|
||
async function refreshNamedSnapshot({
|
||
cacheKey,
|
||
rollingKey,
|
||
buildValue,
|
||
label,
|
||
}) {
|
||
const current = getChannelDefaultSnapshotState(cacheKey) || {};
|
||
if (current.refreshPromise) {
|
||
return current.refreshPromise;
|
||
}
|
||
|
||
const refreshPromise = (async () => {
|
||
try {
|
||
const value = await buildValue();
|
||
const cachedAt = new Date().toISOString();
|
||
|
||
setChannelDefaultSnapshotState(cacheKey, value);
|
||
await redis.setex(cacheKey, CHANNEL_DEFAULT_SNAPSHOT_REDIS_TTL_SECONDS, {
|
||
value,
|
||
cachedAt,
|
||
});
|
||
|
||
if (rollingKey) {
|
||
setChannelDefaultSnapshotState(rollingKey, value, {
|
||
staleTtlSeconds: CHANNEL_DEFAULT_SNAPSHOT_ROLLING_TTL_SECONDS,
|
||
expireTtlSeconds: CHANNEL_DEFAULT_SNAPSHOT_ROLLING_TTL_SECONDS,
|
||
});
|
||
await redis.setex(rollingKey, CHANNEL_DEFAULT_SNAPSHOT_ROLLING_TTL_SECONDS, {
|
||
value,
|
||
cachedAt,
|
||
});
|
||
}
|
||
|
||
return value;
|
||
} finally {
|
||
[cacheKey, rollingKey].filter(Boolean).forEach((key) => {
|
||
const latestState = channelDefaultSnapshotStore.get(key);
|
||
if (latestState && latestState.refreshPromise === refreshPromise) {
|
||
latestState.refreshPromise = null;
|
||
channelDefaultSnapshotStore.set(key, latestState);
|
||
}
|
||
});
|
||
}
|
||
})();
|
||
|
||
[cacheKey, rollingKey].filter(Boolean).forEach((key) => {
|
||
const state = channelDefaultSnapshotStore.get(key) || {};
|
||
channelDefaultSnapshotStore.set(key, {
|
||
...state,
|
||
refreshPromise,
|
||
});
|
||
});
|
||
|
||
if (label) {
|
||
console.log(`[ChannelCache] 开始刷新${label}`);
|
||
}
|
||
|
||
return refreshPromise;
|
||
}
|
||
|
||
async function persistRollingSnapshotValue(rollingKey, value) {
|
||
if (!rollingKey || !value) {
|
||
return;
|
||
}
|
||
|
||
setChannelDefaultSnapshotState(rollingKey, value, {
|
||
staleTtlSeconds: CHANNEL_DEFAULT_SNAPSHOT_ROLLING_TTL_SECONDS,
|
||
expireTtlSeconds: CHANNEL_DEFAULT_SNAPSHOT_ROLLING_TTL_SECONDS,
|
||
});
|
||
await redis.setex(rollingKey, CHANNEL_DEFAULT_SNAPSHOT_ROLLING_TTL_SECONDS, {
|
||
value,
|
||
cachedAt: new Date().toISOString(),
|
||
});
|
||
}
|
||
|
||
async function getNamedSnapshot({
|
||
cacheKey,
|
||
rollingKey,
|
||
refreshSnapshot,
|
||
requestedRange,
|
||
refreshLogLabel,
|
||
fallbackLoader,
|
||
}) {
|
||
const memoryState = getChannelDefaultSnapshotState(cacheKey);
|
||
|
||
if (memoryState?.value && memoryState.staleAt > Date.now()) {
|
||
return {
|
||
value: memoryState.value,
|
||
hit: 'memory-default',
|
||
};
|
||
}
|
||
|
||
if (memoryState?.value) {
|
||
triggerSnapshotRefresh(refreshSnapshot, refreshLogLabel);
|
||
return {
|
||
value: memoryState.value,
|
||
hit: 'stale-memory-default',
|
||
};
|
||
}
|
||
|
||
const redisSnapshot = await redis.get(cacheKey);
|
||
if (redisSnapshot?.value) {
|
||
setChannelDefaultSnapshotState(cacheKey, redisSnapshot.value);
|
||
triggerSnapshotRefresh(refreshSnapshot, refreshLogLabel);
|
||
return {
|
||
value: redisSnapshot.value,
|
||
hit: 'redis-default',
|
||
};
|
||
}
|
||
|
||
const rollingSources = rollingKey ? [
|
||
{
|
||
hit: 'rolling-memory-default',
|
||
value: getChannelDefaultSnapshotState(rollingKey)?.value,
|
||
persist: (value) => setChannelDefaultSnapshotState(rollingKey, value, {
|
||
staleTtlSeconds: CHANNEL_DEFAULT_SNAPSHOT_ROLLING_TTL_SECONDS,
|
||
expireTtlSeconds: CHANNEL_DEFAULT_SNAPSHOT_ROLLING_TTL_SECONDS,
|
||
}),
|
||
},
|
||
{
|
||
hit: 'rolling-redis-default',
|
||
value: (await redis.get(rollingKey))?.value,
|
||
persist: (value) => setChannelDefaultSnapshotState(rollingKey, value, {
|
||
staleTtlSeconds: CHANNEL_DEFAULT_SNAPSHOT_ROLLING_TTL_SECONDS,
|
||
expireTtlSeconds: CHANNEL_DEFAULT_SNAPSHOT_ROLLING_TTL_SECONDS,
|
||
}),
|
||
},
|
||
] : [];
|
||
|
||
for (const source of rollingSources) {
|
||
if (!source.value) {
|
||
continue;
|
||
}
|
||
|
||
source.persist(source.value);
|
||
triggerSnapshotRefresh(refreshSnapshot, refreshLogLabel);
|
||
const sameWindow = isSameSnapshotWindow(source.value, requestedRange);
|
||
|
||
return {
|
||
value: withSnapshotResponseMeta(source.value, {
|
||
snapshotMode: true,
|
||
defaultWindow: true,
|
||
snapshotFallback: 'rolling-default',
|
||
snapshotRequestedDateRange: requestedRange,
|
||
snapshotSourceDateRange: getSnapshotDateRange(source.value),
|
||
staleRequestedWindow: !sameWindow,
|
||
}),
|
||
hit: sameWindow ? source.hit : `${source.hit}-stale`,
|
||
};
|
||
}
|
||
|
||
if (typeof fallbackLoader === 'function') {
|
||
const fallbackValue = await fallbackLoader();
|
||
if (fallbackValue) {
|
||
await persistRollingSnapshotValue(rollingKey, fallbackValue);
|
||
triggerSnapshotRefresh(refreshSnapshot, refreshLogLabel);
|
||
return {
|
||
value: fallbackValue,
|
||
hit: 'generated-fallback-default',
|
||
};
|
||
}
|
||
}
|
||
|
||
const value = await refreshSnapshot();
|
||
return {
|
||
value,
|
||
hit: 'miss-default',
|
||
};
|
||
}
|
||
|
||
function isDefaultChannelQuery(options, dateRange, page, pageSize) {
|
||
if (page < 1 || page > CHANNEL_PREWARM_MAX_PAGES) {
|
||
return false;
|
||
}
|
||
|
||
if (pageSize !== CHANNEL_PREWARM_PAGE_SIZE) {
|
||
return false;
|
||
}
|
||
|
||
const expected = buildChannelDefaultSnapshotConfig(dateRange.latestBizDate, page, pageSize);
|
||
const keyword = String(options.keyword || '').trim();
|
||
const hasListFilters = (
|
||
parseListParam(options.channels).length > 0
|
||
|| parseListParam(options.statuses).length > 0
|
||
|| parseListParam(options.purchasers).length > 0
|
||
);
|
||
const hasRateFilters = (
|
||
parseNumericFilter(options.minAfterSaleAmountRate) !== null
|
||
|| parseNumericFilter(options.maxAfterSaleAmountRate) !== null
|
||
);
|
||
|
||
return (
|
||
!keyword
|
||
&& !hasListFilters
|
||
&& !hasRateFilters
|
||
&& (options.sortField || CHANNEL_PREWARM_SORT_FIELD) === CHANNEL_PREWARM_SORT_FIELD
|
||
&& (options.sortOrder || CHANNEL_PREWARM_SORT_ORDER) === CHANNEL_PREWARM_SORT_ORDER
|
||
&& dateRange.startDate === expected.startDate
|
||
&& dateRange.endDate === expected.endDate
|
||
);
|
||
}
|
||
|
||
function isDefaultChannelIdAnalysisQuery(options, dateRange, page, pageSize) {
|
||
if (page < 1 || page > CHANNEL_ID_ANALYSIS_PREWARM_MAX_PAGES) {
|
||
return false;
|
||
}
|
||
|
||
if (pageSize !== CHANNEL_PREWARM_PAGE_SIZE) {
|
||
return false;
|
||
}
|
||
|
||
const expected = buildChannelIdDefaultSnapshotConfig(dateRange.latestBizDate, page, pageSize);
|
||
return (
|
||
!String(options.keyword || '').trim()
|
||
&& (options.sortField || 'performanceAmount') === 'performanceAmount'
|
||
&& (options.sortOrder || 'descend') === 'descend'
|
||
&& dateRange.startDate === expected.startDate
|
||
&& dateRange.endDate === expected.endDate
|
||
);
|
||
}
|
||
|
||
function isDefaultChannelIdDashboardQuery(options, dateRange, limit) {
|
||
const expected = buildChannelIdDashboardDefaultSnapshotConfig(dateRange.latestBizDate, limit);
|
||
return (
|
||
!String(options.keyword || '').trim()
|
||
&& limit === normalizeChannelIdDashboardLimit(50)
|
||
&& dateRange.startDate === expected.startDate
|
||
&& dateRange.endDate === expected.endDate
|
||
);
|
||
}
|
||
|
||
function isDefaultChannelStockMonitorDashboardQuery(options, dateRange, limit) {
|
||
const expected = buildChannelStockMonitorDashboardDefaultSnapshotConfig(dateRange.latestBizDate, limit);
|
||
const hasListFilters = (
|
||
parseListParam(options.statuses).length > 0
|
||
|| parseListParam(options.purchasers).length > 0
|
||
);
|
||
const hasRateFilters = (
|
||
parseNumericFilter(options.minAfterSaleAmountRate) !== null
|
||
|| parseNumericFilter(options.maxAfterSaleAmountRate) !== null
|
||
);
|
||
|
||
return (
|
||
!String(options.keyword || '').trim()
|
||
&& !hasListFilters
|
||
&& !hasRateFilters
|
||
&& limit === safePageSize(50, 50)
|
||
&& dateRange.startDate === expected.startDate
|
||
&& dateRange.endDate === expected.endDate
|
||
);
|
||
}
|
||
|
||
function isLatestDefaultWindowLike(startDate, endDate, latest) {
|
||
const parsedStart = dayjs(startDate);
|
||
const parsedEnd = dayjs(endDate);
|
||
if (!parsedStart.isValid() || !parsedEnd.isValid()) {
|
||
return false;
|
||
}
|
||
|
||
const latestDay = latest.startOf('day');
|
||
const expectedStart = latestDay.subtract(DEFAULT_LOOKBACK_DAYS - 1, 'day');
|
||
const startDay = parsedStart.startOf('day');
|
||
const endDay = parsedEnd.startOf('day');
|
||
const rangeDays = endDay.diff(startDay, 'day');
|
||
const startDeltaDays = Math.abs(startDay.diff(expectedStart, 'day'));
|
||
|
||
return (
|
||
!endDay.isBefore(latestDay, 'day')
|
||
&& rangeDays >= DEFAULT_LOOKBACK_DAYS - 2
|
||
&& rangeDays <= DEFAULT_LOOKBACK_DAYS + 1
|
||
&& startDeltaDays <= 2
|
||
);
|
||
}
|
||
|
||
async function getLatestChannelBizDate() {
|
||
if (latestChannelBizDateMemory?.expiresAt > Date.now()) {
|
||
return latestChannelBizDateMemory.value;
|
||
}
|
||
|
||
const cached = await redis.get(CHANNEL_LATEST_BIZ_DATE_CACHE_KEY);
|
||
if (cached?.latestBizDate) {
|
||
latestChannelBizDateMemory = {
|
||
value: cached.latestBizDate,
|
||
expiresAt: Date.now() + CHANNEL_LATEST_BIZ_DATE_MEMORY_TTL_SECONDS * 1000,
|
||
};
|
||
return cached.latestBizDate;
|
||
}
|
||
|
||
const rows = await cloudDb.query(`
|
||
SELECT MAX(stat_date) AS latestBizDate
|
||
FROM ${CHANNEL_SOURCE_TABLE}
|
||
WHERE stat_date IS NOT NULL
|
||
`);
|
||
|
||
const latestBizDate = normalizeDateValue(rows?.[0]?.latestBizDate);
|
||
if (latestBizDate) {
|
||
latestChannelBizDateMemory = {
|
||
value: latestBizDate,
|
||
expiresAt: Date.now() + CHANNEL_LATEST_BIZ_DATE_MEMORY_TTL_SECONDS * 1000,
|
||
};
|
||
await redis.setex(CHANNEL_LATEST_BIZ_DATE_CACHE_KEY, CHANNEL_LATEST_BIZ_DATE_CACHE_TTL_SECONDS, { latestBizDate });
|
||
}
|
||
return latestBizDate;
|
||
}
|
||
|
||
async function resolveDateRange(startDate, endDate) {
|
||
const latestBizDate = await getLatestChannelBizDate();
|
||
const latest = latestBizDate && dayjs(latestBizDate).isValid() ? dayjs(latestBizDate) : dayjs();
|
||
|
||
if (isLatestDefaultWindowLike(startDate, endDate, latest)) {
|
||
const resolvedStart = latest.subtract(DEFAULT_LOOKBACK_DAYS - 1, 'day');
|
||
return {
|
||
startDate: resolvedStart.format('YYYY-MM-DD'),
|
||
endDate: latest.format('YYYY-MM-DD'),
|
||
latestBizDate: latest.format('YYYY-MM-DD'),
|
||
lookbackStartDate: resolvedStart.format('YYYY-MM-DD'),
|
||
};
|
||
}
|
||
|
||
let resolvedEnd = dayjs(endDate).isValid() ? dayjs(endDate) : latest;
|
||
if (resolvedEnd.isAfter(latest, 'day')) {
|
||
resolvedEnd = latest;
|
||
}
|
||
|
||
let resolvedStart = dayjs(startDate).isValid()
|
||
? dayjs(startDate)
|
||
: resolvedEnd.subtract(DEFAULT_LOOKBACK_DAYS - 1, 'day');
|
||
if (resolvedStart.isAfter(resolvedEnd, 'day')) {
|
||
resolvedStart = resolvedEnd.subtract(DEFAULT_LOOKBACK_DAYS - 1, 'day');
|
||
}
|
||
|
||
return {
|
||
startDate: resolvedStart.format('YYYY-MM-DD'),
|
||
endDate: resolvedEnd.format('YYYY-MM-DD'),
|
||
latestBizDate: latest.format('YYYY-MM-DD'),
|
||
lookbackStartDate: resolvedEnd.subtract(DEFAULT_LOOKBACK_DAYS - 1, 'day').format('YYYY-MM-DD'),
|
||
};
|
||
}
|
||
|
||
function buildReadStoreBaseSql() {
|
||
return `
|
||
SELECT
|
||
channel_name AS channelName,
|
||
shop_name AS shopName,
|
||
shop_key AS shopKey,
|
||
COALESCE(NULLIF(MAX(spu), ''), sku_code) AS spu,
|
||
sku_code AS skuCode,
|
||
MAX(NULLIF(sku_image_url, '')) AS skuImageUrl,
|
||
COALESCE(NULLIF(MAX(sale_status), ''), '未配置') AS saleStatus,
|
||
COALESCE(NULLIF(MAX(purchaser_name), ''), '未配置') AS purchaserName,
|
||
ROUND(SUM(sales_amount), 2) AS salesAmount,
|
||
ROUND(SUM(after_sale_amount), 2) AS afterSaleAmount,
|
||
ROUND(CASE WHEN SUM(sales_amount) > 0 THEN SUM(after_sale_amount) * 100.0 / SUM(sales_amount) ELSE 0 END, 2) AS afterSaleAmountRate,
|
||
ROUND(SUM(ship_count), 2) AS shipCount,
|
||
ROUND(SUM(refund_count), 2) AS refundCount,
|
||
${CHANNEL_ZONE_READ_SUM_SQL},
|
||
ROUND(CASE WHEN SUM(ship_count) > 0 THEN SUM(refund_count) * 100.0 / SUM(ship_count) ELSE 0 END, 2) AS refundRate
|
||
FROM ${CHANNEL_READ_TABLE}
|
||
WHERE stat_date BETWEEN ? AND ?
|
||
GROUP BY channel_name, shop_name, shop_key, sku_code
|
||
`;
|
||
}
|
||
|
||
function buildReadStoreFilteredSql({ startDate, endDate, keyword, channels, statuses, purchasers, minAfterSaleAmountRate, maxAfterSaleAmountRate }) {
|
||
const innerSql = buildReadStoreBaseSql();
|
||
const params = [startDate, endDate];
|
||
const whereParts = [];
|
||
|
||
if (keyword && String(keyword).trim()) {
|
||
const keywordValue = `%${String(keyword).trim()}%`;
|
||
whereParts.push(`(
|
||
base.channelName LIKE ?
|
||
OR base.shopName LIKE ?
|
||
OR base.spu LIKE ?
|
||
OR base.skuCode LIKE ?
|
||
OR base.purchaserName LIKE ?
|
||
)`);
|
||
params.push(keywordValue, keywordValue, keywordValue, keywordValue, keywordValue);
|
||
}
|
||
|
||
addMysqlListFilter(whereParts, params, parseListParam(channels), 'base.channelName');
|
||
addMysqlListFilter(whereParts, params, parseListParam(statuses), 'base.saleStatus');
|
||
addMysqlListFilter(whereParts, params, parseListParam(purchasers), 'base.purchaserName');
|
||
addMysqlNumericRangeFilter(whereParts, params, minAfterSaleAmountRate, maxAfterSaleAmountRate, 'base.afterSaleAmountRate');
|
||
|
||
const whereSql = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '';
|
||
return {
|
||
sql: `SELECT * FROM (${innerSql}) base ${whereSql}`,
|
||
params,
|
||
};
|
||
}
|
||
|
||
function hasReadStoreFilters({ keyword, channels, statuses, purchasers, minAfterSaleAmountRate, maxAfterSaleAmountRate }) {
|
||
return (
|
||
Boolean(String(keyword || '').trim())
|
||
|| parseListParam(channels).length > 0
|
||
|| parseListParam(statuses).length > 0
|
||
|| parseListParam(purchasers).length > 0
|
||
|| parseNumericFilter(minAfterSaleAmountRate) !== null
|
||
|| parseNumericFilter(maxAfterSaleAmountRate) !== null
|
||
);
|
||
}
|
||
|
||
async function countReadStoreRows(filterOptions, sql, params) {
|
||
await ensurePlanningChannelDailyReadSchema();
|
||
if (!hasReadStoreFilters(filterOptions)) {
|
||
const rows = await channelReadDb.query(
|
||
`
|
||
SELECT COUNT(*) AS totalCount
|
||
FROM (
|
||
SELECT 1
|
||
FROM ${CHANNEL_READ_TABLE}
|
||
WHERE stat_date BETWEEN ? AND ?
|
||
GROUP BY channel_name, shop_name, shop_key, sku_code
|
||
) grouped_rows
|
||
`,
|
||
[filterOptions.startDate, filterOptions.endDate]
|
||
);
|
||
return Number(rows?.[0]?.totalCount || 0);
|
||
}
|
||
|
||
const rows = await channelReadDb.query(`SELECT COUNT(*) AS totalCount FROM (${sql}) counted`, params);
|
||
return Number(rows?.[0]?.totalCount || 0);
|
||
}
|
||
|
||
function buildReadStoreSpuBaseSql(extraWhereSql = '') {
|
||
return `
|
||
SELECT
|
||
sku_code AS skuCode,
|
||
sku_code AS skuCodeText,
|
||
COALESCE(NULLIF(MAX(spu), ''), sku_code) AS spu,
|
||
1 AS skuCount,
|
||
COUNT(DISTINCT channel_name) AS channelCount,
|
||
COUNT(DISTINCT shop_key) AS shopCount,
|
||
MAX(NULLIF(sku_image_url, '')) AS skuImageUrl,
|
||
CASE
|
||
WHEN SUM(CASE WHEN sale_status IN ('销售', '在售') THEN 1 ELSE 0 END) > 0 THEN '销售'
|
||
ELSE COALESCE(NULLIF(MAX(sale_status), ''), '未配置')
|
||
END AS saleStatus,
|
||
COALESCE(NULLIF(MAX(purchaser_name), ''), '未配置') AS purchaserName,
|
||
ROUND(SUM(sales_amount), 2) AS salesAmount,
|
||
ROUND(SUM(after_sale_amount), 2) AS afterSaleAmount,
|
||
ROUND(CASE WHEN SUM(sales_amount) > 0 THEN SUM(after_sale_amount) * 100.0 / SUM(sales_amount) ELSE 0 END, 2) AS afterSaleAmountRate,
|
||
ROUND(SUM(ship_count), 2) AS shipCount,
|
||
ROUND(SUM(refund_count), 2) AS refundCount,
|
||
${CHANNEL_ZONE_READ_SUM_SQL},
|
||
ROUND(CASE WHEN SUM(ship_count) > 0 THEN SUM(refund_count) * 100.0 / SUM(ship_count) ELSE 0 END, 2) AS refundRate
|
||
FROM ${CHANNEL_READ_TABLE}
|
||
WHERE stat_date BETWEEN ? AND ?
|
||
AND sku_code IS NOT NULL
|
||
AND sku_code <> ''
|
||
${extraWhereSql || ''}
|
||
GROUP BY sku_code
|
||
`;
|
||
}
|
||
|
||
function buildReadStoreSpuFilteredSql({
|
||
startDate,
|
||
endDate,
|
||
keyword,
|
||
statuses,
|
||
purchasers,
|
||
minAfterSaleAmountRate,
|
||
maxAfterSaleAmountRate,
|
||
skuCodes,
|
||
}) {
|
||
const skuCodeKeys = uniqueText(parseListParam(skuCodes).map(normalizeSkuCodeKey));
|
||
const skuCodeWhereSql = skuCodeKeys.length
|
||
? `AND UPPER(sku_code) IN (${skuCodeKeys.map(() => '?').join(', ')})`
|
||
: '';
|
||
const innerSql = buildReadStoreSpuBaseSql(skuCodeWhereSql);
|
||
const params = [startDate, endDate, ...skuCodeKeys];
|
||
const whereParts = [];
|
||
|
||
if (keyword && String(keyword).trim()) {
|
||
const keywordValue = `%${String(keyword).trim()}%`;
|
||
whereParts.push(`(
|
||
base.spu LIKE ?
|
||
OR base.skuCodeText LIKE ?
|
||
OR base.purchaserName LIKE ?
|
||
OR base.saleStatus LIKE ?
|
||
)`);
|
||
params.push(keywordValue, keywordValue, keywordValue, keywordValue);
|
||
}
|
||
|
||
addMysqlListFilter(whereParts, params, parseListParam(statuses), 'base.saleStatus');
|
||
addMysqlListFilter(whereParts, params, parseListParam(purchasers), 'base.purchaserName');
|
||
addMysqlNumericRangeFilter(whereParts, params, minAfterSaleAmountRate, maxAfterSaleAmountRate, 'base.afterSaleAmountRate');
|
||
|
||
const whereSql = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '';
|
||
return {
|
||
sql: `SELECT * FROM (${innerSql}) base ${whereSql}`,
|
||
params,
|
||
};
|
||
}
|
||
|
||
function hasReadStoreSpuFilters({ keyword, statuses, purchasers, minAfterSaleAmountRate, maxAfterSaleAmountRate }) {
|
||
return (
|
||
Boolean(String(keyword || '').trim())
|
||
|| parseListParam(statuses).length > 0
|
||
|| parseListParam(purchasers).length > 0
|
||
|| parseNumericFilter(minAfterSaleAmountRate) !== null
|
||
|| parseNumericFilter(maxAfterSaleAmountRate) !== null
|
||
);
|
||
}
|
||
|
||
async function countReadStoreSpuRows(filterOptions, sql, params) {
|
||
await ensurePlanningChannelDailyReadSchema();
|
||
if (!hasReadStoreSpuFilters(filterOptions)) {
|
||
const rows = await channelReadDb.query(
|
||
`
|
||
SELECT COUNT(DISTINCT sku_code) AS totalCount
|
||
FROM ${CHANNEL_READ_TABLE}
|
||
WHERE stat_date BETWEEN ? AND ?
|
||
AND sku_code IS NOT NULL
|
||
AND sku_code <> ''
|
||
`,
|
||
[filterOptions.startDate, filterOptions.endDate]
|
||
);
|
||
return Number(rows?.[0]?.totalCount || 0);
|
||
}
|
||
|
||
const rows = await channelReadDb.query(`SELECT COUNT(*) AS totalCount FROM (${sql}) counted`, params);
|
||
return Number(rows?.[0]?.totalCount || 0);
|
||
}
|
||
|
||
async function getChannelReadSyncInfo(startDate, endDate) {
|
||
await ensurePlanningChannelDailyReadSchema();
|
||
const rows = await channelReadDb.query(
|
||
`SELECT stat_date, source_latest_biz_date, status, synced_at, sync_version
|
||
FROM ${CHANNEL_SYNC_TABLE}
|
||
WHERE stat_date BETWEEN ? AND ?`,
|
||
[startDate, endDate]
|
||
);
|
||
|
||
const map = new Map();
|
||
for (const row of rows) {
|
||
const date = normalizeDateValue(row.stat_date);
|
||
if (date) {
|
||
map.set(date, {
|
||
sourceLatestBizDate: normalizeDateValue(row.source_latest_biz_date),
|
||
status: row.status,
|
||
syncedAt: row.synced_at,
|
||
syncVersion: row.sync_version || null,
|
||
});
|
||
}
|
||
}
|
||
return map;
|
||
}
|
||
|
||
function shouldResyncChannelDate(syncInfo, statDate, latestBizDate) {
|
||
if (!syncInfo || syncInfo.status !== 'success') {
|
||
return true;
|
||
}
|
||
|
||
if (syncInfo.syncVersion !== CHANNEL_READ_SYNC_VERSION) {
|
||
return true;
|
||
}
|
||
|
||
if (syncInfo.sourceLatestBizDate !== latestBizDate) {
|
||
return true;
|
||
}
|
||
|
||
const recentBoundary = dayjs(latestBizDate).subtract(CHANNEL_RECENT_RESYNC_DAYS - 1, 'day').startOf('day');
|
||
const currentDate = dayjs(statDate).startOf('day');
|
||
if (currentDate.isBefore(recentBoundary)) {
|
||
return false;
|
||
}
|
||
|
||
if (!syncInfo.syncedAt) {
|
||
return true;
|
||
}
|
||
|
||
return dayjs(syncInfo.syncedAt).isBefore(dayjs().subtract(CHANNEL_RECENT_RESYNC_MAX_AGE_MINUTES, 'minute'));
|
||
}
|
||
|
||
async function markChannelSyncFailure(startDate, endDate, latestBizDate, error) {
|
||
const dates = listDatesInRange(startDate, endDate);
|
||
const message = String(error?.message || error || '未知错误').slice(0, 1000);
|
||
if (!dates.length) return;
|
||
|
||
const placeholders = dates.map(() => '(?, ?, ?, ?, ?, NOW())').join(', ');
|
||
const params = [];
|
||
dates.forEach((date) => {
|
||
params.push(date, latestBizDate, CHANNEL_READ_SYNC_VERSION, 'failed', message);
|
||
});
|
||
|
||
await ensurePlanningChannelDailyReadSchema();
|
||
await channelReadDb.query(
|
||
`INSERT INTO ${CHANNEL_SYNC_TABLE} (stat_date, source_latest_biz_date, sync_version, status, error_message, synced_at)
|
||
VALUES ${placeholders}
|
||
ON DUPLICATE KEY UPDATE
|
||
source_latest_biz_date = VALUES(source_latest_biz_date),
|
||
sync_version = VALUES(sync_version),
|
||
status = VALUES(status),
|
||
error_message = VALUES(error_message),
|
||
synced_at = VALUES(synced_at)`,
|
||
params
|
||
);
|
||
}
|
||
|
||
async function fetchChannelSummaryRangeFromSource(startDate, endDate) {
|
||
const params = { startDate, endDate };
|
||
const excludedSkuSql = buildExcludedSkuInClause(params);
|
||
|
||
const sqlText = `
|
||
WITH order_zone_dim AS (
|
||
SELECT
|
||
LTRIM(RTRIM(a.undefined2)) AS order_id,
|
||
REPLACE(REPLACE(LTRIM(RTRIM(a.undefined8)), N'_', N''), N'-', N'') AS shop_key,
|
||
LTRIM(RTRIM(a.undefined10)) AS sku_code,
|
||
${ORDER_ZONE_CASE_SQL} AS zone_name
|
||
FROM dbo.jst_goods_profit_kpi_items_afs a
|
||
WHERE a.undefined2 IS NOT NULL
|
||
AND LTRIM(RTRIM(a.undefined2)) <> N''
|
||
AND a.undefined8 IS NOT NULL
|
||
AND LTRIM(RTRIM(a.undefined8)) <> N''
|
||
AND a.undefined10 IS NOT NULL
|
||
AND LTRIM(RTRIM(a.undefined10)) <> N''
|
||
AND a.undefined74 IS NOT NULL
|
||
AND LTRIM(RTRIM(a.undefined74)) <> N''
|
||
GROUP BY
|
||
LTRIM(RTRIM(a.undefined2)),
|
||
REPLACE(REPLACE(LTRIM(RTRIM(a.undefined8)), N'_', N''), N'-', N''),
|
||
LTRIM(RTRIM(a.undefined10)),
|
||
${ORDER_ZONE_CASE_SQL}
|
||
),
|
||
base_metrics AS (
|
||
SELECT
|
||
d.stat_date AS statDate,
|
||
COALESCE(NULLIF(LTRIM(RTRIM(d.platform)), N''), N'未归类') AS channel_name,
|
||
LTRIM(RTRIM(d.shop_name)) AS shop_name,
|
||
REPLACE(REPLACE(LTRIM(RTRIM(d.shop_name)), N'_', N''), N'-', N'') AS shop_key,
|
||
LTRIM(RTRIM(d.skuoid)) AS sku_code,
|
||
SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.sales_amount), 0)) AS sales_amount,
|
||
SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.after_sale_amount), 0)) AS after_sale_amount,
|
||
SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.goods_count), 0)) AS ship_count,
|
||
SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.refund_count), 0)) AS refund_count,
|
||
${CHANNEL_ZONE_SELECT_SQL}
|
||
FROM ${CHANNEL_SOURCE_TABLE} d
|
||
LEFT JOIN order_zone_dim oz
|
||
ON oz.order_id = LTRIM(RTRIM(d.sourceno))
|
||
AND oz.shop_key = REPLACE(REPLACE(LTRIM(RTRIM(d.shop_name)), N'_', N''), N'-', N'')
|
||
AND oz.sku_code = LTRIM(RTRIM(d.skuoid))
|
||
WHERE d.stat_date BETWEEN @startDate AND @endDate
|
||
AND d.shop_name IS NOT NULL
|
||
AND LTRIM(RTRIM(d.shop_name)) <> N''
|
||
AND d.skuoid IS NOT NULL
|
||
AND LTRIM(RTRIM(d.skuoid)) <> N''
|
||
AND UPPER(LTRIM(RTRIM(d.skuoid))) NOT IN (${excludedSkuSql})
|
||
${CHANNEL_NON_VIRTUAL_SOURCE_FILTER_SQL}
|
||
GROUP BY
|
||
d.stat_date,
|
||
COALESCE(NULLIF(LTRIM(RTRIM(d.platform)), N''), N'未归类'),
|
||
LTRIM(RTRIM(d.shop_name)),
|
||
REPLACE(REPLACE(LTRIM(RTRIM(d.shop_name)), N'_', N''), N'-', N''),
|
||
LTRIM(RTRIM(d.skuoid))
|
||
),
|
||
sku_dim AS (
|
||
SELECT
|
||
base.sku_code,
|
||
COALESCE(NULLIF(lib.product_code, N''), NULLIF(jsku.product_code, N''), base.sku_code) AS product_code,
|
||
COALESCE(
|
||
NULLIF(lib.sale_status, N''),
|
||
CASE WHEN ISNULL(jsku.enabled, 0) = 1 AND ISNULL(jsku.stock_disabled, 0) = 0 THEN N'销售' ELSE N'未配置' END
|
||
) AS sale_status,
|
||
COALESCE(NULLIF(jsku.pic_big, N''), NULLIF(jsku.pic, N'')) AS sku_image_url
|
||
FROM (SELECT DISTINCT sku_code FROM base_metrics) base
|
||
OUTER APPLY (
|
||
SELECT TOP 1
|
||
NULLIF(LTRIM(RTRIM(l.ItemNum)), N'') AS product_code,
|
||
COALESCE(NULLIF(LTRIM(RTRIM(l.ProStatus)), N''), N'未配置') AS sale_status
|
||
FROM dbo.fys_SkuDataLibraryNew l
|
||
WHERE l.SkuCode = base.sku_code
|
||
ORDER BY CASE WHEN l.ProStatus = N'销售' THEN 0 ELSE 1 END, l.CreationTime DESC
|
||
) lib
|
||
OUTER APPLY (
|
||
SELECT TOP 1
|
||
${PRODUCT_CODE_EXPR_SQL} AS product_code,
|
||
ISNULL(j.enabled, 0) AS enabled,
|
||
ISNULL(j.stock_disabled, 0) AS stock_disabled,
|
||
NULLIF(LTRIM(RTRIM(j.pic)), N'') AS pic,
|
||
NULLIF(LTRIM(RTRIM(j.pic_big)), N'') AS pic_big
|
||
FROM dbo.jst_sku j
|
||
WHERE j.sku_id = base.sku_code
|
||
) jsku
|
||
)
|
||
SELECT
|
||
CONVERT(varchar(10), bm.statDate, 23) AS statDate,
|
||
bm.channel_name AS channelName,
|
||
bm.shop_name AS shopName,
|
||
bm.shop_key AS shopKey,
|
||
bm.sku_code AS skuCode,
|
||
COALESCE(NULLIF(sk.product_code, N''), bm.sku_code) AS spu,
|
||
sk.sku_image_url AS skuImageUrl,
|
||
COALESCE(NULLIF(sk.sale_status, N''), N'未配置') AS saleStatus,
|
||
${PURCHASER_SQL} AS purchaserName,
|
||
CAST(ROUND(bm.sales_amount, 2) AS DECIMAL(18, 2)) AS salesAmount,
|
||
CAST(ROUND(bm.after_sale_amount, 2) AS DECIMAL(18, 2)) AS afterSaleAmount,
|
||
CAST(ROUND(bm.ship_count, 2) AS DECIMAL(18, 2)) AS shipCount,
|
||
CAST(ROUND(bm.refund_count, 2) AS DECIMAL(18, 2)) AS refundCount,
|
||
CAST(ROUND(bm.sales_amount_zone1, 2) AS DECIMAL(18, 2)) AS salesAmountZone1,
|
||
CAST(ROUND(bm.sales_amount_zone2, 2) AS DECIMAL(18, 2)) AS salesAmountZone2,
|
||
CAST(ROUND(bm.sales_amount_zone3, 2) AS DECIMAL(18, 2)) AS salesAmountZone3,
|
||
CAST(ROUND(bm.sales_amount_zone4, 2) AS DECIMAL(18, 2)) AS salesAmountZone4,
|
||
CAST(ROUND(bm.after_sale_amount_zone1, 2) AS DECIMAL(18, 2)) AS afterSaleAmountZone1,
|
||
CAST(ROUND(bm.after_sale_amount_zone2, 2) AS DECIMAL(18, 2)) AS afterSaleAmountZone2,
|
||
CAST(ROUND(bm.after_sale_amount_zone3, 2) AS DECIMAL(18, 2)) AS afterSaleAmountZone3,
|
||
CAST(ROUND(bm.after_sale_amount_zone4, 2) AS DECIMAL(18, 2)) AS afterSaleAmountZone4
|
||
FROM base_metrics bm
|
||
LEFT JOIN sku_dim sk ON sk.sku_code = bm.sku_code
|
||
OUTER APPLY (SELECT TOP 1 purchaser FROM dwd.dim_ope_spu_data_df d WHERE d.spu = COALESCE(NULLIF(sk.product_code, N''), bm.sku_code)) md
|
||
OUTER APPLY (SELECT TOP 1 purchaser FROM dbo.V_caterAndOper v WHERE v.spu = COALESCE(NULLIF(sk.product_code, N''), bm.sku_code)) mv
|
||
OUTER APPLY (SELECT TOP 1 purchaser FROM dbo.op_spuPrincipal_log l WHERE l.spu = COALESCE(NULLIF(sk.product_code, N''), bm.sku_code) ORDER BY l.created DESC) ml
|
||
OUTER APPLY (SELECT TOP 1 purchaser FROM dbo.fys_SkuDataLibrary_SpuConfig c WHERE COALESCE(NULLIF(sk.product_code, N''), bm.sku_code) LIKE c.spu + N'%' ORDER BY LEN(c.spu) DESC) mc
|
||
`;
|
||
|
||
return cloudDb.query(sqlText, params);
|
||
}
|
||
|
||
async function persistChannelSummaryRange(startDate, endDate, latestBizDate, rows) {
|
||
await ensurePlanningChannelDailyReadSchema();
|
||
const dateList = listDatesInRange(startDate, endDate);
|
||
const rowCountByDate = new Map(dateList.map((date) => [date, 0]));
|
||
const normalizedRows = (rows || []).map((row) => {
|
||
const statDate = normalizeDateValue(row.statDate);
|
||
if (statDate) {
|
||
rowCountByDate.set(statDate, (rowCountByDate.get(statDate) || 0) + 1);
|
||
}
|
||
return {
|
||
statDate,
|
||
channelName: row.channelName || '未归类',
|
||
shopName: row.shopName || '',
|
||
shopKey: row.shopKey || '',
|
||
skuCode: row.skuCode || '',
|
||
spu: row.spu || row.skuCode || '',
|
||
spuKey: row.spu || row.skuCode || '',
|
||
skuImageUrl: row.skuImageUrl || null,
|
||
saleStatus: row.saleStatus || '未配置',
|
||
purchaserName: row.purchaserName || '未配置',
|
||
salesAmount: toNumber(row.salesAmount),
|
||
afterSaleAmount: toNumber(row.afterSaleAmount),
|
||
shipCount: toNumber(row.shipCount),
|
||
refundCount: toNumber(row.refundCount),
|
||
salesAmountZone1: toNumber(row.salesAmountZone1),
|
||
salesAmountZone2: toNumber(row.salesAmountZone2),
|
||
salesAmountZone3: toNumber(row.salesAmountZone3),
|
||
salesAmountZone4: toNumber(row.salesAmountZone4),
|
||
afterSaleAmountZone1: toNumber(row.afterSaleAmountZone1),
|
||
afterSaleAmountZone2: toNumber(row.afterSaleAmountZone2),
|
||
afterSaleAmountZone3: toNumber(row.afterSaleAmountZone3),
|
||
afterSaleAmountZone4: toNumber(row.afterSaleAmountZone4),
|
||
};
|
||
}).filter((row) => row.statDate && row.shopName && row.shopKey && row.skuCode);
|
||
|
||
const pool = await planningDatabase.getPool();
|
||
const connection = await pool.getConnection();
|
||
try {
|
||
await connection.beginTransaction();
|
||
await connection.query(`DELETE FROM ${CHANNEL_READ_TABLE} WHERE stat_date BETWEEN ? AND ?`, [startDate, endDate]);
|
||
|
||
if (normalizedRows.length > 0) {
|
||
const columns = [
|
||
'stat_date',
|
||
'channel_name',
|
||
'shop_name',
|
||
'shop_key',
|
||
'sku_code',
|
||
'spu',
|
||
'spu_key',
|
||
'sku_image_url',
|
||
'sale_status',
|
||
'purchaser_name',
|
||
'sales_amount',
|
||
'after_sale_amount',
|
||
'ship_count',
|
||
'refund_count',
|
||
...CHANNEL_ZONE_INSERT_COLUMNS,
|
||
];
|
||
|
||
for (let index = 0; index < normalizedRows.length; index += 500) {
|
||
const chunk = normalizedRows.slice(index, index + 500);
|
||
const placeholders = chunk.map(() => `(${columns.map(() => '?').join(', ')})`).join(', ');
|
||
const params = [];
|
||
chunk.forEach((row) => {
|
||
params.push(
|
||
row.statDate,
|
||
row.channelName,
|
||
row.shopName,
|
||
row.shopKey,
|
||
row.skuCode,
|
||
row.spu,
|
||
row.spuKey,
|
||
row.skuImageUrl,
|
||
row.saleStatus,
|
||
row.purchaserName,
|
||
row.salesAmount,
|
||
row.afterSaleAmount,
|
||
row.shipCount,
|
||
row.refundCount,
|
||
row.salesAmountZone1,
|
||
row.afterSaleAmountZone1,
|
||
row.salesAmountZone2,
|
||
row.afterSaleAmountZone2,
|
||
row.salesAmountZone3,
|
||
row.afterSaleAmountZone3,
|
||
row.salesAmountZone4,
|
||
row.afterSaleAmountZone4
|
||
);
|
||
});
|
||
|
||
await connection.query(
|
||
`INSERT INTO ${CHANNEL_READ_TABLE} (${columns.join(', ')}) VALUES ${placeholders}`,
|
||
params
|
||
);
|
||
}
|
||
}
|
||
|
||
if (dateList.length > 0) {
|
||
const syncPlaceholders = dateList.map(() => '(?, ?, ?, ?, ?, ?, NOW())').join(', ');
|
||
const syncParams = [];
|
||
dateList.forEach((date) => {
|
||
syncParams.push(date, latestBizDate, CHANNEL_READ_SYNC_VERSION, rowCountByDate.get(date) || 0, 'success', null);
|
||
});
|
||
|
||
await connection.query(
|
||
`INSERT INTO ${CHANNEL_SYNC_TABLE} (stat_date, source_latest_biz_date, sync_version, row_count, status, error_message, synced_at)
|
||
VALUES ${syncPlaceholders}
|
||
ON DUPLICATE KEY UPDATE
|
||
source_latest_biz_date = VALUES(source_latest_biz_date),
|
||
sync_version = VALUES(sync_version),
|
||
row_count = VALUES(row_count),
|
||
status = VALUES(status),
|
||
error_message = VALUES(error_message),
|
||
synced_at = VALUES(synced_at)`,
|
||
syncParams
|
||
);
|
||
}
|
||
|
||
await connection.commit();
|
||
} catch (error) {
|
||
await connection.rollback();
|
||
throw error;
|
||
} finally {
|
||
connection.release();
|
||
}
|
||
|
||
await clearChannelReadCaches();
|
||
}
|
||
|
||
async function syncChannelSummaryRange(startDate, endDate, latestBizDate) {
|
||
try {
|
||
const rows = await fetchChannelSummaryRangeFromSource(startDate, endDate);
|
||
await persistChannelSummaryRange(startDate, endDate, latestBizDate, rows);
|
||
} catch (error) {
|
||
await markChannelSyncFailure(startDate, endDate, latestBizDate, error).catch(() => {});
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async function ensureChannelReadCoverage(startDate, endDate, latestBizDate) {
|
||
const dates = listDatesInRange(startDate, endDate);
|
||
if (!dates.length) return [];
|
||
|
||
const syncInfoMap = await getChannelReadSyncInfo(startDate, endDate);
|
||
const datesToSync = dates.filter((date) => shouldResyncChannelDate(syncInfoMap.get(date), date, latestBizDate));
|
||
if (!datesToSync.length) return [];
|
||
|
||
const ranges = splitDatesIntoRanges(datesToSync);
|
||
for (const range of ranges) {
|
||
await syncChannelSummaryRange(range.startDate, range.endDate, latestBizDate);
|
||
}
|
||
return ranges;
|
||
}
|
||
|
||
async function hasChannelReadRows(startDate, endDate) {
|
||
await ensurePlanningChannelDailyReadSchema();
|
||
const rows = await channelReadDb.query(
|
||
`SELECT 1 AS hasRow
|
||
FROM ${CHANNEL_READ_TABLE}
|
||
WHERE stat_date BETWEEN ? AND ?
|
||
LIMIT 1`,
|
||
[startDate, endDate]
|
||
);
|
||
return rows.length > 0;
|
||
}
|
||
|
||
async function resolveChannelReadCoverage(startDate, endDate, latestBizDate, { allowStale = false, label = '渠道读表' } = {}) {
|
||
if (!allowStale) {
|
||
const refreshedRanges = await ensureChannelReadCoverage(startDate, endDate, latestBizDate);
|
||
return {
|
||
refreshedRanges,
|
||
usedStaleData: false,
|
||
coverageMode: 'synchronous',
|
||
};
|
||
}
|
||
|
||
const dates = listDatesInRange(startDate, endDate);
|
||
if (!dates.length) {
|
||
return {
|
||
refreshedRanges: [],
|
||
usedStaleData: false,
|
||
coverageMode: 'synchronous',
|
||
};
|
||
}
|
||
|
||
const syncInfoMap = await getChannelReadSyncInfo(startDate, endDate);
|
||
const hasSuccessfulData = await hasChannelReadRows(startDate, endDate);
|
||
const hasPendingRefresh = dates.some((date) => shouldResyncChannelDate(syncInfoMap.get(date), date, latestBizDate));
|
||
|
||
if (!hasPendingRefresh) {
|
||
return {
|
||
refreshedRanges: [],
|
||
usedStaleData: false,
|
||
coverageMode: 'synchronous',
|
||
};
|
||
}
|
||
|
||
if (!hasSuccessfulData) {
|
||
const refreshedRanges = await ensureChannelReadCoverage(startDate, endDate, latestBizDate);
|
||
return {
|
||
refreshedRanges,
|
||
usedStaleData: false,
|
||
coverageMode: 'synchronous',
|
||
};
|
||
}
|
||
|
||
ensureChannelReadCoverage(startDate, endDate, latestBizDate).catch((error) => {
|
||
console.warn(`[Channel] ${label}后台补齐读表失败:`, error.message || error);
|
||
});
|
||
|
||
return {
|
||
refreshedRanges: [],
|
||
usedStaleData: true,
|
||
coverageMode: 'background-refresh',
|
||
};
|
||
}
|
||
|
||
async function resolveLatestSyncedChannelDate(startDate, endDate) {
|
||
const dates = listDatesInRange(startDate, endDate);
|
||
if (!dates.length) {
|
||
return null;
|
||
}
|
||
|
||
const syncInfoMap = await getChannelReadSyncInfo(startDate, endDate);
|
||
for (let index = dates.length - 1; index >= 0; index -= 1) {
|
||
const statDate = dates[index];
|
||
const syncInfo = syncInfoMap.get(statDate);
|
||
if (syncInfo?.status === 'success' && syncInfo.syncVersion === CHANNEL_READ_SYNC_VERSION) {
|
||
return statDate;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
async function getChannelIdReadSyncInfo(startDate, endDate) {
|
||
await ensurePlanningChannelIdDailyReadSchema();
|
||
const rows = await channelReadDb.query(
|
||
`SELECT stat_date, source_latest_biz_date, status, synced_at, sync_version
|
||
FROM ${CHANNEL_ID_SYNC_TABLE}
|
||
WHERE stat_date BETWEEN ? AND ?`,
|
||
[startDate, endDate]
|
||
);
|
||
|
||
const map = new Map();
|
||
for (const row of rows) {
|
||
const date = normalizeDateValue(row.stat_date);
|
||
if (date) {
|
||
map.set(date, {
|
||
sourceLatestBizDate: normalizeDateValue(row.source_latest_biz_date),
|
||
status: row.status,
|
||
syncedAt: row.synced_at,
|
||
syncVersion: row.sync_version || null,
|
||
});
|
||
}
|
||
}
|
||
return map;
|
||
}
|
||
|
||
function shouldResyncChannelIdDate(syncInfo, statDate, latestBizDate) {
|
||
if (!syncInfo || syncInfo.status !== 'success') {
|
||
return true;
|
||
}
|
||
|
||
if (syncInfo.syncVersion !== CHANNEL_ID_READ_SYNC_VERSION) {
|
||
return true;
|
||
}
|
||
|
||
if (syncInfo.sourceLatestBizDate !== latestBizDate) {
|
||
return true;
|
||
}
|
||
|
||
const recentBoundary = dayjs(latestBizDate).subtract(CHANNEL_RECENT_RESYNC_DAYS - 1, 'day').startOf('day');
|
||
const currentDate = dayjs(statDate).startOf('day');
|
||
if (currentDate.isBefore(recentBoundary)) {
|
||
return false;
|
||
}
|
||
|
||
if (!syncInfo.syncedAt) {
|
||
return true;
|
||
}
|
||
|
||
return dayjs(syncInfo.syncedAt).isBefore(dayjs().subtract(CHANNEL_RECENT_RESYNC_MAX_AGE_MINUTES, 'minute'));
|
||
}
|
||
|
||
async function resolveLatestSyncedChannelIdDate(startDate, endDate) {
|
||
const dates = listDatesInRange(startDate, endDate);
|
||
if (!dates.length) {
|
||
return null;
|
||
}
|
||
|
||
const syncInfoMap = await getChannelIdReadSyncInfo(startDate, endDate);
|
||
for (let index = dates.length - 1; index >= 0; index -= 1) {
|
||
const statDate = dates[index];
|
||
const syncInfo = syncInfoMap.get(statDate);
|
||
if (syncInfo?.status === 'success' && syncInfo.syncVersion === CHANNEL_ID_READ_SYNC_VERSION) {
|
||
return statDate;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function buildChannelIdWindowConfigFromEndDate(endDate, { page = 1, pageSize = CHANNEL_PREWARM_PAGE_SIZE, limit = 50 } = {}) {
|
||
const normalizedEndDate = dayjs(endDate).format('YYYY-MM-DD');
|
||
return {
|
||
list: buildChannelIdDefaultSnapshotConfig(normalizedEndDate, page, pageSize),
|
||
dashboard: buildChannelIdDashboardDefaultSnapshotConfig(normalizedEndDate, limit),
|
||
};
|
||
}
|
||
|
||
function buildChannelStockMonitorDashboardWindowConfigFromEndDate(endDate, { limit = 50 } = {}) {
|
||
const normalizedEndDate = dayjs(endDate).format('YYYY-MM-DD');
|
||
return buildChannelStockMonitorDashboardDefaultSnapshotConfig(normalizedEndDate, limit);
|
||
}
|
||
|
||
async function markChannelIdSyncFailure(startDate, endDate, latestBizDate, error) {
|
||
const dates = listDatesInRange(startDate, endDate);
|
||
const message = String(error?.message || error || '未知错误').slice(0, 1000);
|
||
if (!dates.length) return;
|
||
|
||
const placeholders = dates.map(() => '(?, ?, ?, ?, ?, NOW())').join(', ');
|
||
const params = [];
|
||
dates.forEach((date) => {
|
||
params.push(date, latestBizDate, CHANNEL_ID_READ_SYNC_VERSION, 'failed', message);
|
||
});
|
||
|
||
await ensurePlanningChannelIdDailyReadSchema();
|
||
await channelReadDb.query(
|
||
`INSERT INTO ${CHANNEL_ID_SYNC_TABLE} (stat_date, source_latest_biz_date, sync_version, status, error_message, synced_at)
|
||
VALUES ${placeholders}
|
||
ON DUPLICATE KEY UPDATE
|
||
source_latest_biz_date = VALUES(source_latest_biz_date),
|
||
sync_version = VALUES(sync_version),
|
||
status = VALUES(status),
|
||
error_message = VALUES(error_message),
|
||
synced_at = VALUES(synced_at)`,
|
||
params
|
||
);
|
||
}
|
||
|
||
async function fetchChannelIdSummaryRangeFromSource(startDate, endDate) {
|
||
const params = { startDate, endDate };
|
||
const excludedSkuSql = buildExcludedSkuInClause(params);
|
||
|
||
const sqlText = `
|
||
WITH source_metrics AS (
|
||
SELECT
|
||
d.stat_date AS statDate,
|
||
COALESCE(NULLIF(LTRIM(RTRIM(d.platform)), N''), N'未归类') AS channelName,
|
||
MAX(LTRIM(RTRIM(d.shop_name))) AS shopName,
|
||
REPLACE(REPLACE(LTRIM(RTRIM(d.shop_name)), N'_', N''), N'-', N'') AS shopKey,
|
||
LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), d.item_id))) AS itemId,
|
||
SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.real_performance), 0)) AS performanceAmount,
|
||
SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.real_conclude_fee), 0)) AS feeAmount,
|
||
SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.sales_amount), 0)) AS transactionAmount,
|
||
SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.after_sale_amount), 0)) AS afterSaleAmount,
|
||
SUM(
|
||
COALESCE(
|
||
TRY_CONVERT(DECIMAL(18, 2), d.push_fee_total),
|
||
ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.push_fee), 0) + ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.shared_push_fee_sum), 0)
|
||
)
|
||
) AS pushFeeAmount,
|
||
SUM(
|
||
COALESCE(
|
||
TRY_CONVERT(DECIMAL(18, 2), d.marketing_fee_total),
|
||
ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.marketing_fee), 0) + ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.shared_market_fee_sum), 0)
|
||
)
|
||
) AS marketingFeeAmount,
|
||
SUM(
|
||
ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.clear_sales_cost), 0)
|
||
+ ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.send_freight), 0)
|
||
) AS grossCostAmount,
|
||
SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.goods_count), 0)) AS goodsCount,
|
||
SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.refund_count), 0)) AS refundCount,
|
||
CAST(0 AS INT) AS orderCount,
|
||
CAST(0 AS INT) AS installOrderCount
|
||
FROM ${CHANNEL_SOURCE_TABLE} d
|
||
WHERE d.stat_date BETWEEN @startDate AND @endDate
|
||
AND d.shop_name IS NOT NULL
|
||
AND LTRIM(RTRIM(d.shop_name)) <> N''
|
||
AND d.item_id IS NOT NULL
|
||
AND LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), d.item_id))) <> N''
|
||
AND (
|
||
d.skuoid IS NULL
|
||
OR LTRIM(RTRIM(d.skuoid)) = N''
|
||
OR UPPER(LTRIM(RTRIM(d.skuoid))) NOT IN (${excludedSkuSql})
|
||
)
|
||
${CHANNEL_NON_VIRTUAL_SOURCE_FILTER_SQL}
|
||
GROUP BY
|
||
d.stat_date,
|
||
COALESCE(NULLIF(LTRIM(RTRIM(d.platform)), N''), N'未归类'),
|
||
REPLACE(REPLACE(LTRIM(RTRIM(d.shop_name)), N'_', N''), N'-', N''),
|
||
LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), d.item_id)))
|
||
),
|
||
sku_metrics AS (
|
||
SELECT
|
||
d.stat_date AS statDate,
|
||
COALESCE(NULLIF(LTRIM(RTRIM(d.platform)), N''), N'未归类') AS channelName,
|
||
REPLACE(REPLACE(LTRIM(RTRIM(d.shop_name)), N'_', N''), N'-', N'') AS shopKey,
|
||
LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), d.item_id))) AS itemId,
|
||
LTRIM(RTRIM(d.skuoid)) AS skuCode,
|
||
SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.real_performance), 0)) AS skuPerformanceAmount
|
||
FROM ${CHANNEL_SOURCE_TABLE} d
|
||
WHERE d.stat_date BETWEEN @startDate AND @endDate
|
||
AND d.shop_name IS NOT NULL
|
||
AND LTRIM(RTRIM(d.shop_name)) <> N''
|
||
AND d.item_id IS NOT NULL
|
||
AND LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), d.item_id))) <> N''
|
||
AND d.skuoid IS NOT NULL
|
||
AND LTRIM(RTRIM(d.skuoid)) <> N''
|
||
AND UPPER(LTRIM(RTRIM(d.skuoid))) NOT IN (${excludedSkuSql})
|
||
${CHANNEL_NON_VIRTUAL_SOURCE_FILTER_SQL}
|
||
GROUP BY
|
||
d.stat_date,
|
||
COALESCE(NULLIF(LTRIM(RTRIM(d.platform)), N''), N'未归类'),
|
||
REPLACE(REPLACE(LTRIM(RTRIM(d.shop_name)), N'_', N''), N'-', N''),
|
||
LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), d.item_id))),
|
||
LTRIM(RTRIM(d.skuoid))
|
||
),
|
||
sku_counts AS (
|
||
SELECT
|
||
statDate,
|
||
channelName,
|
||
shopKey,
|
||
itemId,
|
||
COUNT(1) AS skuCount
|
||
FROM sku_metrics
|
||
GROUP BY statDate, channelName, shopKey, itemId
|
||
),
|
||
primary_sku AS (
|
||
SELECT statDate, channelName, shopKey, itemId, skuCode AS primarySkuCode
|
||
FROM (
|
||
SELECT
|
||
sm.statDate,
|
||
sm.channelName,
|
||
sm.shopKey,
|
||
sm.itemId,
|
||
sm.skuCode,
|
||
ROW_NUMBER() OVER (
|
||
PARTITION BY sm.statDate, sm.channelName, sm.shopKey, sm.itemId
|
||
ORDER BY sm.skuPerformanceAmount DESC, sm.skuCode ASC
|
||
) AS rn
|
||
FROM sku_metrics sm
|
||
) ranked
|
||
WHERE rn = 1
|
||
)
|
||
SELECT
|
||
CONVERT(varchar(10), sm.statDate, 23) AS statDate,
|
||
sm.channelName,
|
||
sm.shopName,
|
||
sm.shopKey,
|
||
sm.itemId,
|
||
ISNULL(sc.skuCount, 0) AS skuCount,
|
||
ps.primarySkuCode,
|
||
COALESCE(ps.primarySkuCode, sm.itemId) AS primarySpu,
|
||
CAST(NULL AS NVARCHAR(500)) AS skuImageUrl,
|
||
N'未配置' AS saleStatus,
|
||
N'未配置' AS purchaserName,
|
||
CAST(ROUND(sm.performanceAmount, 2) AS DECIMAL(18, 2)) AS performanceAmount,
|
||
CAST(ROUND(sm.feeAmount, 2) AS DECIMAL(18, 2)) AS feeAmount,
|
||
CAST(ROUND(sm.transactionAmount, 2) AS DECIMAL(18, 2)) AS transactionAmount,
|
||
CAST(ROUND(sm.afterSaleAmount, 2) AS DECIMAL(18, 2)) AS afterSaleAmount,
|
||
CAST(ROUND(sm.pushFeeAmount, 2) AS DECIMAL(18, 2)) AS pushFeeAmount,
|
||
CAST(ROUND(sm.marketingFeeAmount, 2) AS DECIMAL(18, 2)) AS marketingFeeAmount,
|
||
CAST(ROUND(sm.grossCostAmount, 2) AS DECIMAL(18, 2)) AS grossCostAmount,
|
||
CAST(ROUND(sm.goodsCount, 2) AS DECIMAL(18, 2)) AS goodsCount,
|
||
CAST(ROUND(sm.refundCount, 2) AS DECIMAL(18, 2)) AS refundCount,
|
||
CAST(sm.orderCount AS INT) AS orderCount,
|
||
CAST(sm.installOrderCount AS INT) AS installOrderCount
|
||
FROM source_metrics sm
|
||
LEFT JOIN sku_counts sc
|
||
ON sc.statDate = sm.statDate
|
||
AND sc.channelName = sm.channelName
|
||
AND sc.shopKey = sm.shopKey
|
||
AND sc.itemId = sm.itemId
|
||
LEFT JOIN primary_sku ps
|
||
ON ps.statDate = sm.statDate
|
||
AND ps.channelName = sm.channelName
|
||
AND ps.shopKey = sm.shopKey
|
||
AND ps.itemId = sm.itemId
|
||
`;
|
||
|
||
return cloudDb.query(sqlText, params);
|
||
}
|
||
|
||
async function persistChannelIdSummaryRange(startDate, endDate, latestBizDate, rows) {
|
||
await ensurePlanningChannelIdDailyReadSchema();
|
||
const dateList = listDatesInRange(startDate, endDate);
|
||
const rowCountByDate = new Map(dateList.map((date) => [date, 0]));
|
||
const normalizedRows = (rows || []).map((row) => {
|
||
const statDate = normalizeDateValue(row.statDate);
|
||
if (statDate) {
|
||
rowCountByDate.set(statDate, (rowCountByDate.get(statDate) || 0) + 1);
|
||
}
|
||
return {
|
||
statDate,
|
||
channelName: row.channelName || '未归类',
|
||
shopName: row.shopName || '',
|
||
shopKey: row.shopKey || '',
|
||
itemId: normalizeText(row.itemId),
|
||
primarySkuCode: normalizeText(row.primarySkuCode) || null,
|
||
primarySpu: normalizeText(row.primarySpu) || normalizeText(row.primarySkuCode) || normalizeText(row.itemId) || null,
|
||
skuImageUrl: normalizeText(row.skuImageUrl) || null,
|
||
saleStatus: normalizeText(row.saleStatus) || '未配置',
|
||
purchaserName: normalizeText(row.purchaserName) || '未配置',
|
||
skuCount: toNumber(row.skuCount, 0),
|
||
performanceAmount: toNumber(row.performanceAmount),
|
||
feeAmount: toNumber(row.feeAmount),
|
||
transactionAmount: toNumber(row.transactionAmount),
|
||
afterSaleAmount: toNumber(row.afterSaleAmount),
|
||
pushFeeAmount: toNumber(row.pushFeeAmount),
|
||
marketingFeeAmount: toNumber(row.marketingFeeAmount),
|
||
grossCostAmount: toNumber(row.grossCostAmount),
|
||
goodsCount: toNumber(row.goodsCount),
|
||
refundCount: toNumber(row.refundCount),
|
||
orderCount: toNumber(row.orderCount, 0),
|
||
installOrderCount: toNumber(row.installOrderCount, 0),
|
||
};
|
||
}).filter((row) => row.statDate && row.shopKey && row.itemId);
|
||
|
||
const pool = await planningDatabase.getPool();
|
||
const connection = await pool.getConnection();
|
||
try {
|
||
await connection.beginTransaction();
|
||
await connection.query(`DELETE FROM ${CHANNEL_ID_READ_TABLE} WHERE stat_date BETWEEN ? AND ?`, [startDate, endDate]);
|
||
|
||
if (normalizedRows.length > 0) {
|
||
const columns = [
|
||
'stat_date',
|
||
'channel_name',
|
||
'shop_name',
|
||
'shop_key',
|
||
'item_id',
|
||
'primary_sku_code',
|
||
'primary_spu',
|
||
'sku_image_url',
|
||
'sale_status',
|
||
'purchaser_name',
|
||
'sku_count',
|
||
'performance_amount',
|
||
'fee_amount',
|
||
'transaction_amount',
|
||
'after_sale_amount',
|
||
'push_fee_amount',
|
||
'marketing_fee_amount',
|
||
'gross_cost_amount',
|
||
'goods_count',
|
||
'refund_count',
|
||
'order_count',
|
||
'install_order_count',
|
||
];
|
||
|
||
for (let index = 0; index < normalizedRows.length; index += 500) {
|
||
const chunk = normalizedRows.slice(index, index + 500);
|
||
const placeholders = chunk.map(() => `(${columns.map(() => '?').join(', ')})`).join(', ');
|
||
const params = [];
|
||
chunk.forEach((row) => {
|
||
params.push(
|
||
row.statDate,
|
||
row.channelName,
|
||
row.shopName,
|
||
row.shopKey,
|
||
row.itemId,
|
||
row.primarySkuCode,
|
||
row.primarySpu,
|
||
row.skuImageUrl,
|
||
row.saleStatus,
|
||
row.purchaserName,
|
||
row.skuCount,
|
||
row.performanceAmount,
|
||
row.feeAmount,
|
||
row.transactionAmount,
|
||
row.afterSaleAmount,
|
||
row.pushFeeAmount,
|
||
row.marketingFeeAmount,
|
||
row.grossCostAmount,
|
||
row.goodsCount,
|
||
row.refundCount,
|
||
row.orderCount,
|
||
row.installOrderCount
|
||
);
|
||
});
|
||
|
||
await connection.query(
|
||
`INSERT INTO ${CHANNEL_ID_READ_TABLE} (${columns.join(', ')}) VALUES ${placeholders}`,
|
||
params
|
||
);
|
||
}
|
||
}
|
||
|
||
if (dateList.length > 0) {
|
||
const syncPlaceholders = dateList.map(() => '(?, ?, ?, ?, ?, ?, NOW())').join(', ');
|
||
const syncParams = [];
|
||
dateList.forEach((date) => {
|
||
syncParams.push(date, latestBizDate, CHANNEL_ID_READ_SYNC_VERSION, rowCountByDate.get(date) || 0, 'success', null);
|
||
});
|
||
|
||
await connection.query(
|
||
`INSERT INTO ${CHANNEL_ID_SYNC_TABLE} (stat_date, source_latest_biz_date, sync_version, row_count, status, error_message, synced_at)
|
||
VALUES ${syncPlaceholders}
|
||
ON DUPLICATE KEY UPDATE
|
||
source_latest_biz_date = VALUES(source_latest_biz_date),
|
||
sync_version = VALUES(sync_version),
|
||
row_count = VALUES(row_count),
|
||
status = VALUES(status),
|
||
error_message = VALUES(error_message),
|
||
synced_at = VALUES(synced_at)`,
|
||
syncParams
|
||
);
|
||
}
|
||
|
||
await connection.commit();
|
||
} catch (error) {
|
||
await connection.rollback();
|
||
throw error;
|
||
} finally {
|
||
connection.release();
|
||
}
|
||
|
||
await clearChannelIdAnalysisCaches();
|
||
}
|
||
|
||
async function syncChannelIdSummaryRange(startDate, endDate, latestBizDate) {
|
||
try {
|
||
const rows = await fetchChannelIdSummaryRangeFromSource(startDate, endDate);
|
||
await persistChannelIdSummaryRange(startDate, endDate, latestBizDate, rows);
|
||
} catch (error) {
|
||
await markChannelIdSyncFailure(startDate, endDate, latestBizDate, error).catch(() => {});
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async function ensureChannelIdReadCoverage(startDate, endDate, latestBizDate) {
|
||
const dates = listDatesInRange(startDate, endDate);
|
||
if (!dates.length) return [];
|
||
|
||
const syncInfoMap = await getChannelIdReadSyncInfo(startDate, endDate);
|
||
const datesToSync = dates.filter((date) => shouldResyncChannelIdDate(syncInfoMap.get(date), date, latestBizDate));
|
||
if (!datesToSync.length) return [];
|
||
|
||
const ranges = splitDatesIntoRanges(datesToSync, CHANNEL_ID_SYNC_CHUNK_DAYS);
|
||
for (const range of ranges) {
|
||
await syncChannelIdSummaryRange(range.startDate, range.endDate, latestBizDate);
|
||
}
|
||
return ranges;
|
||
}
|
||
|
||
async function hasChannelIdReadRows(startDate, endDate) {
|
||
await ensurePlanningChannelIdDailyReadSchema();
|
||
const rows = await channelReadDb.query(
|
||
`SELECT 1 AS hasRows
|
||
FROM ${CHANNEL_ID_READ_TABLE}
|
||
WHERE stat_date BETWEEN ? AND ?
|
||
LIMIT 1`,
|
||
[startDate, endDate],
|
||
CHANNEL_ID_ANALYSIS_READ_QUERY_OPTIONS
|
||
);
|
||
return rows.length > 0;
|
||
}
|
||
|
||
async function resolveChannelIdReadCoverage(
|
||
startDate,
|
||
endDate,
|
||
latestBizDate,
|
||
{ allowStale = false, label = 'ID渠道读表' } = {}
|
||
) {
|
||
if (!allowStale) {
|
||
const refreshedRanges = await ensureChannelIdReadCoverage(startDate, endDate, latestBizDate);
|
||
return {
|
||
refreshedRanges,
|
||
usedStaleData: false,
|
||
coverageMode: 'synchronous',
|
||
};
|
||
}
|
||
|
||
const dates = listDatesInRange(startDate, endDate);
|
||
if (!dates.length) {
|
||
return {
|
||
refreshedRanges: [],
|
||
usedStaleData: false,
|
||
coverageMode: 'synchronous',
|
||
};
|
||
}
|
||
|
||
const syncInfoMap = await getChannelIdReadSyncInfo(startDate, endDate);
|
||
const hasSuccessfulData = await hasChannelIdReadRows(startDate, endDate);
|
||
const hasPendingRefresh = dates.some((date) => shouldResyncChannelIdDate(syncInfoMap.get(date), date, latestBizDate));
|
||
|
||
if (!hasPendingRefresh) {
|
||
return {
|
||
refreshedRanges: [],
|
||
usedStaleData: false,
|
||
coverageMode: 'synchronous',
|
||
};
|
||
}
|
||
|
||
if (!hasSuccessfulData) {
|
||
const refreshedRanges = await ensureChannelIdReadCoverage(startDate, endDate, latestBizDate);
|
||
return {
|
||
refreshedRanges,
|
||
usedStaleData: false,
|
||
coverageMode: 'synchronous',
|
||
};
|
||
}
|
||
|
||
ensureChannelIdReadCoverage(startDate, endDate, latestBizDate).catch((error) => {
|
||
console.warn(`[Channel] ${label}后台补齐读表失败:`, error.message || error);
|
||
});
|
||
|
||
return {
|
||
refreshedRanges: [],
|
||
usedStaleData: true,
|
||
coverageMode: 'background-refresh',
|
||
};
|
||
}
|
||
|
||
function buildChannelIdReadStoreBaseSql() {
|
||
return `
|
||
SELECT
|
||
channel_name AS channelName,
|
||
COALESCE(MAX(NULLIF(shop_name, '')), shop_key) AS shopName,
|
||
shop_key AS shopKey,
|
||
item_id AS itemId,
|
||
COALESCE(MAX(NULLIF(primary_sku_code, '')), '') AS primarySkuCode,
|
||
COALESCE(MAX(NULLIF(primary_spu, '')), MAX(NULLIF(primary_sku_code, '')), item_id) AS primarySpu,
|
||
MAX(NULLIF(sku_image_url, '')) AS skuImageUrl,
|
||
COALESCE(MAX(NULLIF(sale_status, '')), '未配置') AS saleStatus,
|
||
COALESCE(MAX(NULLIF(purchaser_name, '')), '未配置') AS purchaserName,
|
||
MAX(sku_count) AS skuCount,
|
||
ROUND(SUM(performance_amount), 2) AS performanceAmount,
|
||
ROUND(SUM(fee_amount), 2) AS feeAmount,
|
||
ROUND(CASE WHEN SUM(performance_amount) > 0 THEN SUM(fee_amount) / SUM(performance_amount) ELSE 0 END, 6) AS feeRatio,
|
||
ROUND(SUM(transaction_amount), 2) AS transactionAmount,
|
||
ROUND(SUM(after_sale_amount), 2) AS afterSaleAmount,
|
||
ROUND(SUM(push_fee_amount), 2) AS pushFeeAmount,
|
||
ROUND(SUM(marketing_fee_amount), 2) AS marketingFeeAmount,
|
||
ROUND(SUM(gross_cost_amount), 2) AS grossCostAmount,
|
||
ROUND(CASE WHEN SUM(performance_amount) > 0 THEN (SUM(performance_amount) - SUM(gross_cost_amount)) / SUM(performance_amount) ELSE 0 END, 6) AS grossMarginRate,
|
||
ROUND(CASE WHEN SUM(performance_amount) > 0 THEN SUM(push_fee_amount) / SUM(performance_amount) ELSE 0 END, 6) AS sourcePromotionFeeRatio,
|
||
ROUND(CASE WHEN SUM(performance_amount) > 0 THEN SUM(marketing_fee_amount) / SUM(performance_amount) ELSE 0 END, 6) AS marketingFeeRatio,
|
||
SUM(order_count) AS orderCount,
|
||
SUM(install_order_count) AS installOrderCount,
|
||
ROUND(CASE WHEN SUM(order_count) > 0 THEN SUM(install_order_count) / SUM(order_count) ELSE 0 END, 6) AS installOrderRatio,
|
||
ROUND(CASE WHEN SUM(performance_amount) > 0 THEN SUM(after_sale_amount) / SUM(performance_amount) ELSE 0 END, 6) AS idAfterSaleRate,
|
||
ROUND(CASE WHEN SUM(performance_amount) > 0 THEN (SUM(transaction_amount) - SUM(performance_amount)) / SUM(performance_amount) ELSE 0 END, 6) AS performanceRefundRate,
|
||
ROUND(SUM(goods_count), 2) AS goodsCount,
|
||
ROUND(SUM(refund_count), 2) AS refundCount
|
||
FROM ${CHANNEL_ID_READ_TABLE}
|
||
WHERE stat_date BETWEEN ? AND ?
|
||
GROUP BY channel_name, shop_key, item_id
|
||
`;
|
||
}
|
||
|
||
function buildChannelIdReadStoreFilteredSql({ startDate, endDate, keyword }) {
|
||
const innerSql = buildChannelIdReadStoreBaseSql();
|
||
const params = [startDate, endDate];
|
||
const whereParts = [];
|
||
|
||
if (keyword && String(keyword).trim()) {
|
||
const keywordValue = `%${String(keyword).trim()}%`;
|
||
whereParts.push(`(
|
||
base.channelName LIKE ?
|
||
OR base.shopName LIKE ?
|
||
OR base.itemId LIKE ?
|
||
OR base.primarySkuCode LIKE ?
|
||
OR base.primarySpu LIKE ?
|
||
OR base.purchaserName LIKE ?
|
||
)`);
|
||
params.push(keywordValue, keywordValue, keywordValue, keywordValue, keywordValue, keywordValue);
|
||
}
|
||
|
||
const whereSql = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '';
|
||
return {
|
||
sql: `SELECT * FROM (${innerSql}) base ${whereSql}`,
|
||
params,
|
||
};
|
||
}
|
||
|
||
async function countChannelIdReadStoreRows(sql, params) {
|
||
await ensurePlanningChannelIdDailyReadSchema();
|
||
const rows = await channelReadDb.query(
|
||
`SELECT COUNT(*) AS totalCount FROM (${sql}) counted`,
|
||
params,
|
||
CHANNEL_ID_ANALYSIS_READ_QUERY_OPTIONS
|
||
);
|
||
return Number(rows?.[0]?.totalCount || 0);
|
||
}
|
||
|
||
async function fetchChannelIdPurchaserRankMetrics(pageRows, filteredSql, filteredParams) {
|
||
const purchasers = uniqueText((pageRows || []).map((row) => row.purchaserName));
|
||
if (!purchasers.length) return new Map();
|
||
|
||
let rows = [];
|
||
try {
|
||
rows = await channelReadDb.query(
|
||
`SELECT
|
||
base.channelName,
|
||
base.shopKey,
|
||
base.itemId,
|
||
base.purchaserName,
|
||
base.performanceAmount
|
||
FROM (${filteredSql}) base
|
||
WHERE base.purchaserName IN (${purchasers.map(() => '?').join(', ')})
|
||
ORDER BY base.purchaserName ASC, base.performanceAmount DESC, base.itemId ASC, base.shopKey ASC, base.channelName ASC`,
|
||
[...filteredParams, ...purchasers],
|
||
CHANNEL_ID_ANALYSIS_PURCHASER_RANK_QUERY_OPTIONS
|
||
);
|
||
} catch (error) {
|
||
console.warn('[Channel] 查询ID维度采购排名补充数据超时或失败,先返回主数据:', error.message || error);
|
||
return new Map();
|
||
}
|
||
|
||
const grouped = new Map();
|
||
(rows || []).forEach((row) => {
|
||
const purchaserName = normalizeText(row.purchaserName) || '未配置';
|
||
const list = grouped.get(purchaserName) || [];
|
||
list.push(row);
|
||
grouped.set(purchaserName, list);
|
||
});
|
||
|
||
const resultMap = new Map();
|
||
grouped.forEach((list, purchaserName) => {
|
||
const total = list.reduce((sum, row) => sum + Number(row.performanceAmount || 0), 0);
|
||
let before = 0;
|
||
list.forEach((row) => {
|
||
const key = [
|
||
purchaserName,
|
||
normalizeText(row.channelName),
|
||
normalizeText(row.shopKey),
|
||
normalizeText(row.itemId),
|
||
].join('__');
|
||
resultMap.set(key, {
|
||
purchaserPerformanceTotal: total,
|
||
purchaserPerformanceBefore: before,
|
||
});
|
||
before += Number(row.performanceAmount || 0);
|
||
});
|
||
});
|
||
|
||
return resultMap;
|
||
}
|
||
|
||
async function fetchChannelIdOperatorMap(spus = []) {
|
||
const uniqueSpus = uniqueText(spus);
|
||
const operatorMap = new Map();
|
||
if (!uniqueSpus.length) return operatorMap;
|
||
|
||
for (const spuChunk of chunkArray(uniqueSpus, 200)) {
|
||
const params = {};
|
||
const unionSql = spuChunk.map((spu, index) => {
|
||
const key = `spu${index}`;
|
||
params[key] = spu;
|
||
return `SELECT @${key} AS spu`;
|
||
}).join(' UNION ALL ');
|
||
|
||
try {
|
||
const rows = await cloudDb.query(
|
||
`WITH input_spus AS (${unionSql})
|
||
SELECT
|
||
b.spu,
|
||
${CHANNEL_ID_OPERATOR_SQL} AS operatorName
|
||
FROM input_spus b
|
||
OUTER APPLY (
|
||
SELECT TOP 1
|
||
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.purchaser))), N'') AS operatorName
|
||
FROM dwd.dim_ope_spu_data_df d
|
||
WHERE d.spu = b.spu
|
||
AND NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.purchaser))), N'') IS NOT NULL
|
||
) md
|
||
OUTER APPLY (
|
||
SELECT TOP 1
|
||
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), v.oper))), N'') AS operatorName
|
||
FROM dbo.V_caterAndOper v
|
||
WHERE v.spu = b.spu
|
||
AND NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), v.oper))), N'') IS NOT NULL
|
||
) mv
|
||
OUTER APPLY (
|
||
SELECT TOP 1
|
||
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.oper))), N'') AS operatorName
|
||
FROM dbo.op_spuPrincipal_log l
|
||
WHERE l.spu = b.spu
|
||
AND NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.oper))), N'') IS NOT NULL
|
||
ORDER BY l.created DESC
|
||
) ml
|
||
OUTER APPLY (
|
||
SELECT TOP 1
|
||
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), c.oper))), N'') AS operatorName
|
||
FROM dbo.fys_SkuDataLibrary_SpuConfig c
|
||
WHERE b.spu LIKE c.spu + N'%'
|
||
AND NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), c.oper))), N'') IS NOT NULL
|
||
ORDER BY LEN(c.spu) DESC
|
||
) mc`,
|
||
params,
|
||
CHANNEL_SPU_ENRICHMENT_QUERY_OPTIONS
|
||
);
|
||
|
||
(rows || []).forEach((row) => {
|
||
const spu = normalizeText(row.spu);
|
||
const operatorName = normalizeText(row.operatorName);
|
||
if (spu && operatorName) {
|
||
operatorMap.set(spu, operatorName);
|
||
}
|
||
});
|
||
} catch (error) {
|
||
console.warn('[Channel] 查询ID负责人补充数据超时或失败,先返回读表负责人:', error.message || error);
|
||
}
|
||
}
|
||
|
||
return operatorMap;
|
||
}
|
||
|
||
async function getChannelIdAnalysisFromReadStore({
|
||
dateRange,
|
||
page,
|
||
pageSize,
|
||
keyword,
|
||
sortField,
|
||
sortOrder,
|
||
skipCoverage = false,
|
||
fastMode = false,
|
||
}) {
|
||
const coverage = skipCoverage
|
||
? {
|
||
refreshedRanges: [],
|
||
usedStaleData: false,
|
||
coverageMode: 'snapshot-skip',
|
||
}
|
||
: await resolveChannelIdReadCoverage(
|
||
dateRange.startDate,
|
||
dateRange.endDate,
|
||
dateRange.latestBizDate,
|
||
{
|
||
allowStale: true,
|
||
label: fastMode ? 'ID数据分析快速列表' : 'ID数据分析列表',
|
||
}
|
||
);
|
||
const refreshedRanges = coverage.refreshedRanges;
|
||
const filtered = buildChannelIdReadStoreFilteredSql({
|
||
startDate: dateRange.startDate,
|
||
endDate: dateRange.endDate,
|
||
keyword,
|
||
});
|
||
const total = await countChannelIdReadStoreRows(filtered.sql, filtered.params);
|
||
|
||
if (!total) {
|
||
return {
|
||
page,
|
||
pageSize,
|
||
total: 0,
|
||
rows: [],
|
||
dateRange,
|
||
meta: {
|
||
source: 'read-store-id',
|
||
refreshedRanges,
|
||
readStoreCoverageMode: coverage.coverageMode,
|
||
staleRequestedWindow: coverage.usedStaleData,
|
||
},
|
||
};
|
||
}
|
||
|
||
const orderSql = `${resolveChannelIdReadSortExpression(sortField)} ${resolveSortOrder(sortOrder)}, base.itemId ASC, base.shopKey ASC`;
|
||
const rawRows = await channelReadDb.query(
|
||
`SELECT base.*
|
||
FROM (${filtered.sql}) base
|
||
ORDER BY ${orderSql}
|
||
LIMIT ? OFFSET ?`,
|
||
[...filtered.params, pageSize, (page - 1) * pageSize],
|
||
CHANNEL_ID_ANALYSIS_READ_QUERY_OPTIONS
|
||
);
|
||
|
||
if (fastMode) {
|
||
const periodType = buildChannelIdPeriodType(dateRange);
|
||
const rows = (rawRows || []).map((row) => {
|
||
const feeRatio = toNullableNumber(row.feeRatio, 6);
|
||
const idAfterSaleRate = toNullableNumber(row.idAfterSaleRate, 6);
|
||
const performanceRefundRate = toNullableNumber(row.performanceRefundRate, 6);
|
||
|
||
return {
|
||
channelName: row.channelName || '未归类',
|
||
shopName: row.shopName || '--',
|
||
purchaserName: normalizeText(row.purchaserName) || '未配置',
|
||
imageUrl: row.skuImageUrl || null,
|
||
itemId: normalizeText(row.itemId),
|
||
periodType,
|
||
anomalyTag: buildChannelIdAnomalyTag({
|
||
channelName: row.channelName,
|
||
feeRatio: Number(feeRatio),
|
||
idAfterSaleRate: Number(idAfterSaleRate),
|
||
}),
|
||
headMidTailLabel: '',
|
||
performanceAmount: toNumber(row.performanceAmount),
|
||
feeRatio,
|
||
grossMarginRate: toNullableNumber(row.grossMarginRate, 6),
|
||
promotionFeeRatio: toNullableNumber(row.sourcePromotionFeeRatio, 6),
|
||
marketingFeeRatio: toNullableNumber(row.marketingFeeRatio, 6),
|
||
installOrderRatio: toNullableNumber(row.installOrderRatio, 6),
|
||
idAfterSaleRate,
|
||
performanceRefundRate,
|
||
roi: null,
|
||
targetRoi: null,
|
||
goodsUv: null,
|
||
payOrdrAmt: toNullableNumber(row.transactionAmount, 2),
|
||
goodsVcr: null,
|
||
trafficLossIndex: null,
|
||
goodsPtHelpRate30d: null,
|
||
promotionScore: null,
|
||
impression: null,
|
||
ctr: null,
|
||
gmv: null,
|
||
netGmv: null,
|
||
actualRoi: null,
|
||
netRoi: null,
|
||
settlementRoi: null,
|
||
refundRate: Number(row.goodsCount) > 0
|
||
? toNullableNumber(Number(row.refundCount) / Number(row.goodsCount), 6)
|
||
: null,
|
||
primarySkuCode: row.primarySkuCode || '',
|
||
primarySpu: normalizeText(row.primarySpu) || normalizeText(row.primarySkuCode) || normalizeText(row.itemId),
|
||
skuCount: toNumber(row.skuCount, 0),
|
||
saleStatus: row.saleStatus || '未配置',
|
||
feeAmount: toNumber(row.feeAmount),
|
||
transactionAmount: toNumber(row.transactionAmount),
|
||
afterSaleAmount: toNumber(row.afterSaleAmount),
|
||
pushFeeAmount: toNumber(row.pushFeeAmount),
|
||
marketingFeeAmount: toNumber(row.marketingFeeAmount),
|
||
grossCostAmount: toNumber(row.grossCostAmount),
|
||
promoteCostCharge: 0,
|
||
promoteSupportCharge: 0,
|
||
strategyIds: 0,
|
||
strategyKeywords: 0,
|
||
strategyPerson: 0,
|
||
strategyScene: 0,
|
||
goodsCount: toNumber(row.goodsCount),
|
||
refundCount: toNumber(row.refundCount),
|
||
orderCount: toNumber(row.orderCount, 0),
|
||
installOrderCount: toNumber(row.installOrderCount, 0),
|
||
};
|
||
});
|
||
|
||
return {
|
||
page,
|
||
pageSize,
|
||
total,
|
||
rows,
|
||
dateRange,
|
||
meta: {
|
||
source: 'read-store-id-fast',
|
||
refreshedRanges,
|
||
readStoreCoverageMode: coverage.coverageMode,
|
||
staleRequestedWindow: coverage.usedStaleData,
|
||
partialData: true,
|
||
enrichmentMode: 'fast',
|
||
},
|
||
};
|
||
}
|
||
|
||
const pddItemIds = uniqueText(
|
||
(rawRows || [])
|
||
.filter((row) => isPddChannelName(row.channelName))
|
||
.map((row) => row.itemId)
|
||
);
|
||
const primarySkuCodes = uniqueText((rawRows || []).map((row) => row.primarySkuCode));
|
||
const [pddMetricMap, basicRankMetricMap, promoteMetricMap, purchaserRankMetricMap, profileMap] = await Promise.all([
|
||
fetchChannelIdPddMetrics(pddItemIds, dateRange.startDate, dateRange.endDate),
|
||
fetchChannelIdBasicRankMetrics(rawRows || [], dateRange.startDate, dateRange.endDate),
|
||
fetchChannelIdPromoteMetrics(rawRows || [], dateRange.startDate, dateRange.endDate),
|
||
fetchChannelIdPurchaserRankMetrics(rawRows || [], filtered.sql, filtered.params),
|
||
fetchSkuProfileMap(primarySkuCodes),
|
||
]);
|
||
const pageSpus = uniqueText((rawRows || []).map((row) => {
|
||
const profile = profileMap.get(normalizeSkuCodeKey(row.primarySkuCode)) || {};
|
||
return profile.productCode || row.primarySpu || row.primarySkuCode || row.itemId;
|
||
}));
|
||
const operatorMap = await fetchChannelIdOperatorMap(pageSpus);
|
||
const periodType = buildChannelIdPeriodType(dateRange);
|
||
|
||
const rows = (rawRows || []).map((row) => {
|
||
const itemId = normalizeText(row.itemId);
|
||
const shopKey = normalizeText(row.shopKey);
|
||
const skuProfile = profileMap.get(normalizeSkuCodeKey(row.primarySkuCode)) || {};
|
||
const primarySpu = normalizeText(skuProfile.productCode) || normalizeText(row.primarySpu) || normalizeText(row.primarySkuCode) || itemId;
|
||
const purchaserName = operatorMap.get(primarySpu) || normalizeText(row.purchaserName) || '未配置';
|
||
const rankKey = [
|
||
normalizeText(row.purchaserName) || '未配置',
|
||
normalizeText(row.channelName),
|
||
shopKey,
|
||
itemId,
|
||
].join('__');
|
||
const rankMetrics = purchaserRankMetricMap.get(rankKey) || {};
|
||
const performanceAmount = toNumber(row.performanceAmount);
|
||
const feeRatio = toNullableNumber(row.feeRatio, 6);
|
||
const idAfterSaleRate = toNullableNumber(row.idAfterSaleRate, 6);
|
||
const performanceRefundRate = toNullableNumber(row.performanceRefundRate, 6);
|
||
const pddMetrics = pddMetricMap.get(itemId) || {};
|
||
const basicRankMetrics = basicRankMetricMap.get(`${shopKey}__${itemId}`) || {};
|
||
const promoteMetrics = promoteMetricMap.get(`${shopKey}__${itemId}`) || {};
|
||
const sourcePromotionFeeRatio = toNullableNumber(row.sourcePromotionFeeRatio, 6);
|
||
const normalizedPddGlobalTakeRate = normalizeRatioValue(pddMetrics.globalTakeRate, 6);
|
||
const pddPromotionFeeRatio = (
|
||
Number.isFinite(Number(normalizedPddGlobalTakeRate)) && Number(normalizedPddGlobalTakeRate) > 0
|
||
? normalizedPddGlobalTakeRate
|
||
: null
|
||
)
|
||
?? (
|
||
Number.isFinite(Number(pddMetrics.spend)) && performanceAmount > 0
|
||
? toNullableNumber(Number(pddMetrics.spend) / performanceAmount, 6)
|
||
: null
|
||
);
|
||
const promotionFeeRatio = pddPromotionFeeRatio ?? sourcePromotionFeeRatio;
|
||
const targetFeeRatio = getChannelIdFeeThreshold(row.channelName);
|
||
const targetPromotionFeeRatio = (
|
||
Number.isFinite(Number(promotionFeeRatio)) &&
|
||
Number.isFinite(Number(feeRatio))
|
||
)
|
||
? toNullableNumber(Number(promotionFeeRatio) - (Number(feeRatio) - targetFeeRatio), 6)
|
||
: null;
|
||
const roi = Number.isFinite(Number(promotionFeeRatio)) && Number(promotionFeeRatio) > 0
|
||
? toNullableNumber(1 / Number(promotionFeeRatio), 2)
|
||
: null;
|
||
const targetRoi = Number.isFinite(Number(pddMetrics.targetRoi)) && Number(pddMetrics.targetRoi) > 0
|
||
? pddMetrics.targetRoi
|
||
: (
|
||
Number.isFinite(Number(targetPromotionFeeRatio)) && Number(targetPromotionFeeRatio) > 0
|
||
? toNullableNumber(1 / Number(targetPromotionFeeRatio), 2)
|
||
: null
|
||
);
|
||
const refundRate = pddMetrics.refundRate ?? (
|
||
Number(row.goodsCount) > 0
|
||
? toNullableNumber(Number(row.refundCount) / Number(row.goodsCount), 6)
|
||
: null
|
||
);
|
||
|
||
return {
|
||
channelName: row.channelName || '未归类',
|
||
shopName: row.shopName || '--',
|
||
purchaserName,
|
||
imageUrl: pddMetrics.imageUrl || skuProfile.skuImageUrl || row.skuImageUrl || null,
|
||
itemId,
|
||
periodType,
|
||
anomalyTag: buildChannelIdAnomalyTag({
|
||
channelName: row.channelName,
|
||
feeRatio: Number(feeRatio),
|
||
idAfterSaleRate: Number(idAfterSaleRate),
|
||
}),
|
||
headMidTailLabel: buildChannelIdHeadTailLabel(
|
||
rankMetrics.purchaserPerformanceTotal,
|
||
rankMetrics.purchaserPerformanceBefore
|
||
),
|
||
performanceAmount,
|
||
feeRatio,
|
||
grossMarginRate: toNullableNumber(row.grossMarginRate, 6),
|
||
promotionFeeRatio,
|
||
marketingFeeRatio: toNullableNumber(row.marketingFeeRatio, 6),
|
||
installOrderRatio: toNullableNumber(row.installOrderRatio, 6),
|
||
idAfterSaleRate,
|
||
performanceRefundRate,
|
||
roi,
|
||
targetRoi,
|
||
goodsUv: pddMetrics.goodsUv ?? basicRankMetrics.goodsUv ?? null,
|
||
payOrdrAmt: pddMetrics.payOrdrAmt ?? basicRankMetrics.payment ?? toNullableNumber(row.transactionAmount, 2),
|
||
goodsVcr: pddMetrics.goodsVcr ?? basicRankMetrics.goodsVcr ?? null,
|
||
trafficLossIndex: pddMetrics.imprUsrCnt ?? basicRankMetrics.searchGuideViewsNum ?? null,
|
||
goodsPtHelpRate30d: pddMetrics.goodsPtHelpRate ?? null,
|
||
promotionScore: promoteMetrics.promotionScore ?? null,
|
||
impression: pddMetrics.impression ?? null,
|
||
ctr: pddMetrics.ctr ?? null,
|
||
gmv: pddMetrics.gmv ?? null,
|
||
netGmv: pddMetrics.netGmv ?? null,
|
||
actualRoi: pddMetrics.actualRoi ?? null,
|
||
netRoi: pddMetrics.netRoi ?? null,
|
||
settlementRoi: pddMetrics.settlementRoi ?? null,
|
||
refundRate,
|
||
primarySkuCode: row.primarySkuCode || '',
|
||
primarySpu,
|
||
skuCount: toNumber(row.skuCount, 0),
|
||
saleStatus: skuProfile.saleStatus || row.saleStatus || '未配置',
|
||
feeAmount: toNumber(row.feeAmount),
|
||
transactionAmount: toNumber(row.transactionAmount),
|
||
afterSaleAmount: toNumber(row.afterSaleAmount),
|
||
pushFeeAmount: toNumber(row.pushFeeAmount),
|
||
marketingFeeAmount: toNumber(row.marketingFeeAmount),
|
||
grossCostAmount: toNumber(row.grossCostAmount),
|
||
promoteCostCharge: toNumber(promoteMetrics.costCharge),
|
||
promoteSupportCharge: toNumber(promoteMetrics.supCharge),
|
||
strategyIds: toNumber(promoteMetrics.strategyIds),
|
||
strategyKeywords: toNumber(promoteMetrics.strategyKeywords),
|
||
strategyPerson: toNumber(promoteMetrics.strategyPerson),
|
||
strategyScene: toNumber(promoteMetrics.strategyScene),
|
||
goodsCount: toNumber(row.goodsCount),
|
||
refundCount: toNumber(row.refundCount),
|
||
orderCount: toNumber(row.orderCount, 0),
|
||
installOrderCount: toNumber(row.installOrderCount, 0),
|
||
};
|
||
});
|
||
|
||
return {
|
||
page,
|
||
pageSize,
|
||
total,
|
||
rows,
|
||
dateRange,
|
||
meta: {
|
||
source: 'read-store-id',
|
||
refreshedRanges,
|
||
readStoreCoverageMode: coverage.coverageMode,
|
||
staleRequestedWindow: coverage.usedStaleData,
|
||
},
|
||
};
|
||
}
|
||
|
||
function formatChannelIdDashboardDate(value) {
|
||
if (!value) return '';
|
||
const parsed = dayjs(value);
|
||
return parsed.isValid() ? parsed.format('YYYY-MM-DD') : String(value).slice(0, 10);
|
||
}
|
||
|
||
function normalizeChannelIdDashboardLimit(value) {
|
||
const parsed = Number.parseInt(value, 10);
|
||
if (!Number.isFinite(parsed) || parsed <= 0) return 50;
|
||
return Math.min(parsed, 80);
|
||
}
|
||
|
||
function getChannelIdTargetFeeRatioSql(channelExpression) {
|
||
return `CASE
|
||
WHEN ${channelExpression} LIKE '%得物%' THEN 0.74
|
||
WHEN ${channelExpression} LIKE '%1688%' THEN 0.8
|
||
WHEN ${channelExpression} LIKE '%唯品会%' THEN 0.75
|
||
WHEN ${channelExpression} LIKE '%京东%' THEN 0.75
|
||
WHEN ${channelExpression} LIKE '%天猫%' THEN 0.75
|
||
WHEN ${channelExpression} LIKE '%拼多多%' THEN 0.75
|
||
ELSE 0.75
|
||
END`;
|
||
}
|
||
|
||
function buildChannelIdDashboardKeywordClause(keyword, alias = 'r') {
|
||
const keywordText = normalizeText(keyword);
|
||
if (!keywordText) {
|
||
return {
|
||
sql: '',
|
||
params: [],
|
||
};
|
||
}
|
||
|
||
const prefix = alias ? `${alias}.` : '';
|
||
const likeValue = `%${keywordText}%`;
|
||
return {
|
||
sql: `AND (
|
||
${prefix}channel_name LIKE ?
|
||
OR ${prefix}shop_name LIKE ?
|
||
OR ${prefix}item_id LIKE ?
|
||
OR ${prefix}primary_sku_code LIKE ?
|
||
OR ${prefix}primary_spu LIKE ?
|
||
OR ${prefix}purchaser_name LIKE ?
|
||
)`,
|
||
params: [likeValue, likeValue, likeValue, likeValue, likeValue, likeValue],
|
||
};
|
||
}
|
||
|
||
function buildChannelIdDashboardBaseSql({ startDate, endDate, keyword }) {
|
||
const keywordClause = buildChannelIdDashboardKeywordClause(keyword, 'r');
|
||
return {
|
||
sql: `SELECT
|
||
r.channel_name AS channelName,
|
||
COALESCE(MAX(NULLIF(r.shop_name, '')), r.shop_key) AS shopName,
|
||
r.shop_key AS shopKey,
|
||
r.item_id AS itemId,
|
||
COALESCE(MAX(NULLIF(r.primary_sku_code, '')), '') AS primarySkuCode,
|
||
COALESCE(MAX(NULLIF(r.primary_spu, '')), MAX(NULLIF(r.primary_sku_code, '')), r.item_id) AS primarySpu,
|
||
COALESCE(MAX(NULLIF(r.sale_status, '')), '未配置') AS saleStatus,
|
||
COALESCE(MAX(NULLIF(r.purchaser_name, '')), '未配置') AS purchaserName,
|
||
COUNT(DISTINCT r.stat_date) AS statDayCount,
|
||
MAX(r.sku_count) AS skuCount,
|
||
ROUND(SUM(r.performance_amount), 2) AS performanceAmount,
|
||
ROUND(SUM(r.fee_amount), 2) AS feeAmount,
|
||
ROUND(CASE WHEN SUM(r.performance_amount) > 0 THEN SUM(r.fee_amount) / SUM(r.performance_amount) ELSE 0 END, 6) AS feeRatio,
|
||
ROUND(SUM(r.transaction_amount), 2) AS transactionAmount,
|
||
ROUND(SUM(r.after_sale_amount), 2) AS afterSaleAmount,
|
||
ROUND(SUM(r.push_fee_amount), 2) AS pushFeeAmount,
|
||
ROUND(SUM(r.marketing_fee_amount), 2) AS marketingFeeAmount,
|
||
ROUND(SUM(r.gross_cost_amount), 2) AS grossCostAmount,
|
||
ROUND(CASE WHEN SUM(r.performance_amount) > 0 THEN (SUM(r.performance_amount) - SUM(r.gross_cost_amount)) / SUM(r.performance_amount) ELSE 0 END, 6) AS grossMarginRate,
|
||
ROUND(CASE WHEN SUM(r.performance_amount) > 0 THEN SUM(r.push_fee_amount) / SUM(r.performance_amount) ELSE 0 END, 6) AS promotionFeeRatio,
|
||
ROUND(CASE WHEN SUM(r.performance_amount) > 0 THEN SUM(r.marketing_fee_amount) / SUM(r.performance_amount) ELSE 0 END, 6) AS marketingFeeRatio,
|
||
SUM(r.order_count) AS orderCount,
|
||
SUM(r.install_order_count) AS installOrderCount,
|
||
ROUND(CASE WHEN SUM(r.order_count) > 0 THEN SUM(r.install_order_count) / SUM(r.order_count) ELSE 0 END, 6) AS installOrderRatio,
|
||
ROUND(CASE WHEN SUM(r.performance_amount) > 0 THEN SUM(r.after_sale_amount) / SUM(r.performance_amount) ELSE 0 END, 6) AS idAfterSaleRate,
|
||
ROUND(CASE WHEN SUM(r.performance_amount) > 0 THEN (SUM(r.transaction_amount) - SUM(r.performance_amount)) / SUM(r.performance_amount) ELSE 0 END, 6) AS performanceRefundRate,
|
||
ROUND(SUM(r.goods_count), 2) AS goodsCount,
|
||
ROUND(SUM(r.refund_count), 2) AS refundCount,
|
||
ROUND(CASE WHEN SUM(r.goods_count) > 0 THEN SUM(r.refund_count) / SUM(r.goods_count) ELSE 0 END, 6) AS refundRate
|
||
FROM ${CHANNEL_ID_READ_TABLE} r
|
||
WHERE r.stat_date BETWEEN ? AND ?
|
||
${keywordClause.sql}
|
||
GROUP BY r.channel_name, r.shop_key, r.item_id`,
|
||
params: [startDate, endDate, ...keywordClause.params],
|
||
};
|
||
}
|
||
|
||
function getChannelIdDashboardScoredSql(baseSql) {
|
||
const targetFeeSql = getChannelIdTargetFeeRatioSql('base.channelName');
|
||
return `WITH base AS (${baseSql}),
|
||
scored AS (
|
||
SELECT
|
||
base.*,
|
||
${targetFeeSql} AS targetFeeRatio,
|
||
CASE WHEN base.performanceAmount > 0 AND base.feeRatio > ${targetFeeSql} THEN 1 ELSE 0 END AS highFeeFlag,
|
||
CASE WHEN base.idAfterSaleRate > 0.05 THEN 1 ELSE 0 END AS afterSaleFlag,
|
||
GREATEST(base.feeAmount - (${targetFeeSql} * base.performanceAmount), 0) AS feeOverrunAmount,
|
||
GREATEST(base.afterSaleAmount, 0) AS afterSaleImpactAmount,
|
||
GREATEST(base.transactionAmount - base.performanceAmount, 0) AS refundImpactAmount,
|
||
GREATEST(base.feeAmount - (${targetFeeSql} * base.performanceAmount), 0)
|
||
+ GREATEST(base.afterSaleAmount, 0)
|
||
+ GREATEST(base.transactionAmount - base.performanceAmount, 0) AS riskAmount
|
||
FROM base
|
||
)`;
|
||
}
|
||
|
||
function normalizeChannelIdDashboardSummary(row = {}) {
|
||
return {
|
||
idCount: toNumber(row.idCount, 0),
|
||
channelCount: toNumber(row.channelCount, 0),
|
||
shopCount: toNumber(row.shopCount, 0),
|
||
performanceAmount: toNumber(row.performanceAmount, 2),
|
||
feeAmount: toNumber(row.feeAmount, 2),
|
||
transactionAmount: toNumber(row.transactionAmount, 2),
|
||
afterSaleAmount: toNumber(row.afterSaleAmount, 2),
|
||
feeRatio: toNumber(row.feeRatio, 6),
|
||
idAfterSaleRate: toNumber(row.idAfterSaleRate, 6),
|
||
performanceRefundRate: toNumber(row.performanceRefundRate, 6),
|
||
grossMarginRate: toNumber(row.grossMarginRate, 6),
|
||
highFeeIdCount: toNumber(row.highFeeIdCount, 0),
|
||
afterSaleAnomalyIdCount: toNumber(row.afterSaleAnomalyIdCount, 0),
|
||
doubleAnomalyIdCount: toNumber(row.doubleAnomalyIdCount, 0),
|
||
riskAmount: toNumber(row.riskAmount, 2),
|
||
orderCount: toNumber(row.orderCount, 0),
|
||
goodsCount: toNumber(row.goodsCount, 0),
|
||
refundCount: toNumber(row.refundCount, 0),
|
||
};
|
||
}
|
||
|
||
async function queryChannelIdDashboardSummary(base) {
|
||
const rows = await channelReadDb.query(
|
||
`${getChannelIdDashboardScoredSql(base.sql)}
|
||
SELECT
|
||
COUNT(1) AS idCount,
|
||
COUNT(DISTINCT channelName) AS channelCount,
|
||
COUNT(DISTINCT shopKey) AS shopCount,
|
||
ROUND(SUM(performanceAmount), 2) AS performanceAmount,
|
||
ROUND(SUM(feeAmount), 2) AS feeAmount,
|
||
ROUND(SUM(transactionAmount), 2) AS transactionAmount,
|
||
ROUND(SUM(afterSaleAmount), 2) AS afterSaleAmount,
|
||
ROUND(CASE WHEN SUM(performanceAmount) > 0 THEN SUM(feeAmount) / SUM(performanceAmount) ELSE 0 END, 6) AS feeRatio,
|
||
ROUND(CASE WHEN SUM(performanceAmount) > 0 THEN SUM(afterSaleAmount) / SUM(performanceAmount) ELSE 0 END, 6) AS idAfterSaleRate,
|
||
ROUND(CASE WHEN SUM(performanceAmount) > 0 THEN (SUM(transactionAmount) - SUM(performanceAmount)) / SUM(performanceAmount) ELSE 0 END, 6) AS performanceRefundRate,
|
||
ROUND(CASE WHEN SUM(performanceAmount) > 0 THEN (SUM(performanceAmount) - SUM(grossCostAmount)) / SUM(performanceAmount) ELSE 0 END, 6) AS grossMarginRate,
|
||
SUM(highFeeFlag) AS highFeeIdCount,
|
||
SUM(afterSaleFlag) AS afterSaleAnomalyIdCount,
|
||
SUM(CASE WHEN highFeeFlag = 1 AND afterSaleFlag = 1 THEN 1 ELSE 0 END) AS doubleAnomalyIdCount,
|
||
ROUND(SUM(riskAmount), 2) AS riskAmount,
|
||
SUM(orderCount) AS orderCount,
|
||
ROUND(SUM(goodsCount), 2) AS goodsCount,
|
||
ROUND(SUM(refundCount), 2) AS refundCount
|
||
FROM scored`,
|
||
base.params,
|
||
CHANNEL_ID_ANALYSIS_DASHBOARD_QUERY_OPTIONS
|
||
);
|
||
|
||
return normalizeChannelIdDashboardSummary(rows?.[0] || {});
|
||
}
|
||
|
||
async function queryChannelIdDashboardTrend({ startDate, endDate, keyword }) {
|
||
const keywordClause = buildChannelIdDashboardKeywordClause(keyword, 'r');
|
||
const targetFeeSql = getChannelIdTargetFeeRatioSql('channelName');
|
||
const rows = await channelReadDb.query(
|
||
`WITH daily AS (
|
||
SELECT
|
||
r.stat_date AS statDate,
|
||
r.channel_name AS channelName,
|
||
r.shop_key AS shopKey,
|
||
r.item_id AS itemId,
|
||
r.performance_amount AS performanceAmount,
|
||
r.fee_amount AS feeAmount,
|
||
r.transaction_amount AS transactionAmount,
|
||
r.after_sale_amount AS afterSaleAmount,
|
||
r.gross_cost_amount AS grossCostAmount,
|
||
ROUND(CASE WHEN r.performance_amount > 0 THEN r.fee_amount / r.performance_amount ELSE 0 END, 6) AS feeRatio,
|
||
ROUND(CASE WHEN r.performance_amount > 0 THEN r.after_sale_amount / r.performance_amount ELSE 0 END, 6) AS idAfterSaleRate
|
||
FROM ${CHANNEL_ID_READ_TABLE} r
|
||
WHERE r.stat_date BETWEEN ? AND ?
|
||
${keywordClause.sql}
|
||
),
|
||
scored AS (
|
||
SELECT
|
||
daily.*,
|
||
CASE WHEN performanceAmount > 0 AND feeRatio > ${targetFeeSql} THEN 1 ELSE 0 END AS highFeeFlag,
|
||
CASE WHEN idAfterSaleRate > 0.05 THEN 1 ELSE 0 END AS afterSaleFlag
|
||
FROM daily
|
||
)
|
||
SELECT
|
||
statDate,
|
||
COUNT(1) AS idCount,
|
||
COUNT(DISTINCT channelName) AS channelCount,
|
||
COUNT(DISTINCT shopKey) AS shopCount,
|
||
ROUND(SUM(performanceAmount), 2) AS performanceAmount,
|
||
ROUND(CASE WHEN SUM(performanceAmount) > 0 THEN SUM(feeAmount) / SUM(performanceAmount) ELSE 0 END, 6) AS feeRatio,
|
||
ROUND(CASE WHEN SUM(performanceAmount) > 0 THEN SUM(afterSaleAmount) / SUM(performanceAmount) ELSE 0 END, 6) AS idAfterSaleRate,
|
||
ROUND(CASE WHEN SUM(performanceAmount) > 0 THEN (SUM(transactionAmount) - SUM(performanceAmount)) / SUM(performanceAmount) ELSE 0 END, 6) AS performanceRefundRate,
|
||
ROUND(CASE WHEN SUM(performanceAmount) > 0 THEN (SUM(performanceAmount) - SUM(grossCostAmount)) / SUM(performanceAmount) ELSE 0 END, 6) AS grossMarginRate,
|
||
SUM(highFeeFlag) AS highFeeIdCount,
|
||
SUM(afterSaleFlag) AS afterSaleAnomalyIdCount
|
||
FROM scored
|
||
GROUP BY statDate
|
||
ORDER BY statDate ASC`,
|
||
[startDate, endDate, ...keywordClause.params],
|
||
CHANNEL_ID_ANALYSIS_DASHBOARD_QUERY_OPTIONS
|
||
);
|
||
|
||
return (rows || []).map((row) => ({
|
||
statDate: formatChannelIdDashboardDate(row.statDate),
|
||
idCount: toNumber(row.idCount, 0),
|
||
channelCount: toNumber(row.channelCount, 0),
|
||
shopCount: toNumber(row.shopCount, 0),
|
||
performanceAmount: toNumber(row.performanceAmount, 2),
|
||
feeRatio: toNumber(row.feeRatio, 6),
|
||
idAfterSaleRate: toNumber(row.idAfterSaleRate, 6),
|
||
performanceRefundRate: toNumber(row.performanceRefundRate, 6),
|
||
grossMarginRate: toNumber(row.grossMarginRate, 6),
|
||
highFeeIdCount: toNumber(row.highFeeIdCount, 0),
|
||
afterSaleAnomalyIdCount: toNumber(row.afterSaleAnomalyIdCount, 0),
|
||
}));
|
||
}
|
||
|
||
function normalizeChannelIdDimensionRow(row = {}) {
|
||
return {
|
||
dimensionKey: row.dimensionKey || '',
|
||
dimensionValue: row.dimensionValue || '未配置',
|
||
subValue: row.subValue || '',
|
||
itemCount: toNumber(row.itemCount, 0),
|
||
performanceAmount: toNumber(row.performanceAmount, 2),
|
||
feeRatio: toNumber(row.feeRatio, 6),
|
||
idAfterSaleRate: toNumber(row.idAfterSaleRate, 6),
|
||
highFeeIdCount: toNumber(row.highFeeIdCount, 0),
|
||
afterSaleAnomalyIdCount: toNumber(row.afterSaleAnomalyIdCount, 0),
|
||
riskAmount: toNumber(row.riskAmount, 2),
|
||
};
|
||
}
|
||
|
||
async function queryChannelIdDashboardDimensionGroups(base) {
|
||
const rows = await channelReadDb.query(
|
||
`${getChannelIdDashboardScoredSql(base.sql)},
|
||
ranked AS (
|
||
SELECT
|
||
scored.*,
|
||
SUM(performanceAmount) OVER () AS totalPerformance,
|
||
SUM(performanceAmount) OVER (
|
||
ORDER BY performanceAmount DESC, itemId ASC, shopKey ASC
|
||
ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
|
||
) AS performanceBefore
|
||
FROM scored
|
||
),
|
||
head_tail AS (
|
||
SELECT
|
||
*,
|
||
CASE
|
||
WHEN totalPerformance <= 0 THEN '尾部'
|
||
WHEN IFNULL(performanceBefore, 0) / totalPerformance < 0.3 THEN '头部'
|
||
WHEN IFNULL(performanceBefore, 0) / totalPerformance < 0.7 THEN '腰部'
|
||
ELSE '尾部'
|
||
END AS segmentName
|
||
FROM ranked
|
||
)
|
||
SELECT
|
||
'channel' AS dimensionKey,
|
||
channelName AS dimensionValue,
|
||
'' AS subValue,
|
||
COUNT(1) AS itemCount,
|
||
ROUND(SUM(performanceAmount), 2) AS performanceAmount,
|
||
ROUND(CASE WHEN SUM(performanceAmount) > 0 THEN SUM(feeAmount) / SUM(performanceAmount) ELSE 0 END, 6) AS feeRatio,
|
||
ROUND(CASE WHEN SUM(performanceAmount) > 0 THEN SUM(afterSaleAmount) / SUM(performanceAmount) ELSE 0 END, 6) AS idAfterSaleRate,
|
||
SUM(highFeeFlag) AS highFeeIdCount,
|
||
SUM(afterSaleFlag) AS afterSaleAnomalyIdCount,
|
||
ROUND(SUM(riskAmount), 2) AS riskAmount
|
||
FROM scored
|
||
GROUP BY channelName
|
||
UNION ALL
|
||
SELECT
|
||
'shop',
|
||
shopName,
|
||
channelName,
|
||
COUNT(1),
|
||
ROUND(SUM(performanceAmount), 2),
|
||
ROUND(CASE WHEN SUM(performanceAmount) > 0 THEN SUM(feeAmount) / SUM(performanceAmount) ELSE 0 END, 6),
|
||
ROUND(CASE WHEN SUM(performanceAmount) > 0 THEN SUM(afterSaleAmount) / SUM(performanceAmount) ELSE 0 END, 6),
|
||
SUM(highFeeFlag),
|
||
SUM(afterSaleFlag),
|
||
ROUND(SUM(riskAmount), 2)
|
||
FROM scored
|
||
GROUP BY channelName, shopKey, shopName
|
||
UNION ALL
|
||
SELECT
|
||
'headTail',
|
||
segmentName,
|
||
'',
|
||
COUNT(1),
|
||
ROUND(SUM(performanceAmount), 2),
|
||
ROUND(CASE WHEN SUM(performanceAmount) > 0 THEN SUM(feeAmount) / SUM(performanceAmount) ELSE 0 END, 6),
|
||
ROUND(CASE WHEN SUM(performanceAmount) > 0 THEN SUM(afterSaleAmount) / SUM(performanceAmount) ELSE 0 END, 6),
|
||
SUM(highFeeFlag),
|
||
SUM(afterSaleFlag),
|
||
ROUND(SUM(riskAmount), 2)
|
||
FROM head_tail
|
||
GROUP BY segmentName
|
||
UNION ALL
|
||
SELECT
|
||
'anomaly',
|
||
'费比超标',
|
||
'',
|
||
SUM(highFeeFlag),
|
||
ROUND(SUM(CASE WHEN highFeeFlag = 1 THEN performanceAmount ELSE 0 END), 2),
|
||
ROUND(CASE WHEN SUM(CASE WHEN highFeeFlag = 1 THEN performanceAmount ELSE 0 END) > 0 THEN SUM(CASE WHEN highFeeFlag = 1 THEN feeAmount ELSE 0 END) / SUM(CASE WHEN highFeeFlag = 1 THEN performanceAmount ELSE 0 END) ELSE 0 END, 6),
|
||
ROUND(CASE WHEN SUM(CASE WHEN highFeeFlag = 1 THEN performanceAmount ELSE 0 END) > 0 THEN SUM(CASE WHEN highFeeFlag = 1 THEN afterSaleAmount ELSE 0 END) / SUM(CASE WHEN highFeeFlag = 1 THEN performanceAmount ELSE 0 END) ELSE 0 END, 6),
|
||
SUM(highFeeFlag),
|
||
SUM(CASE WHEN highFeeFlag = 1 AND afterSaleFlag = 1 THEN 1 ELSE 0 END),
|
||
ROUND(SUM(CASE WHEN highFeeFlag = 1 THEN feeOverrunAmount ELSE 0 END), 2)
|
||
FROM scored
|
||
UNION ALL
|
||
SELECT
|
||
'anomaly',
|
||
'售后异常',
|
||
'',
|
||
SUM(afterSaleFlag),
|
||
ROUND(SUM(CASE WHEN afterSaleFlag = 1 THEN performanceAmount ELSE 0 END), 2),
|
||
ROUND(CASE WHEN SUM(CASE WHEN afterSaleFlag = 1 THEN performanceAmount ELSE 0 END) > 0 THEN SUM(CASE WHEN afterSaleFlag = 1 THEN feeAmount ELSE 0 END) / SUM(CASE WHEN afterSaleFlag = 1 THEN performanceAmount ELSE 0 END) ELSE 0 END, 6),
|
||
ROUND(CASE WHEN SUM(CASE WHEN afterSaleFlag = 1 THEN performanceAmount ELSE 0 END) > 0 THEN SUM(CASE WHEN afterSaleFlag = 1 THEN afterSaleAmount ELSE 0 END) / SUM(CASE WHEN afterSaleFlag = 1 THEN performanceAmount ELSE 0 END) ELSE 0 END, 6),
|
||
SUM(CASE WHEN highFeeFlag = 1 AND afterSaleFlag = 1 THEN 1 ELSE 0 END),
|
||
SUM(afterSaleFlag),
|
||
ROUND(SUM(CASE WHEN afterSaleFlag = 1 THEN afterSaleImpactAmount ELSE 0 END), 2)
|
||
FROM scored
|
||
UNION ALL
|
||
SELECT
|
||
'anomaly',
|
||
'退款影响',
|
||
'',
|
||
SUM(CASE WHEN refundImpactAmount > 0 THEN 1 ELSE 0 END),
|
||
ROUND(SUM(CASE WHEN refundImpactAmount > 0 THEN performanceAmount ELSE 0 END), 2),
|
||
ROUND(CASE WHEN SUM(CASE WHEN refundImpactAmount > 0 THEN performanceAmount ELSE 0 END) > 0 THEN SUM(CASE WHEN refundImpactAmount > 0 THEN feeAmount ELSE 0 END) / SUM(CASE WHEN refundImpactAmount > 0 THEN performanceAmount ELSE 0 END) ELSE 0 END, 6),
|
||
ROUND(CASE WHEN SUM(CASE WHEN refundImpactAmount > 0 THEN performanceAmount ELSE 0 END) > 0 THEN SUM(CASE WHEN refundImpactAmount > 0 THEN afterSaleAmount ELSE 0 END) / SUM(CASE WHEN refundImpactAmount > 0 THEN performanceAmount ELSE 0 END) ELSE 0 END, 6),
|
||
SUM(CASE WHEN refundImpactAmount > 0 AND highFeeFlag = 1 THEN 1 ELSE 0 END),
|
||
SUM(CASE WHEN refundImpactAmount > 0 AND afterSaleFlag = 1 THEN 1 ELSE 0 END),
|
||
ROUND(SUM(refundImpactAmount), 2)
|
||
FROM scored`,
|
||
base.params,
|
||
CHANNEL_ID_ANALYSIS_DASHBOARD_QUERY_OPTIONS
|
||
);
|
||
|
||
const groups = {
|
||
channel: [],
|
||
shop: [],
|
||
headTail: [],
|
||
anomaly: [],
|
||
};
|
||
|
||
(rows || []).forEach((row) => {
|
||
const normalized = normalizeChannelIdDimensionRow(row);
|
||
if (!groups[normalized.dimensionKey]) return;
|
||
groups[normalized.dimensionKey].push(normalized);
|
||
});
|
||
|
||
Object.keys(groups).forEach((key) => {
|
||
groups[key] = groups[key]
|
||
.sort((left, right) => (
|
||
(right.riskAmount - left.riskAmount)
|
||
|| (right.performanceAmount - left.performanceAmount)
|
||
|| (right.itemCount - left.itemCount)
|
||
))
|
||
.slice(0, key === 'shop' ? 15 : 12);
|
||
});
|
||
|
||
return groups;
|
||
}
|
||
|
||
function buildChannelIdRiskActionText(row = {}) {
|
||
const feeOverrun = Number(row.feeOverrunAmount || 0);
|
||
const afterSaleRate = Number(row.idAfterSaleRate || 0);
|
||
const refundImpact = Number(row.refundImpactAmount || 0);
|
||
if (feeOverrun > 0 && afterSaleRate > 0.05) return '费比售后双异常,优先复盘';
|
||
if (feeOverrun > 0) return '费比超标,检查投放与成本';
|
||
if (afterSaleRate > 0.05) return '售后占比异常,排查商品/履约';
|
||
if (refundImpact > 0) return '退款影响偏高,核对成交与退款';
|
||
return '持续观察';
|
||
}
|
||
|
||
async function queryChannelIdDashboardRiskRows(base, limit) {
|
||
const rows = await channelReadDb.query(
|
||
`${getChannelIdDashboardScoredSql(base.sql)}
|
||
SELECT
|
||
channelName,
|
||
shopName,
|
||
itemId,
|
||
primarySkuCode,
|
||
primarySpu,
|
||
saleStatus,
|
||
purchaserName,
|
||
performanceAmount,
|
||
feeRatio,
|
||
idAfterSaleRate,
|
||
performanceRefundRate,
|
||
targetFeeRatio,
|
||
ROUND(feeOverrunAmount, 2) AS feeOverrunAmount,
|
||
ROUND(afterSaleImpactAmount, 2) AS afterSaleImpactAmount,
|
||
ROUND(refundImpactAmount, 2) AS refundImpactAmount,
|
||
ROUND(riskAmount, 2) AS riskAmount
|
||
FROM scored
|
||
WHERE riskAmount > 0
|
||
OR highFeeFlag = 1
|
||
OR afterSaleFlag = 1
|
||
ORDER BY riskAmount DESC, performanceAmount DESC, itemId ASC
|
||
LIMIT ?`,
|
||
[...base.params, limit],
|
||
CHANNEL_ID_ANALYSIS_DASHBOARD_QUERY_OPTIONS
|
||
);
|
||
|
||
return (rows || []).map((row, index) => ({
|
||
rank: index + 1,
|
||
channelName: row.channelName || '未归类',
|
||
shopName: row.shopName || '--',
|
||
itemId: row.itemId || '',
|
||
primarySkuCode: row.primarySkuCode || '',
|
||
primarySpu: row.primarySpu || '',
|
||
saleStatus: row.saleStatus || '未配置',
|
||
purchaserName: row.purchaserName || '未配置',
|
||
performanceAmount: toNumber(row.performanceAmount, 2),
|
||
feeRatio: toNumber(row.feeRatio, 6),
|
||
idAfterSaleRate: toNumber(row.idAfterSaleRate, 6),
|
||
performanceRefundRate: toNumber(row.performanceRefundRate, 6),
|
||
targetFeeRatio: toNumber(row.targetFeeRatio, 6),
|
||
feeOverrunAmount: toNumber(row.feeOverrunAmount, 2),
|
||
afterSaleImpactAmount: toNumber(row.afterSaleImpactAmount, 2),
|
||
refundImpactAmount: toNumber(row.refundImpactAmount, 2),
|
||
riskAmount: toNumber(row.riskAmount, 2),
|
||
actionText: buildChannelIdRiskActionText(row),
|
||
}));
|
||
}
|
||
|
||
function buildChannelIdDashboardMetricCards(summary, dateRange) {
|
||
return [
|
||
{
|
||
key: 'performanceAmount',
|
||
label: '区间业绩',
|
||
value: summary.performanceAmount,
|
||
format: 'currency',
|
||
desc: `${dateRange.startDate} 至 ${dateRange.endDate}`,
|
||
},
|
||
{
|
||
key: 'idCount',
|
||
label: '覆盖ID',
|
||
value: summary.idCount,
|
||
suffix: '个',
|
||
desc: `${summary.channelCount.toLocaleString('zh-CN')} 个渠道 / ${summary.shopCount.toLocaleString('zh-CN')} 个店铺`,
|
||
},
|
||
{
|
||
key: 'feeRatio',
|
||
label: '综合费比',
|
||
value: summary.feeRatio,
|
||
format: 'percent',
|
||
desc: `费比超标 ${summary.highFeeIdCount.toLocaleString('zh-CN')} 个ID`,
|
||
},
|
||
{
|
||
key: 'idAfterSaleRate',
|
||
label: 'ID售后占比',
|
||
value: summary.idAfterSaleRate,
|
||
format: 'percent',
|
||
desc: `售后异常 ${summary.afterSaleAnomalyIdCount.toLocaleString('zh-CN')} 个ID`,
|
||
},
|
||
{
|
||
key: 'riskAmount',
|
||
label: '预计风险金额',
|
||
value: summary.riskAmount,
|
||
format: 'currency',
|
||
desc: '费比超标 + 售后 + 退款影响',
|
||
},
|
||
{
|
||
key: 'grossMarginRate',
|
||
label: '毛利率',
|
||
value: summary.grossMarginRate,
|
||
format: 'percent',
|
||
desc: `成交件数 ${summary.goodsCount.toLocaleString('zh-CN')}`,
|
||
},
|
||
];
|
||
}
|
||
|
||
async function loadChannelIdAnalysisDashboardFastPayload(options = {}, dateRange, { skipCoverage = false } = {}) {
|
||
const limit = normalizeChannelIdDashboardLimit(options.limit);
|
||
const coverage = skipCoverage
|
||
? {
|
||
refreshedRanges: [],
|
||
usedStaleData: false,
|
||
coverageMode: 'snapshot-skip',
|
||
}
|
||
: await resolveChannelIdReadCoverage(
|
||
dateRange.startDate,
|
||
dateRange.endDate,
|
||
dateRange.latestBizDate,
|
||
{
|
||
allowStale: true,
|
||
label: 'ID数据分析仪表盘快速回退',
|
||
}
|
||
);
|
||
const refreshedRanges = coverage.refreshedRanges;
|
||
await ensurePlanningChannelIdDailyReadSchema();
|
||
const base = buildChannelIdDashboardBaseSql({
|
||
startDate: dateRange.startDate,
|
||
endDate: dateRange.endDate,
|
||
keyword: options.keyword,
|
||
});
|
||
|
||
const [summaryResult, trendRowsResult, riskRowsResult] = await Promise.allSettled([
|
||
queryChannelIdDashboardSummary(base),
|
||
queryChannelIdDashboardTrend({
|
||
startDate: dateRange.startDate,
|
||
endDate: dateRange.endDate,
|
||
keyword: options.keyword,
|
||
}),
|
||
queryChannelIdDashboardRiskRows(base, limit),
|
||
]);
|
||
const summary = summaryResult.status === 'fulfilled'
|
||
? summaryResult.value
|
||
: normalizeChannelIdDashboardSummary();
|
||
const trendRows = trendRowsResult.status === 'fulfilled' ? trendRowsResult.value : [];
|
||
const riskRows = riskRowsResult.status === 'fulfilled' ? riskRowsResult.value : [];
|
||
const partialFailures = [
|
||
summaryResult,
|
||
trendRowsResult,
|
||
riskRowsResult,
|
||
]
|
||
.filter((item) => item.status === 'rejected')
|
||
.map((item) => item.reason?.message || String(item.reason || 'unknown'));
|
||
if (partialFailures.length) {
|
||
console.warn('[Channel] ID数据分析仪表盘快速回退部分查询失败:', partialFailures.join(' | '));
|
||
}
|
||
|
||
return {
|
||
dateRange,
|
||
summary,
|
||
metricCards: buildChannelIdDashboardMetricCards(summary, dateRange),
|
||
trendRows,
|
||
dimensionGroups: {
|
||
channel: [],
|
||
shop: [],
|
||
headTail: [],
|
||
anomaly: [],
|
||
},
|
||
riskRows,
|
||
meta: {
|
||
source: `${CHANNEL_ID_READ_TABLE}-fast`,
|
||
refreshedRanges,
|
||
readStoreCoverageMode: coverage.coverageMode,
|
||
staleRequestedWindow: coverage.usedStaleData,
|
||
keyword: normalizeText(options.keyword),
|
||
limit,
|
||
partialData: true,
|
||
partialFailureMessages: partialFailures,
|
||
riskRule: '风险金额 = 费比超标金额 + 正向售后金额 + 退款影响金额',
|
||
enrichmentMode: 'fast',
|
||
},
|
||
};
|
||
}
|
||
|
||
async function loadChannelIdAnalysisDashboardPayload(options = {}, dateRange, { skipCoverage = false } = {}) {
|
||
const limit = normalizeChannelIdDashboardLimit(options.limit);
|
||
const coverage = skipCoverage
|
||
? {
|
||
refreshedRanges: [],
|
||
usedStaleData: false,
|
||
coverageMode: 'snapshot-skip',
|
||
}
|
||
: await resolveChannelIdReadCoverage(
|
||
dateRange.startDate,
|
||
dateRange.endDate,
|
||
dateRange.latestBizDate,
|
||
{
|
||
allowStale: true,
|
||
label: 'ID数据分析仪表盘',
|
||
}
|
||
);
|
||
const refreshedRanges = coverage.refreshedRanges;
|
||
await ensurePlanningChannelIdDailyReadSchema();
|
||
const base = buildChannelIdDashboardBaseSql({
|
||
startDate: dateRange.startDate,
|
||
endDate: dateRange.endDate,
|
||
keyword: options.keyword,
|
||
});
|
||
|
||
const [
|
||
summaryResult,
|
||
trendRowsResult,
|
||
dimensionGroupsResult,
|
||
riskRowsResult,
|
||
] = await Promise.allSettled([
|
||
queryChannelIdDashboardSummary(base),
|
||
queryChannelIdDashboardTrend({
|
||
startDate: dateRange.startDate,
|
||
endDate: dateRange.endDate,
|
||
keyword: options.keyword,
|
||
}),
|
||
queryChannelIdDashboardDimensionGroups(base),
|
||
queryChannelIdDashboardRiskRows(base, limit),
|
||
]);
|
||
|
||
const summary = summaryResult.status === 'fulfilled'
|
||
? summaryResult.value
|
||
: normalizeChannelIdDashboardSummary();
|
||
const trendRows = trendRowsResult.status === 'fulfilled' ? trendRowsResult.value : [];
|
||
const dimensionGroups = dimensionGroupsResult.status === 'fulfilled'
|
||
? dimensionGroupsResult.value
|
||
: {
|
||
channel: [],
|
||
shop: [],
|
||
headTail: [],
|
||
anomaly: [],
|
||
};
|
||
const riskRows = riskRowsResult.status === 'fulfilled' ? riskRowsResult.value : [];
|
||
const partialFailures = [
|
||
summaryResult,
|
||
trendRowsResult,
|
||
dimensionGroupsResult,
|
||
riskRowsResult,
|
||
]
|
||
.filter((item) => item.status === 'rejected')
|
||
.map((item) => item.reason?.message || String(item.reason || 'unknown'));
|
||
if (partialFailures.length) {
|
||
console.warn('[Channel] ID数据分析仪表盘部分查询降级返回:', partialFailures.join(' | '));
|
||
}
|
||
|
||
return {
|
||
dateRange,
|
||
summary,
|
||
metricCards: buildChannelIdDashboardMetricCards(summary, dateRange),
|
||
trendRows,
|
||
dimensionGroups,
|
||
riskRows,
|
||
meta: {
|
||
source: CHANNEL_ID_READ_TABLE,
|
||
refreshedRanges,
|
||
readStoreCoverageMode: coverage.coverageMode,
|
||
staleRequestedWindow: coverage.usedStaleData,
|
||
keyword: normalizeText(options.keyword),
|
||
limit,
|
||
partialData: partialFailures.length > 0,
|
||
partialFailureMessages: partialFailures,
|
||
riskRule: '风险金额 = 费比超标金额 + 正向售后金额 + 退款影响金额',
|
||
},
|
||
};
|
||
}
|
||
|
||
async function buildChannelIdDefaultFastFallback(config) {
|
||
try {
|
||
const currentPayload = await buildChannelIdDefaultSnapshotPayload(config, {
|
||
skipCoverage: true,
|
||
fastMode: true,
|
||
});
|
||
|
||
const hasCurrentSignal = Number(currentPayload?.total || 0) > 0
|
||
|| Number(currentPayload?.rows?.length || 0) > 0;
|
||
if (hasCurrentSignal) {
|
||
return withSnapshotResponseMeta(currentPayload, {
|
||
snapshotMode: true,
|
||
defaultWindow: true,
|
||
snapshotFallback: 'current-range-fast',
|
||
snapshotRequestedDateRange: {
|
||
startDate: config.startDate,
|
||
endDate: config.endDate,
|
||
latestBizDate: config.latestBizDate,
|
||
},
|
||
snapshotSourceDateRange: getSnapshotDateRange(currentPayload),
|
||
staleRequestedWindow: false,
|
||
});
|
||
}
|
||
} catch (error) {
|
||
console.warn('[Channel] 生成ID数据分析当前窗口快速回退失败:', error.message || error);
|
||
}
|
||
|
||
const latestSyncedDate = await resolveLatestSyncedChannelIdDate(config.startDate, config.endDate);
|
||
if (!latestSyncedDate || latestSyncedDate === config.endDate) {
|
||
return null;
|
||
}
|
||
|
||
const fallbackConfig = buildChannelIdWindowConfigFromEndDate(latestSyncedDate, {
|
||
page: config.page,
|
||
pageSize: config.pageSize,
|
||
}).list;
|
||
const payload = await buildChannelIdDefaultSnapshotPayload(fallbackConfig, {
|
||
skipCoverage: true,
|
||
fastMode: true,
|
||
});
|
||
|
||
return withSnapshotResponseMeta(payload, {
|
||
snapshotMode: true,
|
||
defaultWindow: true,
|
||
snapshotFallback: 'latest-synced-fast',
|
||
snapshotRequestedDateRange: {
|
||
startDate: config.startDate,
|
||
endDate: config.endDate,
|
||
latestBizDate: config.latestBizDate,
|
||
},
|
||
snapshotSourceDateRange: getSnapshotDateRange(payload),
|
||
staleRequestedWindow: true,
|
||
});
|
||
}
|
||
|
||
async function buildChannelIdDashboardFastFallback(config) {
|
||
try {
|
||
const currentPayload = await loadChannelIdAnalysisDashboardFastPayload({
|
||
keyword: config.keyword,
|
||
limit: config.limit,
|
||
}, {
|
||
startDate: config.startDate,
|
||
endDate: config.endDate,
|
||
latestBizDate: config.latestBizDate,
|
||
lookbackStartDate: config.lookbackStartDate,
|
||
}, {
|
||
skipCoverage: true,
|
||
});
|
||
|
||
const hasCurrentSignal = Number(currentPayload?.summary?.idCount || 0) > 0
|
||
|| Number(currentPayload?.riskRows?.length || 0) > 0
|
||
|| Number(currentPayload?.trendRows?.length || 0) > 0;
|
||
if (hasCurrentSignal) {
|
||
return withSnapshotResponseMeta(currentPayload, {
|
||
snapshotMode: true,
|
||
defaultWindow: true,
|
||
snapshotFallback: 'current-range-fast',
|
||
snapshotRequestedDateRange: {
|
||
startDate: config.startDate,
|
||
endDate: config.endDate,
|
||
latestBizDate: config.latestBizDate,
|
||
},
|
||
snapshotSourceDateRange: getSnapshotDateRange(currentPayload),
|
||
staleRequestedWindow: false,
|
||
});
|
||
}
|
||
} catch (error) {
|
||
console.warn('[Channel] 生成ID数据分析仪表盘当前窗口快速回退失败:', error.message || error);
|
||
}
|
||
|
||
const latestSyncedDate = await resolveLatestSyncedChannelIdDate(config.startDate, config.endDate);
|
||
if (!latestSyncedDate || latestSyncedDate === config.endDate) {
|
||
return null;
|
||
}
|
||
|
||
const fallbackConfig = buildChannelIdWindowConfigFromEndDate(latestSyncedDate, {
|
||
limit: config.limit,
|
||
}).dashboard;
|
||
const payload = await loadChannelIdAnalysisDashboardFastPayload({
|
||
keyword: fallbackConfig.keyword,
|
||
limit: fallbackConfig.limit,
|
||
}, {
|
||
startDate: fallbackConfig.startDate,
|
||
endDate: fallbackConfig.endDate,
|
||
latestBizDate: fallbackConfig.latestBizDate,
|
||
lookbackStartDate: fallbackConfig.lookbackStartDate,
|
||
}, {
|
||
skipCoverage: true,
|
||
});
|
||
|
||
return withSnapshotResponseMeta(payload, {
|
||
snapshotMode: true,
|
||
defaultWindow: true,
|
||
snapshotFallback: 'latest-synced-fast',
|
||
snapshotRequestedDateRange: {
|
||
startDate: config.startDate,
|
||
endDate: config.endDate,
|
||
latestBizDate: config.latestBizDate,
|
||
},
|
||
snapshotSourceDateRange: getSnapshotDateRange(payload),
|
||
staleRequestedWindow: true,
|
||
});
|
||
}
|
||
|
||
async function buildChannelStockMonitorDashboardFastFallback(config) {
|
||
const stockMonitorMonths = buildStockMonitorMonthBuckets(config.endDate);
|
||
try {
|
||
const { sql, params } = buildReadStoreSpuFilteredSql({
|
||
startDate: config.startDate,
|
||
endDate: config.endDate,
|
||
keyword: '',
|
||
statuses: config.statuses,
|
||
purchasers: config.purchasers,
|
||
minAfterSaleAmountRate: config.minAfterSaleAmountRate,
|
||
maxAfterSaleAmountRate: config.maxAfterSaleAmountRate,
|
||
});
|
||
await ensurePlanningChannelDailyReadSchema();
|
||
const sampleBaseRows = await channelReadDb.query(
|
||
`SELECT * FROM (${sql}) base ORDER BY base.shipCount DESC, base.spu ASC LIMIT ? OFFSET 0`,
|
||
[...params, CHANNEL_PREWARM_PAGE_SIZE]
|
||
);
|
||
const normalizedRows = normalizeReadStoreSpuRows(sampleBaseRows || []);
|
||
const sampleResult = await enrichStockMonitorDashboardRowsDetailed(normalizedRows, stockMonitorMonths);
|
||
const sampleRows = sampleResult.rows;
|
||
const sampleFailures = (sampleResult.diagnostics?.failureMessages || []).slice(0, 5);
|
||
if (sampleRows.length > 0) {
|
||
const rangeDayCount = Math.max(dayjs(config.endDate).diff(dayjs(config.startDate), 'day') + 1, 1);
|
||
const dashboardRows = sampleRows.map((row) => {
|
||
const rangeSalesQty = Math.max(0, toNumber(row.shipCount, 0));
|
||
const estimatedImpactQty = calculateStockMonitorEstimatedImpactQty(row, rangeDayCount);
|
||
const stockOutFlag = Math.max(0, toNumber(row.stockMonitorOutOfStock, 0)) > 0;
|
||
const purchase = row.nonSpuOwner || row.purchaserName || '未配置';
|
||
const category = row.categoryName || row.material || '未配置';
|
||
|
||
return {
|
||
...row,
|
||
skuName: row.skuName || row.spu || row.skuCode,
|
||
purchase,
|
||
category,
|
||
warehouseName: '未配置',
|
||
skuStatus: row.saleStatus || '未配置',
|
||
callType: '未配置',
|
||
rangeSalesQty,
|
||
estimatedImpactQty,
|
||
stockOutFlag,
|
||
actionText: buildStockMonitorActionText(row, estimatedImpactQty),
|
||
jsStock: row.stockMonitorStock,
|
||
jsUseDays: row.stockMonitorStockUseDays,
|
||
produceCycle: row.stockMonitorProduceCycle,
|
||
};
|
||
});
|
||
const dimensionGroups = buildStockMonitorDimensionGroups(dashboardRows);
|
||
const monthlyRows = buildStockMonitorMonthlyRows(dashboardRows, stockMonitorMonths);
|
||
const impactedRows = dashboardRows
|
||
.filter((row) => row.estimatedImpactQty > 0 || row.stockMonitorWarningRequired || row.stockOutFlag)
|
||
.sort((left, right) => (
|
||
right.estimatedImpactQty - left.estimatedImpactQty
|
||
|| Number(right.stockOutFlag) - Number(left.stockOutFlag)
|
||
|| Number(right.stockMonitorWarningRequired) - Number(left.stockMonitorWarningRequired)
|
||
|| right.rangeSalesQty - left.rangeSalesQty
|
||
|| left.skuCode.localeCompare(right.skuCode, 'en')
|
||
));
|
||
const impactRows = impactedRows.slice(0, safePageSize(config.limit, 50)).map((row, index) => ({
|
||
rank: index + 1,
|
||
skuCode: row.skuCode,
|
||
skuName: row.skuName || row.spu || row.skuCode,
|
||
factoryName: row.factoryName || '未配置',
|
||
purchase: row.purchase || '未配置',
|
||
category: row.category || '未配置',
|
||
skuStatus: row.skuStatus || '未配置',
|
||
jsStock: row.jsStock,
|
||
jsUseDays: row.jsUseDays,
|
||
produceCycle: row.produceCycle,
|
||
rangeSalesQty: row.rangeSalesQty,
|
||
estimatedImpactQty: row.estimatedImpactQty,
|
||
actionText: row.actionText,
|
||
}));
|
||
let trendRows = buildStockMonitorTrendRows(dashboardRows, [], config);
|
||
try {
|
||
const dailySalesRows = await fetchChannelReadSkuDailySales(
|
||
dashboardRows.map((row) => row.skuCode),
|
||
config.startDate,
|
||
config.endDate
|
||
);
|
||
trendRows = buildStockMonitorTrendRows(dashboardRows, dailySalesRows, config);
|
||
} catch (error) {
|
||
console.warn('[Channel] 生成库存销售影响仪表盘样本趋势失败:', error.message || error);
|
||
}
|
||
const warningSkuCount = dashboardRows.filter((row) => row.stockMonitorWarningRequired).length;
|
||
const stockOutSkuCount = dashboardRows.filter((row) => row.stockOutFlag).length;
|
||
const riskSkuCount = dashboardRows.filter((row) => (
|
||
row.stockMonitorWarningRequired || row.stockOutFlag || row.estimatedImpactQty > 0
|
||
)).length;
|
||
const estimatedImpactQtyTotal = toNumber(
|
||
dashboardRows.reduce((total, row) => total + toNumber(row.estimatedImpactQty, 0), 0),
|
||
0
|
||
);
|
||
const factoryCount = uniqueText(
|
||
dashboardRows
|
||
.filter((row) => row.estimatedImpactQty > 0 || row.stockMonitorWarningRequired || row.stockOutFlag)
|
||
.map((row) => row.factoryName)
|
||
).length;
|
||
const latestDate = dashboardRows
|
||
.map((row) => normalizeDateValue(row.stockMonitorLatestDate))
|
||
.filter(Boolean)
|
||
.sort()
|
||
.slice(-1)[0] || config.latestBizDate;
|
||
const payload = {
|
||
metricCards: [
|
||
{ key: 'riskSkuCount', label: '风险SKU', value: riskSkuCount, precision: 0, desc: '库存低于生产周期或已缺货的 SKU 数' },
|
||
{ key: 'warningSkuCount', label: '需提醒SKU', value: warningSkuCount, precision: 0, desc: '当前库存低于生产周期的 SKU 数' },
|
||
{ key: 'stockOutSkuCount', label: '缺货SKU', value: stockOutSkuCount, precision: 0, desc: '当前缺货或断货标记的 SKU 数' },
|
||
{ key: 'estimatedImpactQty', label: '预计影响件数', value: estimatedImpactQtyTotal, precision: 0, suffix: '件', desc: '按缺口与近均销估算的影响件数' },
|
||
{ key: 'factoryCount', label: '涉及工厂', value: factoryCount, precision: 0, desc: '受当前库存风险影响的供应工厂数' },
|
||
],
|
||
trendRows,
|
||
monthlyRows,
|
||
impactRows,
|
||
dimensionGroups,
|
||
dateRange: {
|
||
startDate: config.startDate,
|
||
endDate: config.endDate,
|
||
latestDate,
|
||
latestBizDate: config.latestBizDate,
|
||
},
|
||
meta: {
|
||
source: 'read-store-spu-stock-monitor-dashboard-fast-sample',
|
||
refreshedRanges: [],
|
||
readStoreCoverageMode: 'fast-sample',
|
||
staleRequestedWindow: false,
|
||
partialData: true,
|
||
partialFailureMessages: sampleFailures,
|
||
sampleBased: true,
|
||
sampleRowCount: dashboardRows.length,
|
||
totalSkuCount: dashboardRows.length,
|
||
candidateSkuCount: dashboardRows.length,
|
||
stockMonitorSourceTable: STOCK_MONITOR_BACKUP_TABLE,
|
||
stockMonitorMonths,
|
||
stockMonitorWarningRule: 'jsStock < produceCycle',
|
||
stockMonitorWarningText: STOCK_MONITOR_WARNING_TEXT,
|
||
},
|
||
};
|
||
|
||
return withSnapshotResponseMeta(payload, {
|
||
snapshotMode: true,
|
||
defaultWindow: true,
|
||
snapshotFallback: 'spu-sample-fast',
|
||
snapshotRequestedDateRange: {
|
||
startDate: config.startDate,
|
||
endDate: config.endDate,
|
||
latestBizDate: config.latestBizDate,
|
||
},
|
||
snapshotSourceDateRange: getSnapshotDateRange(payload),
|
||
staleRequestedWindow: false,
|
||
});
|
||
}
|
||
} catch (error) {
|
||
console.warn('[Channel] 生成库存销售影响仪表盘样本回退失败:', error.message || error);
|
||
}
|
||
|
||
const latestSyncedDate = await resolveLatestSyncedChannelDate(config.startDate, config.endDate);
|
||
if (!latestSyncedDate || latestSyncedDate === config.endDate) {
|
||
return null;
|
||
}
|
||
|
||
const fallbackConfig = buildChannelStockMonitorDashboardWindowConfigFromEndDate(latestSyncedDate, {
|
||
limit: config.limit,
|
||
});
|
||
|
||
try {
|
||
const payload = await loadChannelStockMonitorDashboard({
|
||
keyword: fallbackConfig.keyword,
|
||
limit: fallbackConfig.limit,
|
||
statuses: fallbackConfig.statuses,
|
||
purchasers: fallbackConfig.purchasers,
|
||
minAfterSaleAmountRate: fallbackConfig.minAfterSaleAmountRate,
|
||
maxAfterSaleAmountRate: fallbackConfig.maxAfterSaleAmountRate,
|
||
}, {
|
||
startDate: fallbackConfig.startDate,
|
||
endDate: fallbackConfig.endDate,
|
||
latestBizDate: fallbackConfig.latestBizDate,
|
||
lookbackStartDate: fallbackConfig.lookbackStartDate,
|
||
}, {
|
||
skipCoverage: true,
|
||
});
|
||
|
||
return withSnapshotResponseMeta(payload, {
|
||
snapshotMode: true,
|
||
defaultWindow: true,
|
||
snapshotFallback: 'latest-synced-fast',
|
||
snapshotRequestedDateRange: {
|
||
startDate: config.startDate,
|
||
endDate: config.endDate,
|
||
latestBizDate: config.latestBizDate,
|
||
},
|
||
snapshotSourceDateRange: getSnapshotDateRange(payload),
|
||
staleRequestedWindow: true,
|
||
});
|
||
} catch (error) {
|
||
console.warn('[Channel] 生成库存销售影响仪表盘快速回退失败:', error.message || error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function buildChannelIdDashboardDefaultSnapshotPayload(config, { skipCoverage = false } = {}) {
|
||
const dateRange = {
|
||
startDate: config.startDate,
|
||
endDate: config.endDate,
|
||
latestBizDate: config.latestBizDate,
|
||
lookbackStartDate: config.lookbackStartDate || config.startDate,
|
||
};
|
||
const data = await loadChannelIdAnalysisDashboardPayload({
|
||
keyword: config.keyword,
|
||
limit: config.limit,
|
||
}, dateRange, { skipCoverage });
|
||
|
||
return withSnapshotResponseMeta(data, {
|
||
snapshotMode: true,
|
||
defaultWindow: true,
|
||
});
|
||
}
|
||
|
||
async function buildChannelStockMonitorDashboardDefaultSnapshotPayload(config, { skipCoverage = false } = {}) {
|
||
const dateRange = {
|
||
startDate: config.startDate,
|
||
endDate: config.endDate,
|
||
latestBizDate: config.latestBizDate,
|
||
lookbackStartDate: config.lookbackStartDate || config.startDate,
|
||
};
|
||
const data = await loadChannelStockMonitorDashboard({
|
||
keyword: config.keyword,
|
||
limit: config.limit,
|
||
statuses: config.statuses,
|
||
purchasers: config.purchasers,
|
||
minAfterSaleAmountRate: config.minAfterSaleAmountRate,
|
||
maxAfterSaleAmountRate: config.maxAfterSaleAmountRate,
|
||
}, dateRange, { skipCoverage });
|
||
|
||
return withSnapshotResponseMeta(data, {
|
||
snapshotMode: true,
|
||
defaultWindow: true,
|
||
});
|
||
}
|
||
|
||
async function refreshChannelIdDashboardDefaultSnapshot(config, { skipCoverage = false } = {}) {
|
||
const cacheKey = buildChannelIdDashboardDefaultSnapshotKey(config);
|
||
const rollingKey = buildChannelIdAnalysisDashboardRollingSnapshotKey(config);
|
||
return refreshNamedSnapshot({
|
||
cacheKey,
|
||
rollingKey,
|
||
label: `ID仪表盘默认快照 ${config.startDate}~${config.endDate} limit=${config.limit}`,
|
||
buildValue: () => buildChannelIdDashboardDefaultSnapshotPayload(config, { skipCoverage }),
|
||
});
|
||
}
|
||
|
||
async function getChannelIdDashboardDefaultSnapshot(config) {
|
||
const cacheKey = buildChannelIdDashboardDefaultSnapshotKey(config);
|
||
const rollingKey = buildChannelIdAnalysisDashboardRollingSnapshotKey(config);
|
||
return getNamedSnapshot({
|
||
cacheKey,
|
||
rollingKey,
|
||
refreshLogLabel: `ID仪表盘默认快照 ${config.startDate}~${config.endDate} limit=${config.limit}`,
|
||
requestedRange: {
|
||
startDate: config.startDate,
|
||
endDate: config.endDate,
|
||
latestBizDate: config.latestBizDate,
|
||
},
|
||
refreshSnapshot: () => refreshChannelIdDashboardDefaultSnapshot(config),
|
||
fallbackLoader: () => buildChannelIdDashboardFastFallback(config),
|
||
});
|
||
}
|
||
|
||
async function refreshChannelStockMonitorDashboardDefaultSnapshot(config, { skipCoverage = false } = {}) {
|
||
const cacheKey = buildChannelStockMonitorDashboardDefaultSnapshotKey(config);
|
||
const rollingKey = buildChannelStockMonitorDashboardRollingSnapshotKey(config);
|
||
return refreshNamedSnapshot({
|
||
cacheKey,
|
||
rollingKey,
|
||
label: `库存销售影响仪表盘默认快照 ${config.startDate}~${config.endDate} limit=${config.limit}`,
|
||
buildValue: () => buildChannelStockMonitorDashboardDefaultSnapshotPayload(config, { skipCoverage }),
|
||
});
|
||
}
|
||
|
||
async function getChannelStockMonitorDashboardDefaultSnapshot(config) {
|
||
const cacheKey = buildChannelStockMonitorDashboardDefaultSnapshotKey(config);
|
||
const rollingKey = buildChannelStockMonitorDashboardRollingSnapshotKey(config);
|
||
return getNamedSnapshot({
|
||
cacheKey,
|
||
rollingKey,
|
||
refreshLogLabel: `库存销售影响仪表盘默认快照 ${config.startDate}~${config.endDate} limit=${config.limit}`,
|
||
requestedRange: {
|
||
startDate: config.startDate,
|
||
endDate: config.endDate,
|
||
latestBizDate: config.latestBizDate,
|
||
},
|
||
refreshSnapshot: () => refreshChannelStockMonitorDashboardDefaultSnapshot(config),
|
||
fallbackLoader: () => buildChannelStockMonitorDashboardFastFallback(config),
|
||
});
|
||
}
|
||
|
||
async function getChannelIdAnalysisDashboard(options = {}) {
|
||
const dateRange = await resolveDateRange(options.startDate, options.endDate);
|
||
const limit = normalizeChannelIdDashboardLimit(options.limit);
|
||
if (isDefaultChannelIdDashboardQuery(options, dateRange, limit)) {
|
||
const snapshotConfig = buildChannelIdDashboardDefaultSnapshotConfig(dateRange.latestBizDate, limit);
|
||
const snapshot = await getChannelIdDashboardDefaultSnapshot(snapshotConfig);
|
||
|
||
return {
|
||
...snapshot.value,
|
||
meta: {
|
||
...(snapshot.value?.meta || {}),
|
||
cacheHit: snapshot.hit !== 'miss-default',
|
||
cacheLayer: snapshot.hit,
|
||
},
|
||
};
|
||
}
|
||
|
||
const cacheKey = buildChannelIdAnalysisDashboardCacheKey({
|
||
...dateRange,
|
||
keyword: options.keyword,
|
||
limit,
|
||
});
|
||
|
||
const cached = await dataPageCache.getOrSetCachedPayload({
|
||
key: cacheKey,
|
||
memoryTtlSeconds: CHANNEL_ID_ANALYSIS_DASHBOARD_MEMORY_TTL_SECONDS,
|
||
redisTtlSeconds: CHANNEL_ID_ANALYSIS_DASHBOARD_CACHE_TTL_SECONDS,
|
||
label: `channel:id-analysis-dashboard ${dateRange.startDate}~${dateRange.endDate}`,
|
||
loader: () => loadChannelIdAnalysisDashboardPayload({ ...options, limit }, dateRange),
|
||
shouldCache: (payload) => Boolean(payload?.dateRange?.endDate),
|
||
});
|
||
|
||
return {
|
||
...cached.value,
|
||
meta: {
|
||
...(cached.value?.meta || {}),
|
||
cacheHit: cached.hit !== 'miss',
|
||
cacheLayer: cached.hit,
|
||
},
|
||
};
|
||
}
|
||
|
||
async function prewarmChannelStockMonitorDashboardDefaultSnapshot({ forceRefresh = false, skipCoverage = false } = {}) {
|
||
const latestBizDate = await getLatestChannelBizDate();
|
||
const config = buildChannelStockMonitorDashboardDefaultSnapshotConfig(latestBizDate, 50);
|
||
if (!skipCoverage) {
|
||
const coverageStartDate = getCoverageStartDate(config);
|
||
await ensureChannelReadCoverage(coverageStartDate, config.endDate, config.latestBizDate);
|
||
}
|
||
|
||
await refreshChannelStockMonitorDashboardDefaultSnapshot(config, { skipCoverage: true });
|
||
if (!forceRefresh) {
|
||
await getChannelStockMonitorDashboardDefaultSnapshot(config);
|
||
}
|
||
|
||
return config;
|
||
}
|
||
|
||
async function fetchRollingSalesFromReadStore(rows, lookbackStartDate, endDate) {
|
||
if (!rows.length) return new Map();
|
||
|
||
const pairConditions = [];
|
||
const params = [lookbackStartDate, endDate];
|
||
rows.forEach((row) => {
|
||
pairConditions.push('(shop_key = ? AND sku_code = ?)');
|
||
params.push(row.shopKey, row.skuCode);
|
||
});
|
||
|
||
await ensurePlanningChannelDailyReadSchema();
|
||
const result = await channelReadDb.query(
|
||
`SELECT
|
||
shop_key AS shopKey,
|
||
sku_code AS skuCode,
|
||
ROUND(SUM(ship_count), 2) AS shipCount30d
|
||
FROM ${CHANNEL_READ_TABLE}
|
||
WHERE stat_date BETWEEN ? AND ?
|
||
AND (${pairConditions.join(' OR ')})
|
||
GROUP BY shop_key, sku_code`,
|
||
params
|
||
);
|
||
|
||
return new Map(result.map((row) => [`${row.shopKey}__${row.skuCode}`, toNumber(row.shipCount30d)]));
|
||
}
|
||
|
||
async function fetchInventoryForRows(rows) {
|
||
const skuCodes = [...new Set(rows.map((row) => row.skuCode).filter(Boolean))];
|
||
if (!skuCodes.length) return new Map();
|
||
|
||
const params = {};
|
||
const placeholders = skuCodes.map((skuCode, index) => {
|
||
const key = `skuCode${index}`;
|
||
params[key] = skuCode;
|
||
return `@${key}`;
|
||
});
|
||
|
||
const result = await cloudDb.query(
|
||
`WITH inventory_latest AS (
|
||
SELECT
|
||
i.sku_id,
|
||
COALESCE(i.qty, 0) AS qty,
|
||
ROW_NUMBER() OVER (
|
||
PARTITION BY i.sku_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
|
||
WHERE i.sku_id IN (${placeholders.join(', ')})
|
||
AND i.sku_id IS NOT NULL
|
||
AND LTRIM(RTRIM(i.sku_id)) <> N''
|
||
)
|
||
SELECT sku_id AS skuCode, SUM(qty) AS stockQty
|
||
FROM inventory_latest
|
||
WHERE rn = 1
|
||
GROUP BY sku_id`,
|
||
params
|
||
);
|
||
|
||
return new Map(result.map((row) => [row.skuCode, toNumber(row.stockQty, 0)]));
|
||
}
|
||
|
||
async function fetchFreightForRows(rows, lookbackStartDate, endDate) {
|
||
if (!rows.length) return new Map();
|
||
|
||
const params = {
|
||
lookbackStartDate,
|
||
endDate,
|
||
};
|
||
|
||
const pairConditions = rows.map((row, index) => {
|
||
const shopKeyParam = `shopKey${index}`;
|
||
const skuCodeParam = `freightSkuCode${index}`;
|
||
params[shopKeyParam] = row.shopKey;
|
||
params[skuCodeParam] = row.skuCode;
|
||
return `(
|
||
REPLACE(REPLACE(LTRIM(RTRIM(f.shop_name)), N'_', N''), N'-', N'') = @${shopKeyParam}
|
||
AND LTRIM(RTRIM(f.sku_id)) = @${skuCodeParam}
|
||
)`;
|
||
});
|
||
|
||
const result = await cloudDb.query(
|
||
`WITH freight_rows AS (
|
||
SELECT
|
||
REPLACE(REPLACE(LTRIM(RTRIM(f.shop_name)), N'_', N''), N'-', N'') AS shop_key,
|
||
LTRIM(RTRIM(f.sku_id)) AS sku_code,
|
||
${FREIGHT_ZONE_CASE_SQL} AS zone_name,
|
||
AVG(TRY_CONVERT(DECIMAL(18, 2), f.freightCost)) AS avg_freight
|
||
FROM dbo.fys_jstFreightCostDetail f
|
||
WHERE TRY_CONVERT(date, f.send_date) BETWEEN @lookbackStartDate AND @endDate
|
||
AND TRY_CONVERT(DECIMAL(18, 2), f.freightCost) IS NOT NULL
|
||
AND (${pairConditions.join(' OR ')})
|
||
GROUP BY
|
||
REPLACE(REPLACE(LTRIM(RTRIM(f.shop_name)), N'_', N''), N'-', N''),
|
||
LTRIM(RTRIM(f.sku_id)),
|
||
${FREIGHT_ZONE_CASE_SQL}
|
||
)
|
||
SELECT
|
||
shop_key AS shopKey,
|
||
sku_code AS skuCode,
|
||
CAST(ROUND(AVG(CASE WHEN zone_name = N'一区' THEN avg_freight END), 2) AS DECIMAL(18, 2)) AS shippingZone1Avg,
|
||
CAST(ROUND(AVG(CASE WHEN zone_name = N'二区' THEN avg_freight END), 2) AS DECIMAL(18, 2)) AS shippingZone2Avg,
|
||
CAST(ROUND(AVG(CASE WHEN zone_name = N'三区' THEN avg_freight END), 2) AS DECIMAL(18, 2)) AS shippingZone3Avg,
|
||
CAST(ROUND(AVG(CASE WHEN zone_name = N'四区' THEN avg_freight END), 2) AS DECIMAL(18, 2)) AS shippingZone4Avg
|
||
FROM freight_rows
|
||
GROUP BY shop_key, sku_code`,
|
||
params
|
||
);
|
||
|
||
return new Map(result.map((row) => [`${row.shopKey}__${row.skuCode}`, {
|
||
shippingZone1Avg: row.shippingZone1Avg === null || row.shippingZone1Avg === undefined ? null : toNumber(row.shippingZone1Avg),
|
||
shippingZone2Avg: row.shippingZone2Avg === null || row.shippingZone2Avg === undefined ? null : toNumber(row.shippingZone2Avg),
|
||
shippingZone3Avg: row.shippingZone3Avg === null || row.shippingZone3Avg === undefined ? null : toNumber(row.shippingZone3Avg),
|
||
shippingZone4Avg: row.shippingZone4Avg === null || row.shippingZone4Avg === undefined ? null : toNumber(row.shippingZone4Avg),
|
||
}]));
|
||
}
|
||
|
||
async function fetchSkuCodesForSpuRows(rows, startDate, endDate) {
|
||
const spuList = uniqueText(rows.map((row) => row.spu));
|
||
const resultMap = new Map(spuList.map((spu) => [spu, []]));
|
||
if (!spuList.length) return resultMap;
|
||
|
||
for (const spuChunk of chunkArray(spuList, 100)) {
|
||
const params = [startDate, endDate, ...spuChunk];
|
||
await ensurePlanningChannelDailyReadSchema();
|
||
const result = await channelReadDb.query(
|
||
`SELECT
|
||
spu_key AS spu,
|
||
sku_code AS skuCode
|
||
FROM ${CHANNEL_READ_TABLE}
|
||
WHERE stat_date BETWEEN ? AND ?
|
||
AND spu_key IN (${spuChunk.map(() => '?').join(', ')})
|
||
GROUP BY spu_key, sku_code
|
||
ORDER BY spu_key, sku_code`,
|
||
params
|
||
);
|
||
|
||
(result || []).forEach((row) => {
|
||
const spu = normalizeText(row.spu);
|
||
const skuCode = normalizeText(row.skuCode);
|
||
if (!spu || !skuCode) return;
|
||
const list = resultMap.get(spu) || [];
|
||
if (!list.includes(skuCode)) {
|
||
list.push(skuCode);
|
||
}
|
||
resultMap.set(spu, list);
|
||
});
|
||
}
|
||
|
||
return resultMap;
|
||
}
|
||
|
||
async function fetchInventoryForSkuCodes(skuCodes = []) {
|
||
const uniqueSkuCodes = Array.from(new Set((skuCodes || []).map(normalizeText).filter(Boolean)));
|
||
if (!uniqueSkuCodes.length) return new Map();
|
||
|
||
const stockMap = new Map();
|
||
for (const skuChunk of chunkArray(uniqueSkuCodes, 300)) {
|
||
const params = {};
|
||
const placeholders = skuChunk.map((skuCode, index) => {
|
||
const key = `skuCode${index}`;
|
||
params[key] = skuCode;
|
||
return `@${key}`;
|
||
});
|
||
|
||
try {
|
||
const result = await cloudDb.query(
|
||
`SELECT
|
||
LTRIM(RTRIM(i.sku_id)) AS skuCode,
|
||
SUM(COALESCE(i.qty, 0)) AS stockQty
|
||
FROM dbo.jst_inventoryInfo i
|
||
WHERE LTRIM(RTRIM(i.sku_id)) IN (${placeholders.join(', ')})
|
||
AND i.sku_id IS NOT NULL
|
||
AND LTRIM(RTRIM(i.sku_id)) <> N''
|
||
GROUP BY LTRIM(RTRIM(i.sku_id))`,
|
||
params,
|
||
CHANNEL_SPU_ENRICHMENT_QUERY_OPTIONS
|
||
);
|
||
|
||
(result || []).forEach((row) => {
|
||
stockMap.set(normalizeSkuCodeKey(row.skuCode), toNumber(row.stockQty, 0));
|
||
});
|
||
} catch (error) {
|
||
console.warn('[Channel] 查询SPU库存补充数据超时或失败,先返回主数据:', error.message || error);
|
||
}
|
||
}
|
||
|
||
return stockMap;
|
||
}
|
||
|
||
async function fetchSkuProfileMap(skuCodes = []) {
|
||
const uniqueSkuCodes = Array.from(new Set((skuCodes || []).map(normalizeSkuCodeKey).filter(Boolean)));
|
||
if (!uniqueSkuCodes.length) return new Map();
|
||
|
||
const profileMap = new Map();
|
||
for (const skuChunk of chunkArray(uniqueSkuCodes, 300)) {
|
||
const params = {};
|
||
const placeholders = skuChunk.map((skuCode, index) => {
|
||
const key = `skuCode${index}`;
|
||
params[key] = skuCode;
|
||
return `@${key}`;
|
||
});
|
||
|
||
try {
|
||
const rows = await cloudDb.query(
|
||
`SELECT
|
||
UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), j.sku_id)))) 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,
|
||
COALESCE(
|
||
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.materialcode))), N''),
|
||
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), j.other_10))), N''),
|
||
N''
|
||
) AS material,
|
||
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),
|
||
NULLIF(TRY_CONVERT(DECIMAL(18, 2), j.cost_price), 0),
|
||
NULLIF(TRY_CONVERT(DECIMAL(18, 2), j.purchase_price), 0)
|
||
) AS productCost,
|
||
COALESCE(
|
||
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.ProStatus))), N''),
|
||
CASE WHEN ISNULL(j.enabled, 0) = 1 AND ISNULL(j.stock_disabled, 0) = 0 THEN N'销售' ELSE N'未配置' END
|
||
) AS saleStatus,
|
||
COALESCE(
|
||
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), l.Supplier))), N''),
|
||
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), j.supplier_name))), N''),
|
||
N''
|
||
) AS factoryName,
|
||
COALESCE(NULLIF(LTRIM(RTRIM(j.pic_big)), N''), NULLIF(LTRIM(RTRIM(j.pic)), N'')) AS skuImageUrl,
|
||
COALESCE(TRY_CONVERT(datetime, l.CreationTime), TRY_CONVERT(datetime, l.created), TRY_CONVERT(datetime, j.modified), TRY_CONVERT(datetime, j.created)) AS sortTime
|
||
FROM dbo.jst_sku j
|
||
LEFT JOIN dbo.fys_SkuDataLibraryNew l ON l.SkuCode = j.sku_id
|
||
WHERE UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), j.sku_id)))) IN (${placeholders.join(', ')})`,
|
||
params,
|
||
CHANNEL_SPU_ENRICHMENT_QUERY_OPTIONS
|
||
);
|
||
|
||
(rows || [])
|
||
.sort((left, right) => {
|
||
const leftSale = normalizeText(left.saleStatus) === '销售' ? 0 : 1;
|
||
const rightSale = normalizeText(right.saleStatus) === '销售' ? 0 : 1;
|
||
if (leftSale !== rightSale) return leftSale - rightSale;
|
||
const leftTime = left.sortTime ? new Date(left.sortTime).getTime() : 0;
|
||
const rightTime = right.sortTime ? new Date(right.sortTime).getTime() : 0;
|
||
return rightTime - leftTime;
|
||
})
|
||
.forEach((row) => {
|
||
const skuCode = normalizeSkuCodeKey(row.skuCode);
|
||
if (!skuCode || profileMap.has(skuCode)) return;
|
||
profileMap.set(skuCode, {
|
||
skuCode,
|
||
productCode: normalizeText(row.productCode),
|
||
skuName: normalizeText(row.skuName),
|
||
material: normalizeText(row.material),
|
||
productCost: toNullableNumber(row.productCost, 2),
|
||
saleStatus: normalizeText(row.saleStatus),
|
||
factoryName: normalizeText(row.factoryName),
|
||
categoryName: '',
|
||
skuImageUrl: normalizeText(row.skuImageUrl),
|
||
});
|
||
});
|
||
} catch (error) {
|
||
console.warn('[Channel] 查询SPU商品资料补充数据超时或失败,先返回主数据:', error.message || error);
|
||
}
|
||
}
|
||
|
||
return profileMap;
|
||
}
|
||
|
||
function buildPriceLimitLookupCandidates({ skuCode, productCode, spu }) {
|
||
const strippedSkuCode = stripSkuVariantSuffix(skuCode);
|
||
const derivedProductCode = deriveProductCodeFromSku(strippedSkuCode);
|
||
const resolvedProductCode = normalizeText(productCode) || derivedProductCode;
|
||
|
||
return uniqueText([
|
||
skuCode,
|
||
strippedSkuCode,
|
||
resolvedProductCode,
|
||
productCode,
|
||
spu,
|
||
deriveStyleCodeFromProductCode(resolvedProductCode),
|
||
deriveStyleCodeFromProductCode(productCode),
|
||
deriveStyleCodeFromProductCode(spu),
|
||
]);
|
||
}
|
||
|
||
function normalizePriceLimitRow(row) {
|
||
const mainSalesPddLimitPrice = toOptionalNumber(row.mainSalesPddLimitPrice, 2)
|
||
?? toOptionalNumber(row.pddLimitPriceNew, 2);
|
||
return {
|
||
priceLimitSourceTable: 'Brand_Price_Limit',
|
||
priceLimitMatchedKey: normalizeText(row.priceLimitMatchedKey),
|
||
spuManagerName: normalizeText(row.spuManagerName),
|
||
spuManagerLimitPrice: toOptionalNumber(row.spuManagerLimitPrice, 2),
|
||
nonSpuManagerLimitPrice: toOptionalNumber(row.nonSpuManagerLimitPriceNew, 2)
|
||
?? toOptionalNumber(row.nonSpuManagerLimitPrice, 2),
|
||
mainSalesPddLimitPrice,
|
||
mainSalesPddSubsidyLimitPrice: toOptionalNumber(row.mainSalesPddLimitPrice, 2),
|
||
pddLimitPriceNew: toOptionalNumber(row.pddLimitPriceNew, 2),
|
||
priceLimitUpdatedAt: normalizeDateValue(row.updateDate || row.createdTime),
|
||
_sortTime: row.updateDate || row.createdTime ? new Date(row.updateDate || row.createdTime).getTime() : 0,
|
||
};
|
||
}
|
||
|
||
function isBetterPriceLimitRow(current, next) {
|
||
if (!current) return true;
|
||
const currentHasLimit = [
|
||
current.spuManagerLimitPrice,
|
||
current.nonSpuManagerLimitPrice,
|
||
current.mainSalesPddLimitPrice,
|
||
].some((value) => value !== null && value !== undefined);
|
||
const nextHasLimit = [
|
||
next.spuManagerLimitPrice,
|
||
next.nonSpuManagerLimitPrice,
|
||
next.mainSalesPddLimitPrice,
|
||
].some((value) => value !== null && value !== undefined);
|
||
if (currentHasLimit !== nextHasLimit) return nextHasLimit;
|
||
return (next._sortTime || 0) > (current._sortTime || 0);
|
||
}
|
||
|
||
async function fetchSkuPriceLimitMap(rows = [], profileMap = new Map()) {
|
||
const rowCandidateMap = new Map();
|
||
const allCandidates = [];
|
||
|
||
rows.forEach((row) => {
|
||
const skuCode = normalizeText(row.skuCode || row.skuCodeText);
|
||
const skuKey = normalizeSkuCodeKey(skuCode);
|
||
const profile = profileMap.get(skuKey) || null;
|
||
const productCode = normalizeText(profile?.productCode) || normalizeText(row.spu);
|
||
const candidates = buildPriceLimitLookupCandidates({
|
||
skuCode,
|
||
productCode,
|
||
spu: row.spu,
|
||
});
|
||
rowCandidateMap.set(skuKey, candidates);
|
||
allCandidates.push(...candidates);
|
||
});
|
||
|
||
const uniqueCandidates = uniqueText(allCandidates);
|
||
if (!uniqueCandidates.length) return new Map();
|
||
|
||
const candidateResultMap = new Map();
|
||
const assignCandidate = (candidate, priceLimitRow) => {
|
||
const key = normalizeSkuCodeKey(candidate);
|
||
if (!key) return;
|
||
if (isBetterPriceLimitRow(candidateResultMap.get(key), priceLimitRow)) {
|
||
candidateResultMap.set(key, {
|
||
...priceLimitRow,
|
||
priceLimitMatchedKey: candidate,
|
||
});
|
||
}
|
||
};
|
||
|
||
for (const candidateChunk of chunkArray(uniqueCandidates, 300)) {
|
||
const params = {};
|
||
const placeholders = candidateChunk.map((candidate, index) => {
|
||
const key = `limitKey${index}`;
|
||
params[key] = candidate;
|
||
return `@${key}`;
|
||
});
|
||
const inSql = placeholders.join(', ');
|
||
|
||
try {
|
||
const limitRows = await cloudDb.query(
|
||
`SELECT
|
||
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), sku_id))), N'') AS skuId,
|
||
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), product_no))), N'') AS productNo,
|
||
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), style_code))), N'') AS styleCode,
|
||
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), spu))), N'') AS spu,
|
||
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), spu_manager))), N'') AS spuManagerName,
|
||
TRY_CONVERT(DECIMAL(18, 2), expected_daily_limit_price) AS spuManagerLimitPrice,
|
||
TRY_CONVERT(DECIMAL(18, 2), non_spu_manager_limit_price_new) AS nonSpuManagerLimitPriceNew,
|
||
TRY_CONVERT(DECIMAL(18, 2), non_spu_manager_limit_price) AS nonSpuManagerLimitPrice,
|
||
TRY_CONVERT(DECIMAL(18, 2), main_sales_pdd_subsidy_limit_price) AS mainSalesPddLimitPrice,
|
||
TRY_CONVERT(DECIMAL(18, 2), pdd_limit_price_new) AS pddLimitPriceNew,
|
||
TRY_CONVERT(datetime, update_date) AS updateDate,
|
||
TRY_CONVERT(datetime, created_time) AS createdTime
|
||
FROM dbo.Brand_Price_Limit WITH (NOLOCK)
|
||
WHERE NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), sku_id))), N'') IN (${inSql})
|
||
OR NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), product_no))), N'') IN (${inSql})
|
||
OR NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), style_code))), N'') IN (${inSql})
|
||
OR NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), spu))), N'') IN (${inSql})`,
|
||
params,
|
||
CHANNEL_SPU_ENRICHMENT_QUERY_OPTIONS
|
||
);
|
||
|
||
(limitRows || []).forEach((row) => {
|
||
const priceLimitRow = normalizePriceLimitRow(row);
|
||
[row.skuId, row.productNo, row.styleCode, row.spu].forEach((candidate) => {
|
||
assignCandidate(candidate, priceLimitRow);
|
||
});
|
||
});
|
||
} catch (error) {
|
||
console.warn('[Channel] 查询SKU限价补充数据超时或失败,先返回主数据:', error.message || error);
|
||
}
|
||
}
|
||
|
||
const resultMap = new Map();
|
||
rowCandidateMap.forEach((candidates, skuKey) => {
|
||
const matchedKey = candidates.map(normalizeSkuCodeKey).find((candidate) => candidateResultMap.has(candidate));
|
||
if (matchedKey) {
|
||
const matched = { ...candidateResultMap.get(matchedKey) };
|
||
delete matched._sortTime;
|
||
resultMap.set(skuKey, matched);
|
||
}
|
||
});
|
||
|
||
return resultMap;
|
||
}
|
||
|
||
async function fetchSpuOwnerMap(spus = []) {
|
||
const uniqueSpus = uniqueText(spus);
|
||
const ownerMap = new Map();
|
||
if (!uniqueSpus.length) return ownerMap;
|
||
|
||
for (const spuChunk of chunkArray(uniqueSpus, 300)) {
|
||
const params = {};
|
||
const placeholders = spuChunk.map((spu, index) => {
|
||
const key = `spu${index}`;
|
||
params[key] = spu;
|
||
return `@${key}`;
|
||
});
|
||
|
||
try {
|
||
const rows = await cloudDb.query(
|
||
`SELECT
|
||
LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), spu))) AS spu,
|
||
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), oper))), N'') AS ownerName,
|
||
COALESCE(TRY_CONVERT(datetime, realTime), GETDATE()) AS sortTime
|
||
FROM dbo.op_TargetManagement
|
||
WHERE LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), spu))) IN (${placeholders.join(', ')})
|
||
AND NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), oper))), N'') IS NOT NULL
|
||
ORDER BY COALESCE(TRY_CONVERT(datetime, realTime), GETDATE()) DESC, Id DESC`,
|
||
params,
|
||
CHANNEL_SPU_ENRICHMENT_QUERY_OPTIONS
|
||
);
|
||
|
||
(rows || []).forEach((row) => {
|
||
const spu = normalizeText(row.spu);
|
||
const ownerName = normalizeText(row.ownerName);
|
||
if (spu && ownerName && !ownerMap.has(spu)) {
|
||
ownerMap.set(spu, ownerName);
|
||
}
|
||
});
|
||
} catch (error) {
|
||
console.warn('[Channel] 查询SPU负责人失败,使用采购负责人兜底:', error.message || error);
|
||
}
|
||
}
|
||
|
||
return ownerMap;
|
||
}
|
||
|
||
async function fetchFreightForSkuCodes(skuCodes = [], lookbackStartDate, endDate) {
|
||
const uniqueSkuCodes = Array.from(new Set((skuCodes || []).map(normalizeText).filter(Boolean)));
|
||
const freightMap = new Map();
|
||
if (!uniqueSkuCodes.length) return freightMap;
|
||
|
||
for (const skuChunk of chunkArray(uniqueSkuCodes, 300)) {
|
||
const params = {
|
||
lookbackStartDate,
|
||
endDate,
|
||
};
|
||
const placeholders = skuChunk.map((skuCode, index) => {
|
||
const key = `skuCode${index}`;
|
||
params[key] = skuCode;
|
||
return `@${key}`;
|
||
});
|
||
|
||
try {
|
||
const result = await cloudDb.query(
|
||
`WITH freight_rows AS (
|
||
SELECT
|
||
LTRIM(RTRIM(f.sku_id)) AS sku_code,
|
||
${FREIGHT_ZONE_CASE_SQL} AS zone_name,
|
||
TRY_CONVERT(DECIMAL(18, 2), f.freightCost) AS freight_cost
|
||
FROM dbo.fys_jstFreightCostDetail f
|
||
WHERE TRY_CONVERT(date, f.send_date) BETWEEN @lookbackStartDate AND @endDate
|
||
AND TRY_CONVERT(DECIMAL(18, 2), f.freightCost) IS NOT NULL
|
||
AND TRY_CONVERT(DECIMAL(18, 2), f.freightCost) > 0
|
||
AND LTRIM(RTRIM(f.sku_id)) IN (${placeholders.join(', ')})
|
||
)
|
||
SELECT
|
||
sku_code AS skuCode,
|
||
CAST(ROUND(AVG(freight_cost), 2) AS DECIMAL(18, 2)) AS nationalFreightAvg,
|
||
CAST(ROUND(AVG(CASE WHEN zone_name = N'一区' THEN freight_cost END), 2) AS DECIMAL(18, 2)) AS shippingZone1Avg,
|
||
CAST(ROUND(AVG(CASE WHEN zone_name = N'二区' THEN freight_cost END), 2) AS DECIMAL(18, 2)) AS shippingZone2Avg,
|
||
CAST(ROUND(AVG(CASE WHEN zone_name = N'三区' THEN freight_cost END), 2) AS DECIMAL(18, 2)) AS shippingZone3Avg,
|
||
CAST(ROUND(AVG(CASE WHEN zone_name = N'四区' THEN freight_cost END), 2) AS DECIMAL(18, 2)) AS shippingZone4Avg
|
||
FROM freight_rows
|
||
GROUP BY sku_code`,
|
||
params,
|
||
CHANNEL_SPU_ENRICHMENT_QUERY_OPTIONS
|
||
);
|
||
|
||
(result || []).forEach((row) => {
|
||
freightMap.set(normalizeSkuCodeKey(row.skuCode), {
|
||
nationalFreightAvg: row.nationalFreightAvg === null || row.nationalFreightAvg === undefined ? null : toNumber(row.nationalFreightAvg),
|
||
shippingZone1Avg: row.shippingZone1Avg === null || row.shippingZone1Avg === undefined ? null : toNumber(row.shippingZone1Avg),
|
||
shippingZone2Avg: row.shippingZone2Avg === null || row.shippingZone2Avg === undefined ? null : toNumber(row.shippingZone2Avg),
|
||
shippingZone3Avg: row.shippingZone3Avg === null || row.shippingZone3Avg === undefined ? null : toNumber(row.shippingZone3Avg),
|
||
shippingZone4Avg: row.shippingZone4Avg === null || row.shippingZone4Avg === undefined ? null : toNumber(row.shippingZone4Avg),
|
||
});
|
||
});
|
||
} catch (error) {
|
||
console.warn('[Channel] 查询SPU运费补充数据超时或失败,先返回主数据:', error.message || error);
|
||
}
|
||
}
|
||
|
||
return freightMap;
|
||
}
|
||
|
||
function ensureStockMonitorMetric(metricMap, skuCode, months = []) {
|
||
const skuKey = normalizeSkuCodeKey(skuCode);
|
||
if (!skuKey) return null;
|
||
if (!metricMap.has(skuKey)) {
|
||
metricMap.set(skuKey, {
|
||
monthlyMap: createEmptyStockMonitorMonthlyMap(months),
|
||
latestDate: null,
|
||
stock: null,
|
||
stockUseDays: null,
|
||
produceCycle: null,
|
||
outOfStock: null,
|
||
warningRequired: false,
|
||
warningText: '',
|
||
});
|
||
}
|
||
return metricMap.get(skuKey);
|
||
}
|
||
|
||
function hasStockMonitorMetricData(metric) {
|
||
if (!metric) return false;
|
||
if (metric.latestDate) return true;
|
||
return Object.values(metric.monthlyMap || {}).some((month) => (
|
||
toNumber(month?.stockOutCount, 0) > 0 || toNumber(month?.monthlySalesQty, 2) > 0
|
||
));
|
||
}
|
||
|
||
function pickStockMonitorMetric(metricMap, candidates = []) {
|
||
const candidateKeys = candidates.map(normalizeSkuCodeKey).filter(Boolean);
|
||
for (const candidateKey of candidateKeys) {
|
||
const metric = metricMap.get(candidateKey);
|
||
if (hasStockMonitorMetricData(metric)) {
|
||
return metric;
|
||
}
|
||
}
|
||
for (const candidateKey of candidateKeys) {
|
||
const metric = metricMap.get(candidateKey);
|
||
if (metric) {
|
||
return metric;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
async function fetchStockMonitorBackupMetricsDetailed(skuCodes = [], months = []) {
|
||
const uniqueSkuCodes = Array.from(new Set((skuCodes || []).map(normalizeSkuCodeKey).filter(Boolean)));
|
||
const metricMap = new Map();
|
||
const failureMessages = [];
|
||
let succeededChunkCount = 0;
|
||
let failedChunkCount = 0;
|
||
uniqueSkuCodes.forEach((skuCode) => ensureStockMonitorMetric(metricMap, skuCode, months));
|
||
if (!uniqueSkuCodes.length || !months.length) {
|
||
return {
|
||
metricMap,
|
||
failureMessages,
|
||
succeededChunkCount,
|
||
failedChunkCount,
|
||
};
|
||
}
|
||
|
||
const queryWindow = buildStockMonitorQueryWindow(months);
|
||
if (!queryWindow) {
|
||
return {
|
||
metricMap,
|
||
failureMessages,
|
||
succeededChunkCount,
|
||
failedChunkCount,
|
||
};
|
||
}
|
||
|
||
for (const skuChunk of chunkArray(uniqueSkuCodes, 80)) {
|
||
const params = {
|
||
startDate: queryWindow.startDateValue,
|
||
endExclusive: queryWindow.endExclusiveValue,
|
||
};
|
||
const placeholders = skuChunk.map((skuCode, index) => {
|
||
const key = `stockMonitorSku${index}`;
|
||
params[key] = skuCode;
|
||
return `@${key}`;
|
||
});
|
||
|
||
try {
|
||
const monthlyRows = await cloudDb.queryWithFreshConnection(
|
||
`WITH normalized AS (
|
||
SELECT
|
||
${STOCK_MONITOR_SKU_EXPR_SQL} AS skuCode,
|
||
CONVERT(CHAR(7), [date], 120) AS monthKey,
|
||
CONVERT(DATE, [date]) AS statDate,
|
||
ISNULL(TRY_CONVERT(DECIMAL(18, 2), [outOfStock]), 0) AS outOfStockValue,
|
||
TRY_CONVERT(DECIMAL(18, 2), [jsStock]) AS jsStock,
|
||
TRY_CONVERT(DECIMAL(18, 2), [produceCycle]) AS produceCycle,
|
||
ISNULL(TRY_CONVERT(DECIMAL(18, 2), [purchaseSale]), 0) AS salesQty
|
||
FROM ${STOCK_MONITOR_BACKUP_TABLE} WITH (NOLOCK)
|
||
WHERE TRY_CONVERT(datetime, [date]) >= @startDate
|
||
AND TRY_CONVERT(datetime, [date]) < @endExclusive
|
||
AND ${STOCK_MONITOR_SKU_EXPR_SQL} IN (${placeholders.join(', ')})
|
||
)
|
||
SELECT
|
||
skuCode,
|
||
monthKey,
|
||
COUNT(DISTINCT CASE WHEN outOfStockValue > 0 THEN statDate END) AS stockOutCount,
|
||
COUNT(DISTINCT CASE WHEN jsStock IS NOT NULL AND produceCycle IS NOT NULL AND jsStock < produceCycle THEN statDate END) AS warningCount,
|
||
CAST(ROUND(SUM(salesQty), 0) AS BIGINT) AS monthlySalesQty
|
||
FROM normalized
|
||
WHERE skuCode IS NOT NULL
|
||
AND monthKey IS NOT NULL
|
||
GROUP BY skuCode, monthKey`,
|
||
params,
|
||
CHANNEL_STOCK_MONITOR_QUERY_OPTIONS
|
||
);
|
||
|
||
(monthlyRows || []).forEach((row) => {
|
||
const metric = ensureStockMonitorMetric(metricMap, row.skuCode, months);
|
||
if (!metric || !row.monthKey || !metric.monthlyMap[row.monthKey]) return;
|
||
metric.monthlyMap[row.monthKey] = {
|
||
...metric.monthlyMap[row.monthKey],
|
||
stockOutCount: toNumber(row.stockOutCount, 0),
|
||
warningCount: toNumber(row.warningCount, 0),
|
||
monthlySalesQty: toNumber(row.monthlySalesQty, 0),
|
||
};
|
||
});
|
||
|
||
const latestRows = await cloudDb.queryWithFreshConnection(
|
||
`WITH normalized AS (
|
||
SELECT
|
||
[id],
|
||
${STOCK_MONITOR_SKU_EXPR_SQL} AS skuCode,
|
||
CONVERT(DATE, [date]) AS statDate,
|
||
TRY_CONVERT(DECIMAL(18, 2), [jsStock]) AS jsStock,
|
||
TRY_CONVERT(DECIMAL(18, 2), [jsUseDays]) AS jsUseDays,
|
||
TRY_CONVERT(DECIMAL(18, 2), [produceCycle]) AS produceCycle,
|
||
TRY_CONVERT(DECIMAL(18, 2), [outOfStock]) AS outOfStock
|
||
FROM ${STOCK_MONITOR_BACKUP_TABLE} WITH (NOLOCK)
|
||
WHERE TRY_CONVERT(datetime, [date]) >= @startDate
|
||
AND TRY_CONVERT(datetime, [date]) < @endExclusive
|
||
AND ${STOCK_MONITOR_SKU_EXPR_SQL} IN (${placeholders.join(', ')})
|
||
),
|
||
ranked AS (
|
||
SELECT
|
||
*,
|
||
ROW_NUMBER() OVER (PARTITION BY skuCode ORDER BY statDate DESC, [id] DESC) AS rn
|
||
FROM normalized
|
||
WHERE skuCode IS NOT NULL
|
||
)
|
||
SELECT skuCode, statDate, jsStock, jsUseDays, produceCycle, outOfStock
|
||
FROM ranked
|
||
WHERE rn = 1`,
|
||
params,
|
||
CHANNEL_STOCK_MONITOR_QUERY_OPTIONS
|
||
);
|
||
|
||
(latestRows || []).forEach((row) => {
|
||
const metric = ensureStockMonitorMetric(metricMap, row.skuCode, months);
|
||
if (!metric) return;
|
||
const stock = toNullableNumber(row.jsStock, 2);
|
||
const produceCycle = toNullableNumber(row.produceCycle, 2);
|
||
const warningRequired = stock !== null && produceCycle !== null && stock < produceCycle;
|
||
metric.latestDate = row.statDate ? dayjs(row.statDate).format('YYYY-MM-DD') : null;
|
||
metric.stock = stock;
|
||
metric.stockUseDays = toNullableNumber(row.jsUseDays, 2);
|
||
metric.produceCycle = produceCycle;
|
||
metric.outOfStock = toNullableNumber(row.outOfStock, 2);
|
||
metric.warningRequired = warningRequired;
|
||
metric.warningText = warningRequired ? STOCK_MONITOR_WARNING_TEXT : '';
|
||
});
|
||
succeededChunkCount += 1;
|
||
} catch (error) {
|
||
failedChunkCount += 1;
|
||
const message = error?.message || String(error || 'unknown');
|
||
failureMessages.push(message);
|
||
console.warn('[Channel] 查询库存监控历史数据失败,先返回主数据:', message);
|
||
}
|
||
}
|
||
|
||
return {
|
||
metricMap,
|
||
failureMessages,
|
||
succeededChunkCount,
|
||
failedChunkCount,
|
||
};
|
||
}
|
||
|
||
async function fetchStockMonitorBackupMetrics(skuCodes = [], months = []) {
|
||
const result = await fetchStockMonitorBackupMetricsDetailed(skuCodes, months);
|
||
return result.metricMap;
|
||
}
|
||
|
||
async function enrichSpuRows(rows, startDate, endDate, lookbackStartDate) {
|
||
if (!rows.length) return rows;
|
||
|
||
const spuSkuMap = await fetchSkuCodesForSpuRows(rows, startDate, endDate);
|
||
const allSkuCodes = Array.from(new Set(Array.from(spuSkuMap.values()).flat()));
|
||
const enrichmentSkuCodes = Array.from(new Set(
|
||
rows.flatMap((row) => (spuSkuMap.get(row.spu) || []).slice(0, CHANNEL_SPU_ENRICHMENT_SKU_LIMIT_PER_SPU))
|
||
));
|
||
const [inventoryMap, profileMap, ownerMap, freightMap] = await Promise.all([
|
||
fetchInventoryForSkuCodes(allSkuCodes),
|
||
fetchSkuProfileMap(enrichmentSkuCodes),
|
||
fetchSpuOwnerMap(rows.map((row) => row.spu)),
|
||
fetchFreightForSkuCodes(enrichmentSkuCodes, lookbackStartDate, endDate),
|
||
]);
|
||
|
||
return rows.map((row) => {
|
||
const skuCodes = spuSkuMap.get(row.spu) || [];
|
||
const skuKeys = skuCodes.map(normalizeSkuCodeKey);
|
||
const profiles = skuKeys.map((skuCode) => profileMap.get(skuCode)).filter(Boolean);
|
||
const freights = skuKeys.map((skuCode) => freightMap.get(skuCode)).filter(Boolean);
|
||
const realtimeStock = skuKeys.reduce((sum, skuCode) => sum + (inventoryMap.get(skuCode) || 0), 0);
|
||
const stockTurnoverBase = row.shipCount > 0 ? row.shipCount : 0;
|
||
const avg30DaySales = stockTurnoverBase > 0 ? toNumber(stockTurnoverBase / 30, 2) : 0;
|
||
const primaryProfile = profiles.find((item) => normalizeText(item.saleStatus) === '销售') || profiles[0] || null;
|
||
const imageFromProfile = profiles.map((item) => item.skuImageUrl).find(Boolean);
|
||
|
||
return {
|
||
...row,
|
||
skuCodes,
|
||
skuCodeText: formatLimitedList(skuCodes, 4, row.skuCodeText || ''),
|
||
skuImageUrl: row.skuImageUrl || imageFromProfile || null,
|
||
saleStatus: normalizeText(primaryProfile?.saleStatus) || row.saleStatus,
|
||
realtimeStock,
|
||
avg30DaySales,
|
||
estimatedSalesDays: avg30DaySales > 0 ? toNumber(realtimeStock / avg30DaySales, 1) : null,
|
||
material: formatLimitedList(profiles.map((item) => item.material), 3, ''),
|
||
nonSpuOwner: ownerMap.get(row.spu) || row.purchaserName || '未配置',
|
||
productCost: averageNumbers(profiles.map((item) => item.productCost), 2),
|
||
nationalFreightAvg: averageNumbers(freights.map((item) => item.nationalFreightAvg), 2),
|
||
shippingZone1Avg: averageNumbers(freights.map((item) => item.shippingZone1Avg), 2),
|
||
shippingZone2Avg: averageNumbers(freights.map((item) => item.shippingZone2Avg), 2),
|
||
shippingZone3Avg: averageNumbers(freights.map((item) => item.shippingZone3Avg), 2),
|
||
shippingZone4Avg: averageNumbers(freights.map((item) => item.shippingZone4Avg), 2),
|
||
};
|
||
});
|
||
}
|
||
|
||
async function enrichSkuRows(rows, startDate, endDate, lookbackStartDate, stockMonitorMonths = []) {
|
||
if (!rows.length) return rows;
|
||
|
||
const skuCodes = Array.from(new Set(rows.map((row) => row.skuCode || row.skuCodeText).map(normalizeText).filter(Boolean)));
|
||
const stockMonitorCandidateMap = new Map();
|
||
const stockMonitorLookupCodes = [];
|
||
rows.forEach((row) => {
|
||
const skuKey = normalizeSkuCodeKey(row.skuCode || row.skuCodeText);
|
||
const candidates = buildStockMonitorRowLookupCandidates(row);
|
||
stockMonitorCandidateMap.set(skuKey, candidates);
|
||
candidates.forEach((candidate) => addUniqueSkuKey(stockMonitorLookupCodes, candidate));
|
||
});
|
||
const [inventoryMap, profileMap, freightMap, stockMonitorMap] = await Promise.all([
|
||
fetchInventoryForSkuCodes(skuCodes),
|
||
fetchSkuProfileMap(skuCodes),
|
||
fetchFreightForSkuCodes(skuCodes, lookbackStartDate, endDate),
|
||
fetchStockMonitorBackupMetrics(stockMonitorLookupCodes, stockMonitorMonths),
|
||
]);
|
||
|
||
const ownerSpus = rows.map((row) => {
|
||
const skuKey = normalizeSkuCodeKey(row.skuCode || row.skuCodeText);
|
||
const profile = profileMap.get(skuKey);
|
||
return normalizeText(profile?.productCode) || normalizeText(row.spu);
|
||
});
|
||
const [ownerMap, priceLimitMap] = await Promise.all([
|
||
fetchSpuOwnerMap(ownerSpus),
|
||
fetchSkuPriceLimitMap(rows, profileMap),
|
||
]);
|
||
|
||
return rows.map((row) => {
|
||
const skuCode = normalizeText(row.skuCode || row.skuCodeText);
|
||
const skuKey = normalizeSkuCodeKey(skuCode);
|
||
const profile = profileMap.get(skuKey) || null;
|
||
const productSpu = normalizeText(profile?.productCode) || normalizeText(row.spu) || skuCode;
|
||
const freight = freightMap.get(skuKey) || {};
|
||
const priceLimit = priceLimitMap.get(skuKey) || {};
|
||
const stockMonitorCandidates = stockMonitorCandidateMap.get(skuKey) || [skuKey];
|
||
const stockMonitor = pickStockMonitorMetric(stockMonitorMap, stockMonitorCandidates) || {};
|
||
const stockMonitorMonthlyMap = stockMonitor.monthlyMap || createEmptyStockMonitorMonthlyMap(stockMonitorMonths);
|
||
const realtimeStock = inventoryMap.get(skuKey) || 0;
|
||
const stockTurnoverBase = row.shipCount > 0 ? row.shipCount : 0;
|
||
const avg30DaySales = stockTurnoverBase > 0 ? toNumber(stockTurnoverBase / 30, 2) : 0;
|
||
|
||
return {
|
||
...row,
|
||
spu: productSpu,
|
||
skuCode,
|
||
skuCodes: skuCode ? [skuCode] : [],
|
||
skuCodeText: skuCode,
|
||
skuCount: 1,
|
||
skuImageUrl: row.skuImageUrl || profile?.skuImageUrl || null,
|
||
skuName: normalizeText(profile?.skuName) || row.spu || skuCode,
|
||
saleStatus: normalizeText(profile?.saleStatus) || row.saleStatus,
|
||
factoryName: normalizeText(profile?.factoryName),
|
||
categoryName: normalizeText(profile?.categoryName),
|
||
realtimeStock,
|
||
stockMonitorLatestDate: stockMonitor.latestDate || null,
|
||
stockMonitorStock: stockMonitor.stock ?? null,
|
||
stockMonitorStockUseDays: stockMonitor.stockUseDays ?? null,
|
||
stockMonitorProduceCycle: stockMonitor.produceCycle ?? null,
|
||
stockMonitorOutOfStock: stockMonitor.outOfStock ?? null,
|
||
stockMonitorWarningRequired: Boolean(stockMonitor.warningRequired),
|
||
stockMonitorWarningText: stockMonitor.warningText || '',
|
||
stockMonitorMonthlyMap,
|
||
stockMonitorMonthly: stockMonitorMonths.map((month) => stockMonitorMonthlyMap[month.key] || {
|
||
monthKey: month.key,
|
||
label: month.label,
|
||
shortLabel: month.shortLabel,
|
||
stockOutCount: 0,
|
||
warningCount: 0,
|
||
monthlySalesQty: 0,
|
||
}),
|
||
avg30DaySales,
|
||
estimatedSalesDays: avg30DaySales > 0 ? toNumber(realtimeStock / avg30DaySales, 1) : null,
|
||
material: normalizeText(profile?.material),
|
||
nonSpuOwner: ownerMap.get(productSpu) || row.purchaserName || '未配置',
|
||
productCost: toNullableNumber(profile?.productCost, 2),
|
||
priceLimitSourceTable: priceLimit.priceLimitSourceTable || null,
|
||
priceLimitMatchedKey: priceLimit.priceLimitMatchedKey || null,
|
||
spuManagerName: priceLimit.spuManagerName || '',
|
||
spuManagerLimitPrice: priceLimit.spuManagerLimitPrice ?? null,
|
||
nonSpuManagerLimitPrice: priceLimit.nonSpuManagerLimitPrice ?? null,
|
||
mainSalesPddLimitPrice: priceLimit.mainSalesPddLimitPrice ?? null,
|
||
mainSalesPddSubsidyLimitPrice: priceLimit.mainSalesPddSubsidyLimitPrice ?? null,
|
||
pddLimitPriceNew: priceLimit.pddLimitPriceNew ?? null,
|
||
priceLimitUpdatedAt: priceLimit.priceLimitUpdatedAt || null,
|
||
nationalFreightAvg: freight.nationalFreightAvg ?? null,
|
||
shippingZone1Avg: freight.shippingZone1Avg ?? null,
|
||
shippingZone2Avg: freight.shippingZone2Avg ?? null,
|
||
shippingZone3Avg: freight.shippingZone3Avg ?? null,
|
||
shippingZone4Avg: freight.shippingZone4Avg ?? null,
|
||
};
|
||
});
|
||
}
|
||
|
||
async function enrichStockMonitorDashboardRows(rows, stockMonitorMonths = []) {
|
||
const detailed = await enrichStockMonitorDashboardRowsDetailed(rows, stockMonitorMonths);
|
||
return detailed.rows;
|
||
}
|
||
|
||
function hasStockMonitorDashboardRowSignal(row = {}) {
|
||
if (normalizeDateValue(row.stockMonitorLatestDate)) {
|
||
return true;
|
||
}
|
||
|
||
if (row.stockMonitorWarningRequired || Math.max(0, toNumber(row.stockMonitorOutOfStock, 0)) > 0) {
|
||
return true;
|
||
}
|
||
|
||
return (row.stockMonitorMonthly || []).some((monthItem) => (
|
||
toNumber(monthItem?.stockOutCount, 0) > 0
|
||
|| toNumber(monthItem?.warningCount, 0) > 0
|
||
|| toNumber(monthItem?.monthlySalesQty, 0) > 0
|
||
));
|
||
}
|
||
|
||
async function enrichStockMonitorDashboardRowsDetailed(rows, stockMonitorMonths = []) {
|
||
if (!rows.length) {
|
||
return {
|
||
rows,
|
||
diagnostics: {
|
||
metricMap: new Map(),
|
||
failureMessages: [],
|
||
succeededChunkCount: 0,
|
||
failedChunkCount: 0,
|
||
hasData: false,
|
||
},
|
||
};
|
||
}
|
||
|
||
const stockMonitorCandidateMap = new Map();
|
||
const stockMonitorLookupCodes = [];
|
||
rows.forEach((row) => {
|
||
const skuKey = normalizeSkuCodeKey(row.skuCode || row.skuCodeText);
|
||
const candidates = buildStockMonitorRowLookupCandidates(row);
|
||
stockMonitorCandidateMap.set(skuKey, candidates);
|
||
candidates.forEach((candidate) => addUniqueSkuKey(stockMonitorLookupCodes, candidate));
|
||
});
|
||
|
||
const diagnostics = await fetchStockMonitorBackupMetricsDetailed(stockMonitorLookupCodes, stockMonitorMonths);
|
||
const stockMonitorMap = diagnostics.metricMap;
|
||
const enrichedRows = rows.map((row) => {
|
||
const skuCode = normalizeText(row.skuCode || row.skuCodeText);
|
||
const skuKey = normalizeSkuCodeKey(skuCode);
|
||
const stockMonitorCandidates = stockMonitorCandidateMap.get(skuKey) || [skuKey];
|
||
const stockMonitor = pickStockMonitorMetric(stockMonitorMap, stockMonitorCandidates) || {};
|
||
const stockMonitorMonthlyMap = stockMonitor.monthlyMap || createEmptyStockMonitorMonthlyMap(stockMonitorMonths);
|
||
const stockTurnoverBase = row.shipCount > 0 ? row.shipCount : 0;
|
||
const avg30DaySales = stockTurnoverBase > 0 ? toNumber(stockTurnoverBase / 30, 2) : 0;
|
||
|
||
return {
|
||
...row,
|
||
skuCode,
|
||
skuCodeText: skuCode,
|
||
skuCount: 1,
|
||
skuName: row.spu || skuCode,
|
||
saleStatus: row.saleStatus || '未配置',
|
||
factoryName: '未配置',
|
||
categoryName: row.saleStatus || '未配置',
|
||
stockMonitorLatestDate: stockMonitor.latestDate || null,
|
||
stockMonitorStock: stockMonitor.stock ?? null,
|
||
stockMonitorStockUseDays: stockMonitor.stockUseDays ?? null,
|
||
stockMonitorProduceCycle: stockMonitor.produceCycle ?? null,
|
||
stockMonitorOutOfStock: stockMonitor.outOfStock ?? null,
|
||
stockMonitorWarningRequired: Boolean(stockMonitor.warningRequired),
|
||
stockMonitorWarningText: stockMonitor.warningText || '',
|
||
stockMonitorMonthlyMap,
|
||
stockMonitorMonthly: stockMonitorMonths.map((month) => stockMonitorMonthlyMap[month.key] || {
|
||
monthKey: month.key,
|
||
label: month.label,
|
||
shortLabel: month.shortLabel,
|
||
stockOutCount: 0,
|
||
warningCount: 0,
|
||
monthlySalesQty: 0,
|
||
}),
|
||
avg30DaySales,
|
||
estimatedSalesDays: avg30DaySales > 0 ? toNumber((stockMonitor.stock || 0) / avg30DaySales, 1) : null,
|
||
nonSpuOwner: row.purchaserName || '未配置',
|
||
};
|
||
});
|
||
|
||
return {
|
||
rows: enrichedRows,
|
||
diagnostics: {
|
||
...diagnostics,
|
||
hasData: enrichedRows.some((row) => hasStockMonitorDashboardRowSignal(row)),
|
||
},
|
||
};
|
||
}
|
||
|
||
async function fetchChannelIdPddMetrics(itemIds = [], startDate, endDate) {
|
||
const uniqueItemIds = uniqueText(itemIds);
|
||
if (!uniqueItemIds.length) return new Map();
|
||
|
||
const metricsMap = new Map();
|
||
const startDateTime = `${startDate} 00:00:00`;
|
||
const endDateExclusive = dayjs(endDate).add(1, 'day').format('YYYY-MM-DD 00:00:00');
|
||
|
||
for (const itemChunk of chunkArray(uniqueItemIds, 200)) {
|
||
const params = {
|
||
startDateTime,
|
||
endDateExclusive,
|
||
};
|
||
const placeholders = itemChunk.map((itemId, index) => {
|
||
const key = `itemId${index}`;
|
||
params[key] = itemId;
|
||
return `@${key}`;
|
||
});
|
||
|
||
try {
|
||
const rows = await cloudDb.query(
|
||
`WITH goods_agg AS (
|
||
SELECT
|
||
LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) AS itemId,
|
||
MAX(NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(500), g.[goodsName]))), N'')) AS goodsName,
|
||
MAX(
|
||
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(1000), g.[hdThumbUrl]))), N'')
|
||
) AS imageUrl,
|
||
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[goodsUv]), 0)) AS goodsUv,
|
||
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrAmt]), 0)) AS payOrdrAmt,
|
||
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrUsrCnt]), 0)) AS payOrdrUsrCnt,
|
||
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[imprUsrCnt]), 0)) AS imprUsrCnt,
|
||
AVG(TRY_CONVERT(DECIMAL(38, 6), REPLACE(TRY_CONVERT(NVARCHAR(50), g.[goodsPtHelpRate]), '%', ''))) AS goodsPtHelpRate
|
||
FROM dbo.[pdd_DataGoods] g
|
||
WHERE LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) IN (${placeholders.join(', ')})
|
||
AND g.[createTime] >= @startDateTime
|
||
AND g.[createTime] < @endDateExclusive
|
||
GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId])))
|
||
),
|
||
spread_agg AS (
|
||
SELECT
|
||
LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), s.[goodsId]))) AS itemId,
|
||
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[impression]), 0)) AS impression,
|
||
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[click]), 0)) AS click,
|
||
CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[gmv]), 0)) / 1000.0 AS DECIMAL(38, 6)) AS gmv,
|
||
CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[spend]), 0)) / 1000.0 AS DECIMAL(38, 6)) AS spend,
|
||
CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[orderSpend]), 0)) / 1000.0 AS DECIMAL(38, 6)) AS orderSpend,
|
||
CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[netGmv]), 0)) / 1000.0 AS DECIMAL(38, 6)) AS netGmv,
|
||
AVG(TRY_CONVERT(DECIMAL(38, 6), REPLACE(TRY_CONVERT(NVARCHAR(50), s.[targetRoi]), '%', ''))) AS targetRoi,
|
||
AVG(TRY_CONVERT(DECIMAL(38, 6), REPLACE(TRY_CONVERT(NVARCHAR(50), s.[globalTakeRate]), '%', ''))) AS globalTakeRate,
|
||
AVG(TRY_CONVERT(DECIMAL(38, 6), REPLACE(TRY_CONVERT(NVARCHAR(50), s.[settlementRoi]), '%', ''))) AS settlementRoi,
|
||
AVG(TRY_CONVERT(DECIMAL(38, 6), REPLACE(TRY_CONVERT(NVARCHAR(50), s.[refundGmvRate30d]), '%', ''))) AS refundRate
|
||
FROM dbo.[pdd_ReportGoodsSpread] s
|
||
WHERE LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), s.[goodsId]))) IN (${placeholders.join(', ')})
|
||
AND s.[createTime] >= @startDateTime
|
||
AND s.[createTime] < @endDateExclusive
|
||
GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), s.[goodsId])))
|
||
)
|
||
SELECT
|
||
COALESCE(g.itemId, s.itemId) AS itemId,
|
||
g.goodsName,
|
||
g.imageUrl,
|
||
CAST(ISNULL(g.goodsUv, 0) AS DECIMAL(38, 2)) AS goodsUv,
|
||
CAST(ISNULL(g.payOrdrAmt, 0) AS DECIMAL(38, 2)) AS payOrdrAmt,
|
||
CAST(
|
||
CASE
|
||
WHEN ISNULL(g.goodsUv, 0) = 0 THEN 0
|
||
ELSE ISNULL(g.payOrdrUsrCnt, 0) / NULLIF(g.goodsUv, 0)
|
||
END AS DECIMAL(38, 6)
|
||
) AS goodsVcr,
|
||
CAST(ISNULL(g.imprUsrCnt, 0) AS DECIMAL(38, 2)) AS imprUsrCnt,
|
||
CAST(g.goodsPtHelpRate AS DECIMAL(38, 6)) AS goodsPtHelpRate,
|
||
CAST(ISNULL(s.impression, 0) AS DECIMAL(38, 2)) AS impression,
|
||
CAST(
|
||
CASE
|
||
WHEN ISNULL(s.impression, 0) = 0 THEN 0
|
||
ELSE ISNULL(s.click, 0) / NULLIF(s.impression, 0)
|
||
END AS DECIMAL(38, 6)
|
||
) AS ctr,
|
||
CAST(ISNULL(s.gmv, 0) AS DECIMAL(38, 2)) AS gmv,
|
||
CAST(ISNULL(s.netGmv, 0) AS DECIMAL(38, 2)) AS netGmv,
|
||
CAST(
|
||
CASE
|
||
WHEN ISNULL(s.spend, 0) = 0 THEN 0
|
||
ELSE ISNULL(s.gmv, 0) / NULLIF(s.spend, 0)
|
||
END AS DECIMAL(38, 6)
|
||
) AS actualRoi,
|
||
CAST(
|
||
CASE
|
||
WHEN ISNULL(s.orderSpend, 0) = 0 THEN 0
|
||
ELSE ISNULL(s.netGmv, 0) / NULLIF(s.orderSpend, 0)
|
||
END AS DECIMAL(38, 6)
|
||
) AS netRoi,
|
||
CAST(ISNULL(s.targetRoi, 0) AS DECIMAL(38, 6)) AS targetRoi,
|
||
CAST(ISNULL(s.globalTakeRate, 0) AS DECIMAL(38, 6)) AS globalTakeRate,
|
||
CAST(ISNULL(s.settlementRoi, 0) AS DECIMAL(38, 6)) AS settlementRoi,
|
||
CAST(ISNULL(s.refundRate, 0) AS DECIMAL(38, 6)) AS refundRate,
|
||
CAST(ISNULL(s.spend, 0) AS DECIMAL(38, 2)) AS spend
|
||
FROM goods_agg g
|
||
FULL OUTER JOIN spread_agg s
|
||
ON s.itemId = g.itemId`,
|
||
params,
|
||
CHANNEL_SPU_ENRICHMENT_QUERY_OPTIONS
|
||
);
|
||
|
||
(rows || []).forEach((row) => {
|
||
const itemId = normalizeText(row.itemId);
|
||
if (!itemId) return;
|
||
metricsMap.set(itemId, {
|
||
itemId,
|
||
goodsName: normalizeText(row.goodsName),
|
||
imageUrl: normalizeText(row.imageUrl),
|
||
goodsUv: toNullableNumber(row.goodsUv, 0),
|
||
payOrdrAmt: toNullableNumber(row.payOrdrAmt, 2),
|
||
goodsVcr: toNullableNumber(row.goodsVcr, 6),
|
||
imprUsrCnt: toNullableNumber(row.imprUsrCnt, 0),
|
||
goodsPtHelpRate: normalizeRatioValue(row.goodsPtHelpRate, 6),
|
||
impression: toNullableNumber(row.impression, 0),
|
||
ctr: toNullableNumber(row.ctr, 6),
|
||
gmv: toNullableNumber(row.gmv, 2),
|
||
netGmv: toNullableNumber(row.netGmv, 2),
|
||
actualRoi: toNullableNumber(row.actualRoi, 2),
|
||
netRoi: toNullableNumber(row.netRoi, 2),
|
||
targetRoi: normalizePddTargetRoiValue(row.targetRoi, 2),
|
||
globalTakeRate: normalizeRatioValue(row.globalTakeRate, 6),
|
||
settlementRoi: toNullableNumber(row.settlementRoi, 2),
|
||
refundRate: normalizeRatioValue(row.refundRate, 6),
|
||
spend: toNullableNumber(row.spend, 2),
|
||
});
|
||
});
|
||
} catch (error) {
|
||
console.warn('[Channel] 查询ID维度拼多多指标失败,先返回主数据:', error.message || error);
|
||
}
|
||
}
|
||
|
||
return metricsMap;
|
||
}
|
||
|
||
async function fetchChannelIdBasicRankMetrics(rows = [], startDate, endDate) {
|
||
const rowPairs = (rows || [])
|
||
.map((row) => ({
|
||
itemId: normalizeText(row.itemId),
|
||
shopKey: normalizeText(row.shopKey),
|
||
}))
|
||
.filter((row) => row.itemId && row.shopKey);
|
||
if (!rowPairs.length) return new Map();
|
||
|
||
const uniqueItemIds = uniqueText(rowPairs.map((row) => row.itemId));
|
||
const metricsMap = new Map();
|
||
|
||
for (const itemChunk of chunkArray(uniqueItemIds, 200)) {
|
||
const params = {
|
||
startDate,
|
||
endDate,
|
||
};
|
||
const placeholders = itemChunk.map((itemId, index) => {
|
||
const key = `rankItemId${index}`;
|
||
params[key] = itemId;
|
||
return `@${key}`;
|
||
});
|
||
|
||
try {
|
||
const rowsResult = await cloudDb.query(
|
||
`SELECT
|
||
REPLACE(REPLACE(LTRIM(RTRIM(r.shop)), N'_', N''), N'-', N'') AS shopKey,
|
||
LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), r.item_id))) AS itemId,
|
||
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), r.payment), 0)) AS payment,
|
||
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), r.pay_buyer_num), 0)) AS payBuyerNum,
|
||
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), r.item_visitor_num), 0)) AS goodsUv,
|
||
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), r.add_purchase_num), 0)) AS addPurchaseNum,
|
||
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), r.search_guide_views_num), 0)) AS searchGuideViewsNum
|
||
FROM dwd.dwd_ope_basic_id_rank_di r
|
||
WHERE r.stat_date BETWEEN @startDate AND @endDate
|
||
AND r.item_id IS NOT NULL
|
||
AND LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), r.item_id))) IN (${placeholders.join(', ')})
|
||
GROUP BY
|
||
REPLACE(REPLACE(LTRIM(RTRIM(r.shop)), N'_', N''), N'-', N''),
|
||
LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), r.item_id)))`,
|
||
params,
|
||
CHANNEL_SPU_ENRICHMENT_QUERY_OPTIONS
|
||
);
|
||
|
||
(rowsResult || []).forEach((row) => {
|
||
const itemId = normalizeText(row.itemId);
|
||
const shopKey = normalizeText(row.shopKey);
|
||
if (!itemId || !shopKey) return;
|
||
const goodsUv = toNullableNumber(row.goodsUv, 0);
|
||
const payBuyerNum = toNullableNumber(row.payBuyerNum, 0);
|
||
metricsMap.set(`${shopKey}__${itemId}`, {
|
||
payment: toNullableNumber(row.payment, 2),
|
||
payBuyerNum,
|
||
goodsUv,
|
||
goodsVcr: Number.isFinite(Number(goodsUv)) && Number(goodsUv) > 0 && Number.isFinite(Number(payBuyerNum))
|
||
? toNullableNumber(Number(payBuyerNum) / Number(goodsUv), 6)
|
||
: null,
|
||
addPurchaseNum: toNullableNumber(row.addPurchaseNum, 0),
|
||
searchGuideViewsNum: toNullableNumber(row.searchGuideViewsNum, 0),
|
||
});
|
||
});
|
||
} catch (error) {
|
||
console.warn('[Channel] 查询ID维度基础流量指标失败,先返回主数据:', error.message || error);
|
||
}
|
||
}
|
||
|
||
return metricsMap;
|
||
}
|
||
|
||
async function fetchChannelIdPromoteMetrics(rows = [], startDate, endDate) {
|
||
const rowPairs = (rows || [])
|
||
.map((row) => ({
|
||
itemId: normalizeText(row.itemId),
|
||
shopKey: normalizeText(row.shopKey),
|
||
}))
|
||
.filter((row) => row.itemId && row.shopKey);
|
||
if (!rowPairs.length) return new Map();
|
||
|
||
const uniqueItemIds = uniqueText(rowPairs.map((row) => row.itemId));
|
||
const metricsMap = new Map();
|
||
|
||
for (const itemChunk of chunkArray(uniqueItemIds, 200)) {
|
||
const params = {
|
||
startDate,
|
||
endDate,
|
||
};
|
||
const placeholders = itemChunk.map((itemId, index) => {
|
||
const key = `promoteItemId${index}`;
|
||
params[key] = itemId;
|
||
return `@${key}`;
|
||
});
|
||
|
||
try {
|
||
const rowsResult = await cloudDb.query(
|
||
`SELECT
|
||
REPLACE(REPLACE(LTRIM(RTRIM(p.shopname)), N'_', N''), N'-', N'') AS shopKey,
|
||
LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), p.item_id))) AS itemId,
|
||
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), p.cost_charge), 0)) AS costCharge,
|
||
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), p.sup_charge), 0)) AS supCharge,
|
||
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), p.strategy_ids), 0)) AS strategyIds,
|
||
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), p.strategy_keywords), 0)) AS strategyKeywords,
|
||
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), p.strategy_person), 0)) AS strategyPerson,
|
||
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), p.strategy_scene), 0)) AS strategyScene
|
||
FROM dwd.dwd_ope_basic_id_promote_di p
|
||
WHERE p.stat_date BETWEEN @startDate AND @endDate
|
||
AND p.item_id IS NOT NULL
|
||
AND LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), p.item_id))) IN (${placeholders.join(', ')})
|
||
GROUP BY
|
||
REPLACE(REPLACE(LTRIM(RTRIM(p.shopname)), N'_', N''), N'-', N''),
|
||
LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), p.item_id)))`,
|
||
params,
|
||
CHANNEL_SPU_ENRICHMENT_QUERY_OPTIONS
|
||
);
|
||
|
||
(rowsResult || []).forEach((row) => {
|
||
const itemId = normalizeText(row.itemId);
|
||
const shopKey = normalizeText(row.shopKey);
|
||
if (!itemId || !shopKey) return;
|
||
const strategyIds = Number(row.strategyIds || 0);
|
||
const strategyKeywords = Number(row.strategyKeywords || 0);
|
||
const strategyPerson = Number(row.strategyPerson || 0);
|
||
const strategyScene = Number(row.strategyScene || 0);
|
||
metricsMap.set(`${shopKey}__${itemId}`, {
|
||
costCharge: toNullableNumber(row.costCharge, 2),
|
||
supCharge: toNullableNumber(row.supCharge, 2),
|
||
strategyIds: toNullableNumber(strategyIds, 2),
|
||
strategyKeywords: toNullableNumber(strategyKeywords, 2),
|
||
strategyPerson: toNullableNumber(strategyPerson, 2),
|
||
strategyScene: toNullableNumber(strategyScene, 2),
|
||
promotionScore: toNullableNumber(
|
||
strategyIds + strategyKeywords + strategyPerson + strategyScene,
|
||
2
|
||
),
|
||
});
|
||
});
|
||
} catch (error) {
|
||
console.warn('[Channel] 查询ID维度推广策略指标失败,先返回主数据:', error.message || error);
|
||
}
|
||
}
|
||
|
||
return metricsMap;
|
||
}
|
||
|
||
const CHANNEL_DATA_EXPORT_COLUMNS = [
|
||
{ key: 'channelName', title: '渠道' },
|
||
{ key: 'shopName', title: '店铺' },
|
||
{ key: 'spu', title: 'SPU' },
|
||
{ key: 'skuCode', title: 'SKU编码' },
|
||
{ key: 'skuImageUrl', title: '图片链接' },
|
||
{ key: 'saleStatus', title: '状态(停产/在售等)' },
|
||
{ key: 'salesAmount', title: '销售额', type: 'amount' },
|
||
{ key: 'afterSaleAmount', title: '售后金额', type: 'amount' },
|
||
{ key: 'afterSaleAmountRate', title: '售后占比', type: 'percent' },
|
||
{ key: 'shipCount', title: '发货件数', type: 'number' },
|
||
{ key: 'refundCount', title: '退货件数', type: 'number' },
|
||
{ key: 'refundRate', title: '退货率', type: 'percent' },
|
||
{ key: 'purchaserName', title: '采购负责人' },
|
||
{ key: 'realtimeStock', title: '实时库存', type: 'integer' },
|
||
{ key: 'avg30DaySales', title: '平均30天日销', type: 'number' },
|
||
{ key: 'estimatedSalesDays', title: '预估销售天数', type: 'days' },
|
||
{ key: 'salesAmountZone1', title: '业绩分布-一区', type: 'amount' },
|
||
{ key: 'salesAmountZone2', title: '业绩分布-二区', type: 'amount' },
|
||
{ key: 'salesAmountZone3', title: '业绩分布-三区', type: 'amount' },
|
||
{ key: 'salesAmountZone4', title: '业绩分布-四区', type: 'amount' },
|
||
{ key: 'afterSaleAmountZone1', title: '售后金额分布-一区', type: 'amount' },
|
||
{ key: 'afterSaleAmountZone2', title: '售后金额分布-二区', type: 'amount' },
|
||
{ key: 'afterSaleAmountZone3', title: '售后金额分布-三区', type: 'amount' },
|
||
{ key: 'afterSaleAmountZone4', title: '售后金额分布-四区', type: 'amount' },
|
||
{ key: 'afterSaleAmountRateZone1', title: '售后金额占比分布-一区', type: 'percent' },
|
||
{ key: 'afterSaleAmountRateZone2', title: '售后金额占比分布-二区', type: 'percent' },
|
||
{ key: 'afterSaleAmountRateZone3', title: '售后金额占比分布-三区', type: 'percent' },
|
||
{ key: 'afterSaleAmountRateZone4', title: '售后金额占比分布-四区', type: 'percent' },
|
||
{ key: 'shippingZone1Avg', title: '近30天一区平均运费', type: 'amount' },
|
||
{ key: 'shippingZone2Avg', title: '近30天二区平均运费', type: 'amount' },
|
||
{ key: 'shippingZone3Avg', title: '近30天三区平均运费', type: 'amount' },
|
||
{ key: 'shippingZone4Avg', title: '近30天四区平均运费', type: 'amount' },
|
||
];
|
||
|
||
function normalizeChannelReadStoreRow(row) {
|
||
return {
|
||
channelName: row.channelName || '未归类',
|
||
shopName: row.shopName || '',
|
||
shopKey: row.shopKey || '',
|
||
spu: row.spu || '',
|
||
skuCode: row.skuCode || '',
|
||
skuImageUrl: row.skuImageUrl || null,
|
||
saleStatus: row.saleStatus || '未配置',
|
||
salesAmount: toNumber(row.salesAmount),
|
||
afterSaleAmount: toNumber(row.afterSaleAmount),
|
||
afterSaleAmountRate: toNumber(row.afterSaleAmountRate),
|
||
shipCount: toNumber(row.shipCount),
|
||
refundCount: toNumber(row.refundCount),
|
||
refundRate: toNumber(row.refundRate),
|
||
salesAmountZone1: toNumber(row.salesAmountZone1),
|
||
salesAmountZone2: toNumber(row.salesAmountZone2),
|
||
salesAmountZone3: toNumber(row.salesAmountZone3),
|
||
salesAmountZone4: toNumber(row.salesAmountZone4),
|
||
afterSaleAmountZone1: toNumber(row.afterSaleAmountZone1),
|
||
afterSaleAmountZone2: toNumber(row.afterSaleAmountZone2),
|
||
afterSaleAmountZone3: toNumber(row.afterSaleAmountZone3),
|
||
afterSaleAmountZone4: toNumber(row.afterSaleAmountZone4),
|
||
afterSaleAmountRateZone1: toNumber(row.afterSaleAmountRateZone1),
|
||
afterSaleAmountRateZone2: toNumber(row.afterSaleAmountRateZone2),
|
||
afterSaleAmountRateZone3: toNumber(row.afterSaleAmountRateZone3),
|
||
afterSaleAmountRateZone4: toNumber(row.afterSaleAmountRateZone4),
|
||
purchaserName: row.purchaserName || '未配置',
|
||
};
|
||
}
|
||
|
||
function mergeChannelEnrichment(row, rollingMap, inventoryMap, freightMap) {
|
||
const pairKey = `${row.shopKey}__${row.skuCode}`;
|
||
const shipCount30d = rollingMap.get(pairKey) || 0;
|
||
const realtimeStock = inventoryMap.get(row.skuCode) ?? inventoryMap.get(normalizeSkuCodeKey(row.skuCode)) ?? 0;
|
||
const freight = freightMap.get(pairKey) || {};
|
||
|
||
return {
|
||
channelName: row.channelName,
|
||
shopName: row.shopName,
|
||
spu: row.spu,
|
||
skuCode: row.skuCode,
|
||
skuImageUrl: row.skuImageUrl,
|
||
saleStatus: row.saleStatus,
|
||
salesAmount: row.salesAmount,
|
||
afterSaleAmount: row.afterSaleAmount,
|
||
afterSaleAmountRate: row.afterSaleAmountRate,
|
||
shipCount: row.shipCount,
|
||
refundCount: row.refundCount,
|
||
refundRate: row.refundRate,
|
||
salesAmountZone1: row.salesAmountZone1,
|
||
salesAmountZone2: row.salesAmountZone2,
|
||
salesAmountZone3: row.salesAmountZone3,
|
||
salesAmountZone4: row.salesAmountZone4,
|
||
afterSaleAmountZone1: row.afterSaleAmountZone1,
|
||
afterSaleAmountZone2: row.afterSaleAmountZone2,
|
||
afterSaleAmountZone3: row.afterSaleAmountZone3,
|
||
afterSaleAmountZone4: row.afterSaleAmountZone4,
|
||
afterSaleAmountRateZone1: row.afterSaleAmountRateZone1,
|
||
afterSaleAmountRateZone2: row.afterSaleAmountRateZone2,
|
||
afterSaleAmountRateZone3: row.afterSaleAmountRateZone3,
|
||
afterSaleAmountRateZone4: row.afterSaleAmountRateZone4,
|
||
purchaserName: row.purchaserName,
|
||
realtimeStock,
|
||
avg30DaySales: toNumber(shipCount30d / 30, 2),
|
||
estimatedSalesDays: shipCount30d > 0 ? toNumber(realtimeStock * 30 / shipCount30d, 1) : null,
|
||
shippingZone1Avg: freight.shippingZone1Avg ?? null,
|
||
shippingZone2Avg: freight.shippingZone2Avg ?? null,
|
||
shippingZone3Avg: freight.shippingZone3Avg ?? null,
|
||
shippingZone4Avg: freight.shippingZone4Avg ?? null,
|
||
};
|
||
}
|
||
|
||
async function enrichChannelReadRows(normalizedRows, lookbackStartDate, endDate) {
|
||
if (!normalizedRows.length) return [];
|
||
|
||
const [rollingMap, inventoryMap, freightMap] = await Promise.all([
|
||
fetchRollingSalesFromReadStore(normalizedRows, lookbackStartDate, endDate),
|
||
fetchInventoryForRows(normalizedRows),
|
||
fetchFreightForRows(normalizedRows, lookbackStartDate, endDate),
|
||
]);
|
||
|
||
return normalizedRows.map((row) => mergeChannelEnrichment(row, rollingMap, inventoryMap, freightMap));
|
||
}
|
||
|
||
async function enrichChannelExportRows(normalizedRows, lookbackStartDate, endDate) {
|
||
if (!normalizedRows.length) return [];
|
||
|
||
const inventoryMap = await fetchInventoryForSkuCodes(normalizedRows.map((row) => row.skuCode));
|
||
const enrichedRows = [];
|
||
|
||
for (const rowChunk of chunkArray(normalizedRows, CHANNEL_EXPORT_BATCH_SIZE)) {
|
||
const [rollingMap, freightMap] = await Promise.all([
|
||
fetchRollingSalesFromReadStore(rowChunk, lookbackStartDate, endDate),
|
||
fetchFreightForRows(rowChunk, lookbackStartDate, endDate),
|
||
]);
|
||
|
||
enrichedRows.push(...rowChunk.map((row) => mergeChannelEnrichment(row, rollingMap, inventoryMap, freightMap)));
|
||
}
|
||
|
||
return enrichedRows;
|
||
}
|
||
|
||
function buildFastChannelExportRows(normalizedRows, dateRange) {
|
||
const rangeDays = dayjs(dateRange.endDate).diff(dayjs(dateRange.startDate), 'day') + 1;
|
||
const canUseRangeAsThirtyDayWindow = (
|
||
dateRange.startDate === dateRange.lookbackStartDate
|
||
&& rangeDays >= DEFAULT_LOOKBACK_DAYS - 1
|
||
&& rangeDays <= DEFAULT_LOOKBACK_DAYS + 1
|
||
);
|
||
|
||
return normalizedRows.map((row) => ({
|
||
channelName: row.channelName,
|
||
shopName: row.shopName,
|
||
spu: row.spu,
|
||
skuCode: row.skuCode,
|
||
skuImageUrl: row.skuImageUrl,
|
||
saleStatus: row.saleStatus,
|
||
salesAmount: row.salesAmount,
|
||
afterSaleAmount: row.afterSaleAmount,
|
||
afterSaleAmountRate: row.afterSaleAmountRate,
|
||
shipCount: row.shipCount,
|
||
refundCount: row.refundCount,
|
||
refundRate: row.refundRate,
|
||
salesAmountZone1: row.salesAmountZone1,
|
||
salesAmountZone2: row.salesAmountZone2,
|
||
salesAmountZone3: row.salesAmountZone3,
|
||
salesAmountZone4: row.salesAmountZone4,
|
||
afterSaleAmountZone1: row.afterSaleAmountZone1,
|
||
afterSaleAmountZone2: row.afterSaleAmountZone2,
|
||
afterSaleAmountZone3: row.afterSaleAmountZone3,
|
||
afterSaleAmountZone4: row.afterSaleAmountZone4,
|
||
afterSaleAmountRateZone1: row.afterSaleAmountRateZone1,
|
||
afterSaleAmountRateZone2: row.afterSaleAmountRateZone2,
|
||
afterSaleAmountRateZone3: row.afterSaleAmountRateZone3,
|
||
afterSaleAmountRateZone4: row.afterSaleAmountRateZone4,
|
||
purchaserName: row.purchaserName,
|
||
realtimeStock: null,
|
||
avg30DaySales: canUseRangeAsThirtyDayWindow ? toNumber(row.shipCount / DEFAULT_LOOKBACK_DAYS, 2) : null,
|
||
estimatedSalesDays: null,
|
||
shippingZone1Avg: null,
|
||
shippingZone2Avg: null,
|
||
shippingZone3Avg: null,
|
||
shippingZone4Avg: null,
|
||
}));
|
||
}
|
||
|
||
function formatChannelExportCell(column, row) {
|
||
const value = row?.[column.key];
|
||
if (value === null || value === undefined || value === '') return '--';
|
||
|
||
const numeric = Number(value);
|
||
if (['amount', 'number'].includes(column.type)) {
|
||
return Number.isFinite(numeric) ? numeric.toFixed(2) : value;
|
||
}
|
||
if (column.type === 'percent') {
|
||
return Number.isFinite(numeric) ? `${numeric.toFixed(2)}%` : value;
|
||
}
|
||
if (column.type === 'integer') {
|
||
return Number.isFinite(numeric) ? String(Math.round(numeric)) : value;
|
||
}
|
||
if (column.type === 'days') {
|
||
return Number.isFinite(numeric) ? numeric.toFixed(1) : value;
|
||
}
|
||
|
||
if (typeof value === 'object') return JSON.stringify(value);
|
||
return String(value);
|
||
}
|
||
|
||
function escapeChannelCsvCell(value) {
|
||
const text = String(value ?? '--');
|
||
if (/[",\r\n]/.test(text)) {
|
||
return `"${text.replace(/"/g, '""')}"`;
|
||
}
|
||
return text;
|
||
}
|
||
|
||
function buildChannelExportCsv(rows) {
|
||
const headerLine = CHANNEL_DATA_EXPORT_COLUMNS.map((column) => escapeChannelCsvCell(column.title)).join(',');
|
||
const bodyLines = rows.map((row) => CHANNEL_DATA_EXPORT_COLUMNS.map((column) => (
|
||
escapeChannelCsvCell(formatChannelExportCell(column, row))
|
||
)).join(','));
|
||
|
||
return `\uFEFF${[headerLine, ...bodyLines].join('\r\n')}`;
|
||
}
|
||
|
||
function serializeChannelExportJob(job) {
|
||
if (!job) return null;
|
||
|
||
return {
|
||
id: job.id,
|
||
status: job.status,
|
||
progress: job.progress,
|
||
stage: job.stage,
|
||
error: job.error,
|
||
filename: job.filename,
|
||
totalRows: job.totalRows,
|
||
totalBytes: job.totalBytes,
|
||
exportMode: job.exportMode,
|
||
dateRange: job.dateRange,
|
||
downloadUrl: job.downloadUrl,
|
||
createdAt: job.createdAt,
|
||
updatedAt: job.updatedAt,
|
||
completedAt: job.completedAt,
|
||
expiresAt: job.expiresAt,
|
||
};
|
||
}
|
||
|
||
function updateChannelExportJob(jobId, patch) {
|
||
const job = channelExportJobStore.get(jobId);
|
||
if (!job) return null;
|
||
|
||
const nextProgress = patch.progress === undefined
|
||
? job.progress
|
||
: Math.max(0, Math.min(100, Number(patch.progress) || 0));
|
||
const nextJob = {
|
||
...job,
|
||
...patch,
|
||
progress: nextProgress,
|
||
updatedAt: new Date().toISOString(),
|
||
};
|
||
|
||
channelExportJobStore.set(jobId, nextJob);
|
||
return nextJob;
|
||
}
|
||
|
||
function getChannelExportJobDownloadUrl(jobId) {
|
||
return `/api/channel/export-jobs/${encodeURIComponent(jobId)}/download`;
|
||
}
|
||
|
||
function isChannelExportJobExpired(job) {
|
||
return Boolean(job?.expiresAt && Date.parse(job.expiresAt) <= Date.now());
|
||
}
|
||
|
||
function cleanupChannelExportJobs() {
|
||
for (const [jobId, job] of channelExportJobStore.entries()) {
|
||
if (!isChannelExportJobExpired(job)) continue;
|
||
channelExportJobStore.delete(jobId);
|
||
if (job.filePath) {
|
||
fs.promises.unlink(job.filePath).catch(() => {});
|
||
}
|
||
}
|
||
}
|
||
|
||
function pickChannelExportOptions(options = {}) {
|
||
return {
|
||
startDate: options.startDate,
|
||
endDate: options.endDate,
|
||
keyword: options.keyword,
|
||
sortField: options.sortField,
|
||
sortOrder: options.sortOrder,
|
||
channels: options.channels,
|
||
statuses: options.statuses,
|
||
purchasers: options.purchasers,
|
||
minAfterSaleAmountRate: options.minAfterSaleAmountRate,
|
||
maxAfterSaleAmountRate: options.maxAfterSaleAmountRate,
|
||
};
|
||
}
|
||
|
||
async function runChannelExportJob(jobId) {
|
||
const job = channelExportJobStore.get(jobId);
|
||
if (!job) return;
|
||
|
||
try {
|
||
updateChannelExportJob(jobId, {
|
||
status: 'running',
|
||
progress: 2,
|
||
stage: '开始生成导出文件',
|
||
});
|
||
|
||
const data = await exportChannelListCsv(job.options, {
|
||
onProgress: ({ progress, stage }) => {
|
||
updateChannelExportJob(jobId, { progress, stage });
|
||
},
|
||
});
|
||
|
||
updateChannelExportJob(jobId, {
|
||
progress: 94,
|
||
stage: '正在写入下载文件',
|
||
});
|
||
|
||
await fs.promises.mkdir(CHANNEL_EXPORT_JOB_DIR, { recursive: true });
|
||
const filePath = path.join(CHANNEL_EXPORT_JOB_DIR, `${jobId}.csv`);
|
||
const csvBuffer = Buffer.from(data.csv || '', 'utf8');
|
||
await fs.promises.writeFile(filePath, csvBuffer);
|
||
|
||
updateChannelExportJob(jobId, {
|
||
status: 'completed',
|
||
progress: 100,
|
||
stage: '文件已生成',
|
||
filename: data.filename,
|
||
totalRows: data.total,
|
||
totalBytes: csvBuffer.length,
|
||
exportMode: data.exportMode,
|
||
dateRange: data.dateRange,
|
||
filePath,
|
||
downloadUrl: getChannelExportJobDownloadUrl(jobId),
|
||
completedAt: new Date().toISOString(),
|
||
expiresAt: new Date(Date.now() + CHANNEL_EXPORT_JOB_RETENTION_MS).toISOString(),
|
||
});
|
||
} catch (error) {
|
||
updateChannelExportJob(jobId, {
|
||
status: 'failed',
|
||
progress: 100,
|
||
stage: '生成失败',
|
||
error: error.message || '生成导出文件失败',
|
||
expiresAt: new Date(Date.now() + CHANNEL_EXPORT_JOB_RETENTION_MS).toISOString(),
|
||
});
|
||
}
|
||
}
|
||
|
||
function createChannelExportJob(options = {}, user = {}) {
|
||
cleanupChannelExportJobs();
|
||
|
||
const now = new Date().toISOString();
|
||
const jobId = typeof crypto.randomUUID === 'function'
|
||
? crypto.randomUUID()
|
||
: crypto.randomBytes(16).toString('hex');
|
||
const job = {
|
||
id: jobId,
|
||
status: 'queued',
|
||
progress: 0,
|
||
stage: '等待生成',
|
||
options: pickChannelExportOptions(options),
|
||
ownerId: user?.id || user?.userId || user?.username || null,
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
expiresAt: new Date(Date.now() + CHANNEL_EXPORT_JOB_RETENTION_MS).toISOString(),
|
||
};
|
||
|
||
channelExportJobStore.set(jobId, job);
|
||
setImmediate(() => {
|
||
runChannelExportJob(jobId).catch((error) => {
|
||
updateChannelExportJob(jobId, {
|
||
status: 'failed',
|
||
progress: 100,
|
||
stage: '生成失败',
|
||
error: error.message || '生成导出文件失败',
|
||
});
|
||
});
|
||
});
|
||
|
||
return serializeChannelExportJob(job);
|
||
}
|
||
|
||
async function getChannelExportJob(jobId) {
|
||
cleanupChannelExportJobs();
|
||
|
||
const job = channelExportJobStore.get(jobId);
|
||
if (!job) {
|
||
const error = new Error('导出任务不存在或已过期,请重新生成');
|
||
error.statusCode = 404;
|
||
throw error;
|
||
}
|
||
|
||
return serializeChannelExportJob(job);
|
||
}
|
||
|
||
async function getChannelExportJobFile(jobId) {
|
||
cleanupChannelExportJobs();
|
||
|
||
const job = channelExportJobStore.get(jobId);
|
||
if (!job) {
|
||
const error = new Error('导出任务不存在或已过期,请重新生成');
|
||
error.statusCode = 404;
|
||
throw error;
|
||
}
|
||
if (job.status !== 'completed' || !job.filePath) {
|
||
const error = new Error('导出文件尚未生成完成');
|
||
error.statusCode = 409;
|
||
throw error;
|
||
}
|
||
|
||
await fs.promises.access(job.filePath, fs.constants.R_OK);
|
||
|
||
return {
|
||
job: serializeChannelExportJob(job),
|
||
filePath: job.filePath,
|
||
filename: job.filename || `channel-export-${jobId}.csv`,
|
||
};
|
||
}
|
||
|
||
async function getChannelListFromReadStore({
|
||
startDate,
|
||
endDate,
|
||
lookbackStartDate,
|
||
page,
|
||
pageSize,
|
||
keyword,
|
||
sortField,
|
||
sortOrder,
|
||
channels,
|
||
statuses,
|
||
purchasers,
|
||
minAfterSaleAmountRate,
|
||
maxAfterSaleAmountRate,
|
||
latestBizDate,
|
||
}) {
|
||
const { sql, params } = buildReadStoreFilteredSql({
|
||
startDate,
|
||
endDate,
|
||
keyword,
|
||
channels,
|
||
statuses,
|
||
purchasers,
|
||
minAfterSaleAmountRate,
|
||
maxAfterSaleAmountRate,
|
||
});
|
||
|
||
const total = await countReadStoreRows({
|
||
startDate,
|
||
endDate,
|
||
keyword,
|
||
channels,
|
||
statuses,
|
||
purchasers,
|
||
minAfterSaleAmountRate,
|
||
maxAfterSaleAmountRate,
|
||
}, sql, params);
|
||
if (total === 0) {
|
||
return {
|
||
page,
|
||
pageSize,
|
||
total: 0,
|
||
rows: [],
|
||
dateRange: {
|
||
startDate,
|
||
endDate,
|
||
latestBizDate,
|
||
lookbackStartDate,
|
||
},
|
||
meta: {
|
||
source: 'read-store',
|
||
cacheHit: false,
|
||
},
|
||
};
|
||
}
|
||
|
||
const offset = (page - 1) * pageSize;
|
||
const orderSql = `${resolveReadSortExpression(sortField)} ${resolveSortOrder(sortOrder)}, base.shopName ASC, base.skuCode ASC`;
|
||
await ensurePlanningChannelDailyReadSchema();
|
||
const pageRows = await channelReadDb.query(
|
||
`SELECT * FROM (${sql}) base ORDER BY ${orderSql} LIMIT ? OFFSET ?`,
|
||
[...params, pageSize, offset]
|
||
);
|
||
|
||
const normalizedPageRows = (pageRows || []).map(normalizeChannelReadStoreRow);
|
||
const rows = await enrichChannelReadRows(normalizedPageRows, lookbackStartDate, endDate);
|
||
|
||
return {
|
||
page,
|
||
pageSize,
|
||
total,
|
||
rows,
|
||
dateRange: {
|
||
startDate,
|
||
endDate,
|
||
latestBizDate,
|
||
lookbackStartDate,
|
||
},
|
||
meta: {
|
||
source: 'read-store',
|
||
cacheHit: false,
|
||
},
|
||
};
|
||
}
|
||
|
||
async function exportChannelListCsv(options = {}, context = {}) {
|
||
const reportProgress = (progress, stage) => {
|
||
if (typeof context.onProgress === 'function') {
|
||
context.onProgress({ progress, stage });
|
||
}
|
||
};
|
||
|
||
reportProgress(6, '解析导出条件');
|
||
const dateRange = await resolveDateRange(options.startDate, options.endDate);
|
||
reportProgress(14, '查询渠道汇总数据');
|
||
const filterOptions = {
|
||
startDate: dateRange.startDate,
|
||
endDate: dateRange.endDate,
|
||
keyword: options.keyword,
|
||
channels: options.channels,
|
||
statuses: options.statuses,
|
||
purchasers: options.purchasers,
|
||
minAfterSaleAmountRate: options.minAfterSaleAmountRate,
|
||
maxAfterSaleAmountRate: options.maxAfterSaleAmountRate,
|
||
};
|
||
const { sql, params } = buildReadStoreFilteredSql(filterOptions);
|
||
const orderSql = `${resolveReadSortExpression(options.sortField)} ${resolveSortOrder(options.sortOrder)}, base.shopName ASC, base.skuCode ASC`;
|
||
await ensurePlanningChannelDailyReadSchema();
|
||
const exportRows = await channelReadDb.query(
|
||
`SELECT * FROM (${sql}) base ORDER BY ${orderSql} LIMIT ?`,
|
||
[...params, CHANNEL_EXPORT_MAX_ROWS + 1]
|
||
);
|
||
const total = Array.isArray(exportRows) ? exportRows.length : 0;
|
||
reportProgress(58, `已汇总 ${total.toLocaleString('zh-CN')} 条数据`);
|
||
|
||
if (total > CHANNEL_EXPORT_MAX_ROWS) {
|
||
const error = new Error(`当前条件超过单次导出上限 ${CHANNEL_EXPORT_MAX_ROWS.toLocaleString('zh-CN')} 条,请先缩小日期或筛选条件`);
|
||
error.statusCode = 400;
|
||
throw error;
|
||
}
|
||
|
||
const normalizedRows = (exportRows || []).map(normalizeChannelReadStoreRow);
|
||
const shouldUseExactEnrichment = total <= CHANNEL_EXPORT_FULL_ENRICH_MAX_ROWS;
|
||
reportProgress(
|
||
shouldUseExactEnrichment ? 64 : 76,
|
||
shouldUseExactEnrichment ? '补充库存、运费和近30天日销' : '整理快速汇总数据'
|
||
);
|
||
const outputRows = shouldUseExactEnrichment
|
||
? await enrichChannelExportRows(normalizedRows, dateRange.lookbackStartDate, dateRange.endDate)
|
||
: buildFastChannelExportRows(normalizedRows, dateRange);
|
||
reportProgress(86, '生成 CSV 内容');
|
||
const csv = buildChannelExportCsv(outputRows);
|
||
reportProgress(92, 'CSV 内容生成完成');
|
||
|
||
return {
|
||
filename: `渠道数据-${dateRange.latestBizDate || dateRange.endDate}-全部${total}条.csv`,
|
||
total,
|
||
exportMode: shouldUseExactEnrichment ? 'exact-enriched' : 'fast-summary',
|
||
dateRange: {
|
||
startDate: dateRange.startDate,
|
||
endDate: dateRange.endDate,
|
||
latestBizDate: dateRange.latestBizDate,
|
||
lookbackStartDate: dateRange.lookbackStartDate,
|
||
},
|
||
csv,
|
||
};
|
||
}
|
||
|
||
async function getChannelListDirect({
|
||
startDate,
|
||
endDate,
|
||
page,
|
||
pageSize,
|
||
keyword,
|
||
sortField,
|
||
sortOrder,
|
||
channels,
|
||
statuses,
|
||
purchasers,
|
||
minAfterSaleAmountRate,
|
||
maxAfterSaleAmountRate,
|
||
}, dateRangeOverride = null) {
|
||
const resolvedPage = safePage(page);
|
||
const resolvedPageSize = safePageSize(pageSize);
|
||
const dateRange = dateRangeOverride || await resolveDateRange(startDate, endDate);
|
||
const params = {
|
||
startDate: dateRange.startDate,
|
||
endDate: dateRange.endDate,
|
||
lookbackStartDate: dateRange.lookbackStartDate,
|
||
rowStart: (resolvedPage - 1) * resolvedPageSize + 1,
|
||
rowEnd: resolvedPage * resolvedPageSize,
|
||
};
|
||
const excludedSkuSql = buildExcludedSkuInClause(params);
|
||
|
||
const whereParts = [];
|
||
if (keyword && String(keyword).trim()) {
|
||
params.keyword = `%${String(keyword).trim()}%`;
|
||
whereParts.push(`(
|
||
bm.channel_name LIKE @keyword
|
||
OR bm.shop_name LIKE @keyword
|
||
OR COALESCE(NULLIF(sk.product_code, N''), bm.sku_code) LIKE @keyword
|
||
OR bm.sku_code LIKE @keyword
|
||
OR ${PURCHASER_SQL} LIKE @keyword
|
||
)`);
|
||
}
|
||
|
||
addListFilter(whereParts, params, parseListParam(channels), 'bm.channel_name', 'channelFilter');
|
||
addListFilter(whereParts, params, parseListParam(statuses), `COALESCE(NULLIF(sk.sale_status, N''), N'未配置')`, 'statusFilter');
|
||
addListFilter(whereParts, params, parseListParam(purchasers), PURCHASER_SQL, 'purchaserFilter');
|
||
addNumericRangeFilter(
|
||
whereParts,
|
||
params,
|
||
minAfterSaleAmountRate,
|
||
maxAfterSaleAmountRate,
|
||
`CASE WHEN bm.sales_amount > 0 THEN bm.after_sale_amount * 100.0 / bm.sales_amount ELSE 0 END`,
|
||
'afterSaleAmountRate'
|
||
);
|
||
|
||
const whereSql = whereParts.length > 0 ? `WHERE ${whereParts.join(' AND ')}` : '';
|
||
const orderSql = `${resolveSortExpression(sortField)} ${resolveSortOrder(sortOrder)}, bm.shop_name ASC, bm.sku_code ASC`;
|
||
const keywordText = String(keyword || '').trim();
|
||
const channelFilterValues = parseListParam(channels);
|
||
const statusFilterValues = parseListParam(statuses);
|
||
const purchaserFilterValues = parseListParam(purchasers);
|
||
const minAfterSaleRateValue = parseNumericFilter(minAfterSaleAmountRate);
|
||
const maxAfterSaleRateValue = parseNumericFilter(maxAfterSaleAmountRate);
|
||
const requiresDimensionBeforePaging =
|
||
Boolean(keywordText) ||
|
||
statusFilterValues.length > 0 ||
|
||
purchaserFilterValues.length > 0 ||
|
||
['spu', 'saleStatus', 'purchaserName'].includes(sortField);
|
||
|
||
if (!requiresDimensionBeforePaging) {
|
||
const fastParams = {
|
||
startDate: dateRange.startDate,
|
||
endDate: dateRange.endDate,
|
||
lookbackStartDate: dateRange.lookbackStartDate,
|
||
rowStart: (resolvedPage - 1) * resolvedPageSize + 1,
|
||
rowEnd: resolvedPage * resolvedPageSize,
|
||
};
|
||
const fastExcludedSkuSql = buildExcludedSkuInClause(fastParams);
|
||
const fastWhereParts = [];
|
||
addListFilter(fastWhereParts, fastParams, channelFilterValues, 'bm.channel_name', 'channelFilter');
|
||
addNumericRangeFilter(
|
||
fastWhereParts,
|
||
fastParams,
|
||
minAfterSaleRateValue,
|
||
maxAfterSaleRateValue,
|
||
`CASE WHEN bm.sales_amount > 0 THEN bm.after_sale_amount * 100.0 / bm.sales_amount ELSE 0 END`,
|
||
'afterSaleAmountRate'
|
||
);
|
||
const fastWhereSql = fastWhereParts.length > 0 ? `WHERE ${fastWhereParts.join(' AND ')}` : '';
|
||
const fastOrderSql = `${resolveBaseSortExpression(sortField)} ${resolveSortOrder(sortOrder)}, bm.shop_name ASC, bm.sku_code ASC`;
|
||
|
||
const fastSqlText = `
|
||
WITH order_zone_dim AS (
|
||
SELECT
|
||
LTRIM(RTRIM(a.undefined2)) AS order_id,
|
||
REPLACE(REPLACE(LTRIM(RTRIM(a.undefined8)), N'_', N''), N'-', N'') AS shop_key,
|
||
LTRIM(RTRIM(a.undefined10)) AS sku_code,
|
||
${ORDER_ZONE_CASE_SQL} AS zone_name
|
||
FROM dbo.jst_goods_profit_kpi_items_afs a
|
||
WHERE a.undefined2 IS NOT NULL
|
||
AND LTRIM(RTRIM(a.undefined2)) <> N''
|
||
AND a.undefined8 IS NOT NULL
|
||
AND LTRIM(RTRIM(a.undefined8)) <> N''
|
||
AND a.undefined10 IS NOT NULL
|
||
AND LTRIM(RTRIM(a.undefined10)) <> N''
|
||
AND a.undefined74 IS NOT NULL
|
||
AND LTRIM(RTRIM(a.undefined74)) <> N''
|
||
GROUP BY
|
||
LTRIM(RTRIM(a.undefined2)),
|
||
REPLACE(REPLACE(LTRIM(RTRIM(a.undefined8)), N'_', N''), N'-', N''),
|
||
LTRIM(RTRIM(a.undefined10)),
|
||
${ORDER_ZONE_CASE_SQL}
|
||
),
|
||
base_metrics AS (
|
||
SELECT
|
||
COALESCE(NULLIF(LTRIM(RTRIM(d.platform)), N''), N'未归类') AS channel_name,
|
||
LTRIM(RTRIM(d.shop_name)) AS shop_name,
|
||
REPLACE(REPLACE(LTRIM(RTRIM(d.shop_name)), N'_', N''), N'-', N'') AS shop_key,
|
||
LTRIM(RTRIM(d.skuoid)) AS sku_code,
|
||
SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.sales_amount), 0)) AS sales_amount,
|
||
SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.after_sale_amount), 0)) AS after_sale_amount,
|
||
SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.goods_count), 0)) AS ship_count,
|
||
SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.refund_count), 0)) AS refund_count,
|
||
${CHANNEL_ZONE_SELECT_SQL}
|
||
FROM ${CHANNEL_SOURCE_TABLE} d
|
||
LEFT JOIN order_zone_dim oz
|
||
ON oz.order_id = LTRIM(RTRIM(d.sourceno))
|
||
AND oz.shop_key = REPLACE(REPLACE(LTRIM(RTRIM(d.shop_name)), N'_', N''), N'-', N'')
|
||
AND oz.sku_code = LTRIM(RTRIM(d.skuoid))
|
||
WHERE d.stat_date BETWEEN @startDate AND @endDate
|
||
AND d.shop_name IS NOT NULL
|
||
AND LTRIM(RTRIM(d.shop_name)) <> N''
|
||
AND d.skuoid IS NOT NULL
|
||
AND LTRIM(RTRIM(d.skuoid)) <> N''
|
||
AND UPPER(LTRIM(RTRIM(d.skuoid))) NOT IN (${fastExcludedSkuSql})
|
||
${CHANNEL_NON_VIRTUAL_SOURCE_FILTER_SQL}
|
||
GROUP BY
|
||
COALESCE(NULLIF(LTRIM(RTRIM(d.platform)), N''), N'未归类'),
|
||
LTRIM(RTRIM(d.shop_name)),
|
||
REPLACE(REPLACE(LTRIM(RTRIM(d.shop_name)), N'_', N''), N'-', N''),
|
||
LTRIM(RTRIM(d.skuoid))
|
||
),
|
||
filtered_rows AS (
|
||
SELECT
|
||
bm.channel_name AS channelName,
|
||
bm.shop_name AS shopName,
|
||
bm.shop_key AS shopKey,
|
||
bm.sku_code AS skuCode,
|
||
CAST(ROUND(bm.sales_amount, 2) AS DECIMAL(18, 2)) AS salesAmount,
|
||
CAST(ROUND(bm.after_sale_amount, 2) AS DECIMAL(18, 2)) AS afterSaleAmount,
|
||
CAST(ROUND(CASE WHEN bm.sales_amount > 0 THEN bm.after_sale_amount * 100.0 / bm.sales_amount ELSE 0 END, 2) AS DECIMAL(18, 2)) AS afterSaleAmountRate,
|
||
CAST(ROUND(bm.ship_count, 2) AS DECIMAL(18, 2)) AS shipCount,
|
||
CAST(ROUND(bm.refund_count, 2) AS DECIMAL(18, 2)) AS refundCount,
|
||
CAST(ROUND(CASE WHEN bm.ship_count > 0 THEN bm.refund_count * 100.0 / bm.ship_count ELSE 0 END, 2) AS DECIMAL(18, 2)) AS refundRate,
|
||
CAST(ROUND(bm.sales_amount_zone1, 2) AS DECIMAL(18, 2)) AS salesAmountZone1,
|
||
CAST(ROUND(bm.sales_amount_zone2, 2) AS DECIMAL(18, 2)) AS salesAmountZone2,
|
||
CAST(ROUND(bm.sales_amount_zone3, 2) AS DECIMAL(18, 2)) AS salesAmountZone3,
|
||
CAST(ROUND(bm.sales_amount_zone4, 2) AS DECIMAL(18, 2)) AS salesAmountZone4,
|
||
CAST(ROUND(bm.after_sale_amount_zone1, 2) AS DECIMAL(18, 2)) AS afterSaleAmountZone1,
|
||
CAST(ROUND(bm.after_sale_amount_zone2, 2) AS DECIMAL(18, 2)) AS afterSaleAmountZone2,
|
||
CAST(ROUND(bm.after_sale_amount_zone3, 2) AS DECIMAL(18, 2)) AS afterSaleAmountZone3,
|
||
CAST(ROUND(bm.after_sale_amount_zone4, 2) AS DECIMAL(18, 2)) AS afterSaleAmountZone4,
|
||
COUNT(1) OVER() AS totalCount,
|
||
ROW_NUMBER() OVER (ORDER BY ${fastOrderSql}) AS rn
|
||
FROM base_metrics bm
|
||
${fastWhereSql}
|
||
),
|
||
paged_rows AS (
|
||
SELECT *
|
||
FROM filtered_rows
|
||
WHERE rn BETWEEN @rowStart AND @rowEnd
|
||
),
|
||
sku_dim AS (
|
||
SELECT
|
||
base.skuCode,
|
||
COALESCE(NULLIF(lib.product_code, N''), NULLIF(jsku.product_code, N''), base.skuCode) AS productCode,
|
||
COALESCE(
|
||
NULLIF(lib.sale_status, N''),
|
||
CASE WHEN ISNULL(jsku.enabled, 0) = 1 AND ISNULL(jsku.stock_disabled, 0) = 0 THEN N'销售' ELSE N'未配置' END
|
||
) AS saleStatus,
|
||
COALESCE(NULLIF(jsku.pic_big, N''), NULLIF(jsku.pic, N'')) AS skuImageUrl
|
||
FROM (SELECT DISTINCT skuCode FROM paged_rows) base
|
||
OUTER APPLY (
|
||
SELECT TOP 1
|
||
NULLIF(LTRIM(RTRIM(l.ItemNum)), N'') AS product_code,
|
||
COALESCE(NULLIF(LTRIM(RTRIM(l.ProStatus)), N''), N'未配置') AS sale_status
|
||
FROM dbo.fys_SkuDataLibraryNew l
|
||
WHERE l.SkuCode = base.skuCode
|
||
ORDER BY CASE WHEN l.ProStatus = N'销售' THEN 0 ELSE 1 END, l.CreationTime DESC
|
||
) lib
|
||
OUTER APPLY (
|
||
SELECT TOP 1
|
||
${PRODUCT_CODE_EXPR_SQL} AS product_code,
|
||
ISNULL(j.enabled, 0) AS enabled,
|
||
ISNULL(j.stock_disabled, 0) AS stock_disabled,
|
||
NULLIF(LTRIM(RTRIM(j.pic)), N'') AS pic,
|
||
NULLIF(LTRIM(RTRIM(j.pic_big)), N'') AS pic_big
|
||
FROM dbo.jst_sku j
|
||
WHERE j.sku_id = base.skuCode
|
||
) jsku
|
||
),
|
||
purchaser_dim AS (
|
||
SELECT
|
||
pr.shopKey,
|
||
pr.skuCode,
|
||
${PURCHASER_SQL} AS purchaserName
|
||
FROM paged_rows pr
|
||
LEFT JOIN sku_dim sk ON sk.skuCode = pr.skuCode
|
||
OUTER APPLY (SELECT TOP 1 purchaser FROM dwd.dim_ope_spu_data_df d WHERE d.spu = COALESCE(NULLIF(sk.productCode, N''), pr.skuCode)) md
|
||
OUTER APPLY (SELECT TOP 1 purchaser FROM dbo.V_caterAndOper v WHERE v.spu = COALESCE(NULLIF(sk.productCode, N''), pr.skuCode)) mv
|
||
OUTER APPLY (SELECT TOP 1 purchaser FROM dbo.op_spuPrincipal_log l WHERE l.spu = COALESCE(NULLIF(sk.productCode, N''), pr.skuCode) ORDER BY l.created DESC) ml
|
||
OUTER APPLY (SELECT TOP 1 purchaser FROM dbo.fys_SkuDataLibrary_SpuConfig c WHERE COALESCE(NULLIF(sk.productCode, N''), pr.skuCode) LIKE c.spu + N'%' ORDER BY LEN(c.spu) DESC) mc
|
||
),
|
||
rolling_rows AS (
|
||
SELECT
|
||
REPLACE(REPLACE(LTRIM(RTRIM(d.shop_name)), N'_', N''), N'-', N'') AS shop_key,
|
||
LTRIM(RTRIM(d.skuoid)) AS sku_code,
|
||
SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.goods_count), 0)) AS ship_count_30d
|
||
FROM ${CHANNEL_SOURCE_TABLE} d
|
||
INNER JOIN paged_rows pr
|
||
ON pr.shopKey = REPLACE(REPLACE(LTRIM(RTRIM(d.shop_name)), N'_', N''), N'-', N'')
|
||
AND pr.skuCode = LTRIM(RTRIM(d.skuoid))
|
||
WHERE d.stat_date BETWEEN @lookbackStartDate AND @endDate
|
||
${CHANNEL_NON_VIRTUAL_SOURCE_FILTER_SQL}
|
||
GROUP BY REPLACE(REPLACE(LTRIM(RTRIM(d.shop_name)), N'_', N''), N'-', N''), LTRIM(RTRIM(d.skuoid))
|
||
),
|
||
inventory_latest AS (
|
||
SELECT
|
||
i.sku_id,
|
||
COALESCE(i.qty, 0) AS qty,
|
||
ROW_NUMBER() OVER (
|
||
PARTITION BY i.sku_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 (SELECT DISTINCT skuCode FROM paged_rows) ks ON ks.skuCode = i.sku_id
|
||
WHERE i.sku_id IS NOT NULL
|
||
AND LTRIM(RTRIM(i.sku_id)) <> N''
|
||
),
|
||
inventory_sku AS (
|
||
SELECT
|
||
il.sku_id,
|
||
SUM(il.qty) AS stock_qty
|
||
FROM inventory_latest il
|
||
WHERE il.rn = 1
|
||
GROUP BY il.sku_id
|
||
),
|
||
freight_rows AS (
|
||
SELECT
|
||
REPLACE(REPLACE(LTRIM(RTRIM(f.shop_name)), N'_', N''), N'-', N'') AS shop_key,
|
||
LTRIM(RTRIM(f.sku_id)) AS sku_code,
|
||
${FREIGHT_ZONE_CASE_SQL} AS zone_name,
|
||
AVG(TRY_CONVERT(DECIMAL(18, 2), f.freightCost)) AS avg_freight
|
||
FROM dbo.fys_jstFreightCostDetail f
|
||
INNER JOIN paged_rows pr
|
||
ON pr.shopKey = REPLACE(REPLACE(LTRIM(RTRIM(f.shop_name)), N'_', N''), N'-', N'')
|
||
AND pr.skuCode = LTRIM(RTRIM(f.sku_id))
|
||
WHERE TRY_CONVERT(date, f.send_date) BETWEEN @lookbackStartDate AND @endDate
|
||
AND TRY_CONVERT(DECIMAL(18, 2), f.freightCost) IS NOT NULL
|
||
GROUP BY REPLACE(REPLACE(LTRIM(RTRIM(f.shop_name)), N'_', N''), N'-', N''), LTRIM(RTRIM(f.sku_id)), ${FREIGHT_ZONE_CASE_SQL}
|
||
),
|
||
freight_pivot AS (
|
||
SELECT
|
||
fr.shop_key,
|
||
fr.sku_code,
|
||
AVG(CASE WHEN fr.zone_name = N'一区' THEN fr.avg_freight END) AS zone1_avg,
|
||
AVG(CASE WHEN fr.zone_name = N'二区' THEN fr.avg_freight END) AS zone2_avg,
|
||
AVG(CASE WHEN fr.zone_name = N'三区' THEN fr.avg_freight END) AS zone3_avg,
|
||
AVG(CASE WHEN fr.zone_name = N'四区' THEN fr.avg_freight END) AS zone4_avg
|
||
FROM freight_rows fr
|
||
GROUP BY fr.shop_key, fr.sku_code
|
||
)
|
||
SELECT
|
||
pr.channelName,
|
||
pr.shopName,
|
||
COALESCE(NULLIF(sk.productCode, N''), pr.skuCode) AS spu,
|
||
pr.skuCode,
|
||
sk.skuImageUrl,
|
||
COALESCE(NULLIF(sk.saleStatus, N''), N'未配置') AS saleStatus,
|
||
pr.salesAmount,
|
||
pr.afterSaleAmount,
|
||
pr.afterSaleAmountRate,
|
||
pr.shipCount,
|
||
pr.refundCount,
|
||
pr.refundRate,
|
||
pr.salesAmountZone1,
|
||
pr.salesAmountZone2,
|
||
pr.salesAmountZone3,
|
||
pr.salesAmountZone4,
|
||
pr.afterSaleAmountZone1,
|
||
pr.afterSaleAmountZone2,
|
||
pr.afterSaleAmountZone3,
|
||
pr.afterSaleAmountZone4,
|
||
CASE WHEN pr.salesAmountZone1 > 0 THEN CAST(ROUND(pr.afterSaleAmountZone1 * 100.0 / pr.salesAmountZone1, 2) AS DECIMAL(18, 2)) ELSE 0 END AS afterSaleAmountRateZone1,
|
||
CASE WHEN pr.salesAmountZone2 > 0 THEN CAST(ROUND(pr.afterSaleAmountZone2 * 100.0 / pr.salesAmountZone2, 2) AS DECIMAL(18, 2)) ELSE 0 END AS afterSaleAmountRateZone2,
|
||
CASE WHEN pr.salesAmountZone3 > 0 THEN CAST(ROUND(pr.afterSaleAmountZone3 * 100.0 / pr.salesAmountZone3, 2) AS DECIMAL(18, 2)) ELSE 0 END AS afterSaleAmountRateZone3,
|
||
CASE WHEN pr.salesAmountZone4 > 0 THEN CAST(ROUND(pr.afterSaleAmountZone4 * 100.0 / pr.salesAmountZone4, 2) AS DECIMAL(18, 2)) ELSE 0 END AS afterSaleAmountRateZone4,
|
||
COALESCE(NULLIF(pd.purchaserName, N''), N'未配置') AS purchaserName,
|
||
ISNULL(inv.stock_qty, 0) AS realtimeStock,
|
||
CAST(ROUND(ISNULL(rr.ship_count_30d, 0) / 30.0, 2) AS DECIMAL(18, 2)) AS avg30DaySales,
|
||
CASE
|
||
WHEN ISNULL(rr.ship_count_30d, 0) > 0
|
||
THEN CAST(ROUND(ISNULL(inv.stock_qty, 0) * 30.0 / rr.ship_count_30d, 1) AS DECIMAL(18, 1))
|
||
ELSE NULL
|
||
END AS estimatedSalesDays,
|
||
CAST(ROUND(fp.zone1_avg, 2) AS DECIMAL(18, 2)) AS shippingZone1Avg,
|
||
CAST(ROUND(fp.zone2_avg, 2) AS DECIMAL(18, 2)) AS shippingZone2Avg,
|
||
CAST(ROUND(fp.zone3_avg, 2) AS DECIMAL(18, 2)) AS shippingZone3Avg,
|
||
CAST(ROUND(fp.zone4_avg, 2) AS DECIMAL(18, 2)) AS shippingZone4Avg,
|
||
pr.totalCount
|
||
FROM paged_rows pr
|
||
LEFT JOIN sku_dim sk ON sk.skuCode = pr.skuCode
|
||
LEFT JOIN purchaser_dim pd ON pd.shopKey = pr.shopKey AND pd.skuCode = pr.skuCode
|
||
LEFT JOIN rolling_rows rr ON rr.shop_key = pr.shopKey AND rr.sku_code = pr.skuCode
|
||
LEFT JOIN inventory_sku inv ON inv.sku_id = pr.skuCode
|
||
LEFT JOIN freight_pivot fp ON fp.shop_key = pr.shopKey AND fp.sku_code = pr.skuCode
|
||
ORDER BY pr.rn ASC
|
||
`;
|
||
|
||
const rows = await cloudDb.query(fastSqlText, fastParams);
|
||
const total = Number(rows?.[0]?.totalCount || 0);
|
||
const normalizedRows = (rows || []).map((row) => ({
|
||
channelName: row.channelName || '未归类',
|
||
shopName: row.shopName || '',
|
||
spu: row.spu || '',
|
||
skuCode: row.skuCode || '',
|
||
skuImageUrl: row.skuImageUrl || null,
|
||
saleStatus: row.saleStatus || '未配置',
|
||
salesAmount: toNumber(row.salesAmount),
|
||
afterSaleAmount: toNumber(row.afterSaleAmount),
|
||
afterSaleAmountRate: toNumber(row.afterSaleAmountRate),
|
||
shipCount: toNumber(row.shipCount),
|
||
refundCount: toNumber(row.refundCount),
|
||
refundRate: toNumber(row.refundRate),
|
||
salesAmountZone1: toNumber(row.salesAmountZone1),
|
||
salesAmountZone2: toNumber(row.salesAmountZone2),
|
||
salesAmountZone3: toNumber(row.salesAmountZone3),
|
||
salesAmountZone4: toNumber(row.salesAmountZone4),
|
||
afterSaleAmountZone1: toNumber(row.afterSaleAmountZone1),
|
||
afterSaleAmountZone2: toNumber(row.afterSaleAmountZone2),
|
||
afterSaleAmountZone3: toNumber(row.afterSaleAmountZone3),
|
||
afterSaleAmountZone4: toNumber(row.afterSaleAmountZone4),
|
||
afterSaleAmountRateZone1: toNumber(row.afterSaleAmountRateZone1),
|
||
afterSaleAmountRateZone2: toNumber(row.afterSaleAmountRateZone2),
|
||
afterSaleAmountRateZone3: toNumber(row.afterSaleAmountRateZone3),
|
||
afterSaleAmountRateZone4: toNumber(row.afterSaleAmountRateZone4),
|
||
purchaserName: row.purchaserName || '未配置',
|
||
realtimeStock: toNumber(row.realtimeStock, 0),
|
||
avg30DaySales: toNumber(row.avg30DaySales),
|
||
estimatedSalesDays: row.estimatedSalesDays === null || row.estimatedSalesDays === undefined
|
||
? null
|
||
: toNumber(row.estimatedSalesDays, 1),
|
||
shippingZone1Avg: row.shippingZone1Avg === null || row.shippingZone1Avg === undefined ? null : toNumber(row.shippingZone1Avg),
|
||
shippingZone2Avg: row.shippingZone2Avg === null || row.shippingZone2Avg === undefined ? null : toNumber(row.shippingZone2Avg),
|
||
shippingZone3Avg: row.shippingZone3Avg === null || row.shippingZone3Avg === undefined ? null : toNumber(row.shippingZone3Avg),
|
||
shippingZone4Avg: row.shippingZone4Avg === null || row.shippingZone4Avg === undefined ? null : toNumber(row.shippingZone4Avg),
|
||
}));
|
||
|
||
return {
|
||
page: resolvedPage,
|
||
pageSize: resolvedPageSize,
|
||
total,
|
||
rows: normalizedRows,
|
||
dateRange: {
|
||
startDate: dateRange.startDate,
|
||
endDate: dateRange.endDate,
|
||
latestBizDate: dateRange.latestBizDate,
|
||
lookbackStartDate: dateRange.lookbackStartDate,
|
||
},
|
||
meta: {
|
||
source: 'direct-source',
|
||
cacheHit: false,
|
||
fallback: true,
|
||
},
|
||
};
|
||
}
|
||
|
||
const sqlText = `
|
||
WITH order_zone_dim AS (
|
||
SELECT
|
||
LTRIM(RTRIM(a.undefined2)) AS order_id,
|
||
REPLACE(REPLACE(LTRIM(RTRIM(a.undefined8)), N'_', N''), N'-', N'') AS shop_key,
|
||
LTRIM(RTRIM(a.undefined10)) AS sku_code,
|
||
${ORDER_ZONE_CASE_SQL} AS zone_name
|
||
FROM dbo.jst_goods_profit_kpi_items_afs a
|
||
WHERE a.undefined2 IS NOT NULL
|
||
AND LTRIM(RTRIM(a.undefined2)) <> N''
|
||
AND a.undefined8 IS NOT NULL
|
||
AND LTRIM(RTRIM(a.undefined8)) <> N''
|
||
AND a.undefined10 IS NOT NULL
|
||
AND LTRIM(RTRIM(a.undefined10)) <> N''
|
||
AND a.undefined74 IS NOT NULL
|
||
AND LTRIM(RTRIM(a.undefined74)) <> N''
|
||
GROUP BY
|
||
LTRIM(RTRIM(a.undefined2)),
|
||
REPLACE(REPLACE(LTRIM(RTRIM(a.undefined8)), N'_', N''), N'-', N''),
|
||
LTRIM(RTRIM(a.undefined10)),
|
||
${ORDER_ZONE_CASE_SQL}
|
||
),
|
||
base_metrics AS (
|
||
SELECT
|
||
COALESCE(NULLIF(LTRIM(RTRIM(d.platform)), N''), N'未归类') AS channel_name,
|
||
LTRIM(RTRIM(d.shop_name)) AS shop_name,
|
||
REPLACE(REPLACE(LTRIM(RTRIM(d.shop_name)), N'_', N''), N'-', N'') AS shop_key,
|
||
LTRIM(RTRIM(d.skuoid)) AS sku_code,
|
||
SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.sales_amount), 0)) AS sales_amount,
|
||
SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.after_sale_amount), 0)) AS after_sale_amount,
|
||
SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.goods_count), 0)) AS ship_count,
|
||
SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.refund_count), 0)) AS refund_count,
|
||
${CHANNEL_ZONE_SELECT_SQL}
|
||
FROM ${CHANNEL_SOURCE_TABLE} d
|
||
LEFT JOIN order_zone_dim oz
|
||
ON oz.order_id = LTRIM(RTRIM(d.sourceno))
|
||
AND oz.shop_key = REPLACE(REPLACE(LTRIM(RTRIM(d.shop_name)), N'_', N''), N'-', N'')
|
||
AND oz.sku_code = LTRIM(RTRIM(d.skuoid))
|
||
WHERE d.stat_date BETWEEN @startDate AND @endDate
|
||
AND d.shop_name IS NOT NULL
|
||
AND LTRIM(RTRIM(d.shop_name)) <> N''
|
||
AND d.skuoid IS NOT NULL
|
||
AND LTRIM(RTRIM(d.skuoid)) <> N''
|
||
AND UPPER(LTRIM(RTRIM(d.skuoid))) NOT IN (${excludedSkuSql})
|
||
${CHANNEL_NON_VIRTUAL_SOURCE_FILTER_SQL}
|
||
GROUP BY
|
||
COALESCE(NULLIF(LTRIM(RTRIM(d.platform)), N''), N'未归类'),
|
||
LTRIM(RTRIM(d.shop_name)),
|
||
REPLACE(REPLACE(LTRIM(RTRIM(d.shop_name)), N'_', N''), N'-', N''),
|
||
LTRIM(RTRIM(d.skuoid))
|
||
),
|
||
sku_dim AS (
|
||
SELECT
|
||
base.sku_code,
|
||
COALESCE(NULLIF(lib.product_code, N''), NULLIF(jsku.product_code, N''), base.sku_code) AS product_code,
|
||
COALESCE(
|
||
NULLIF(lib.sale_status, N''),
|
||
CASE WHEN ISNULL(jsku.enabled, 0) = 1 AND ISNULL(jsku.stock_disabled, 0) = 0 THEN N'销售' ELSE N'未配置' END
|
||
) AS sale_status,
|
||
COALESCE(NULLIF(jsku.pic_big, N''), NULLIF(jsku.pic, N'')) AS sku_image_url
|
||
FROM (SELECT DISTINCT sku_code FROM base_metrics) base
|
||
OUTER APPLY (
|
||
SELECT TOP 1
|
||
NULLIF(LTRIM(RTRIM(l.ItemNum)), N'') AS product_code,
|
||
COALESCE(NULLIF(LTRIM(RTRIM(l.ProStatus)), N''), N'未配置') AS sale_status
|
||
FROM dbo.fys_SkuDataLibraryNew l
|
||
WHERE l.SkuCode = base.sku_code
|
||
ORDER BY CASE WHEN l.ProStatus = N'销售' THEN 0 ELSE 1 END, l.CreationTime DESC
|
||
) lib
|
||
OUTER APPLY (
|
||
SELECT TOP 1
|
||
${PRODUCT_CODE_EXPR_SQL} AS product_code,
|
||
ISNULL(j.enabled, 0) AS enabled,
|
||
ISNULL(j.stock_disabled, 0) AS stock_disabled,
|
||
NULLIF(LTRIM(RTRIM(j.pic)), N'') AS pic,
|
||
NULLIF(LTRIM(RTRIM(j.pic_big)), N'') AS pic_big
|
||
FROM dbo.jst_sku j
|
||
WHERE j.sku_id = base.sku_code
|
||
) jsku
|
||
),
|
||
filtered_rows AS (
|
||
SELECT
|
||
bm.channel_name AS channelName,
|
||
bm.shop_name AS shopName,
|
||
bm.shop_key AS shopKey,
|
||
COALESCE(NULLIF(sk.product_code, N''), bm.sku_code) AS spu,
|
||
bm.sku_code AS skuCode,
|
||
sk.sku_image_url AS skuImageUrl,
|
||
COALESCE(NULLIF(sk.sale_status, N''), N'未配置') AS saleStatus,
|
||
${PURCHASER_SQL} AS purchaserName,
|
||
CAST(ROUND(bm.sales_amount, 2) AS DECIMAL(18, 2)) AS salesAmount,
|
||
CAST(ROUND(bm.after_sale_amount, 2) AS DECIMAL(18, 2)) AS afterSaleAmount,
|
||
CAST(ROUND(CASE WHEN bm.sales_amount > 0 THEN bm.after_sale_amount * 100.0 / bm.sales_amount ELSE 0 END, 2) AS DECIMAL(18, 2)) AS afterSaleAmountRate,
|
||
CAST(ROUND(bm.ship_count, 2) AS DECIMAL(18, 2)) AS shipCount,
|
||
CAST(ROUND(bm.refund_count, 2) AS DECIMAL(18, 2)) AS refundCount,
|
||
CAST(ROUND(CASE WHEN bm.ship_count > 0 THEN bm.refund_count * 100.0 / bm.ship_count ELSE 0 END, 2) AS DECIMAL(18, 2)) AS refundRate,
|
||
CAST(ROUND(bm.sales_amount_zone1, 2) AS DECIMAL(18, 2)) AS salesAmountZone1,
|
||
CAST(ROUND(bm.sales_amount_zone2, 2) AS DECIMAL(18, 2)) AS salesAmountZone2,
|
||
CAST(ROUND(bm.sales_amount_zone3, 2) AS DECIMAL(18, 2)) AS salesAmountZone3,
|
||
CAST(ROUND(bm.sales_amount_zone4, 2) AS DECIMAL(18, 2)) AS salesAmountZone4,
|
||
CAST(ROUND(bm.after_sale_amount_zone1, 2) AS DECIMAL(18, 2)) AS afterSaleAmountZone1,
|
||
CAST(ROUND(bm.after_sale_amount_zone2, 2) AS DECIMAL(18, 2)) AS afterSaleAmountZone2,
|
||
CAST(ROUND(bm.after_sale_amount_zone3, 2) AS DECIMAL(18, 2)) AS afterSaleAmountZone3,
|
||
CAST(ROUND(bm.after_sale_amount_zone4, 2) AS DECIMAL(18, 2)) AS afterSaleAmountZone4,
|
||
COUNT(1) OVER() AS totalCount,
|
||
ROW_NUMBER() OVER (ORDER BY ${orderSql}) AS rn
|
||
FROM base_metrics bm
|
||
LEFT JOIN sku_dim sk ON sk.sku_code = bm.sku_code
|
||
OUTER APPLY (SELECT TOP 1 purchaser FROM dwd.dim_ope_spu_data_df d WHERE d.spu = COALESCE(NULLIF(sk.product_code, N''), bm.sku_code)) md
|
||
OUTER APPLY (SELECT TOP 1 purchaser FROM dbo.V_caterAndOper v WHERE v.spu = COALESCE(NULLIF(sk.product_code, N''), bm.sku_code)) mv
|
||
OUTER APPLY (SELECT TOP 1 purchaser FROM dbo.op_spuPrincipal_log l WHERE l.spu = COALESCE(NULLIF(sk.product_code, N''), bm.sku_code) ORDER BY l.created DESC) ml
|
||
OUTER APPLY (SELECT TOP 1 purchaser FROM dbo.fys_SkuDataLibrary_SpuConfig c WHERE COALESCE(NULLIF(sk.product_code, N''), bm.sku_code) LIKE c.spu + N'%' ORDER BY LEN(c.spu) DESC) mc
|
||
${whereSql}
|
||
),
|
||
paged_rows AS (
|
||
SELECT *
|
||
FROM filtered_rows
|
||
WHERE rn BETWEEN @rowStart AND @rowEnd
|
||
),
|
||
rolling_rows AS (
|
||
SELECT
|
||
REPLACE(REPLACE(LTRIM(RTRIM(d.shop_name)), N'_', N''), N'-', N'') AS shop_key,
|
||
LTRIM(RTRIM(d.skuoid)) AS sku_code,
|
||
SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.goods_count), 0)) AS ship_count_30d
|
||
FROM ${CHANNEL_SOURCE_TABLE} d
|
||
INNER JOIN paged_rows pr
|
||
ON pr.shopKey = REPLACE(REPLACE(LTRIM(RTRIM(d.shop_name)), N'_', N''), N'-', N'')
|
||
AND pr.skuCode = LTRIM(RTRIM(d.skuoid))
|
||
WHERE d.stat_date BETWEEN @lookbackStartDate AND @endDate
|
||
${CHANNEL_NON_VIRTUAL_SOURCE_FILTER_SQL}
|
||
GROUP BY REPLACE(REPLACE(LTRIM(RTRIM(d.shop_name)), N'_', N''), N'-', N''), LTRIM(RTRIM(d.skuoid))
|
||
),
|
||
inventory_latest AS (
|
||
SELECT
|
||
i.sku_id,
|
||
COALESCE(i.qty, 0) AS qty,
|
||
ROW_NUMBER() OVER (
|
||
PARTITION BY i.sku_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 (SELECT DISTINCT skuCode FROM paged_rows) ks ON ks.skuCode = i.sku_id
|
||
WHERE i.sku_id IS NOT NULL
|
||
AND LTRIM(RTRIM(i.sku_id)) <> N''
|
||
),
|
||
inventory_sku AS (
|
||
SELECT
|
||
il.sku_id,
|
||
SUM(il.qty) AS stock_qty
|
||
FROM inventory_latest il
|
||
WHERE il.rn = 1
|
||
GROUP BY il.sku_id
|
||
),
|
||
freight_rows AS (
|
||
SELECT
|
||
REPLACE(REPLACE(LTRIM(RTRIM(f.shop_name)), N'_', N''), N'-', N'') AS shop_key,
|
||
LTRIM(RTRIM(f.sku_id)) AS sku_code,
|
||
${FREIGHT_ZONE_CASE_SQL} AS zone_name,
|
||
AVG(TRY_CONVERT(DECIMAL(18, 2), f.freightCost)) AS avg_freight
|
||
FROM dbo.fys_jstFreightCostDetail f
|
||
INNER JOIN paged_rows pr
|
||
ON pr.shopKey = REPLACE(REPLACE(LTRIM(RTRIM(f.shop_name)), N'_', N''), N'-', N'')
|
||
AND pr.skuCode = LTRIM(RTRIM(f.sku_id))
|
||
WHERE TRY_CONVERT(date, f.send_date) BETWEEN @lookbackStartDate AND @endDate
|
||
AND TRY_CONVERT(DECIMAL(18, 2), f.freightCost) IS NOT NULL
|
||
GROUP BY REPLACE(REPLACE(LTRIM(RTRIM(f.shop_name)), N'_', N''), N'-', N''), LTRIM(RTRIM(f.sku_id)), ${FREIGHT_ZONE_CASE_SQL}
|
||
),
|
||
freight_pivot AS (
|
||
SELECT
|
||
fr.shop_key,
|
||
fr.sku_code,
|
||
AVG(CASE WHEN fr.zone_name = N'一区' THEN fr.avg_freight END) AS zone1_avg,
|
||
AVG(CASE WHEN fr.zone_name = N'二区' THEN fr.avg_freight END) AS zone2_avg,
|
||
AVG(CASE WHEN fr.zone_name = N'三区' THEN fr.avg_freight END) AS zone3_avg,
|
||
AVG(CASE WHEN fr.zone_name = N'四区' THEN fr.avg_freight END) AS zone4_avg
|
||
FROM freight_rows fr
|
||
GROUP BY fr.shop_key, fr.sku_code
|
||
)
|
||
SELECT
|
||
pr.channelName,
|
||
pr.shopName,
|
||
pr.spu,
|
||
pr.skuCode,
|
||
pr.skuImageUrl,
|
||
pr.saleStatus,
|
||
pr.salesAmount,
|
||
pr.afterSaleAmount,
|
||
pr.afterSaleAmountRate,
|
||
pr.shipCount,
|
||
pr.refundCount,
|
||
pr.refundRate,
|
||
pr.salesAmountZone1,
|
||
pr.salesAmountZone2,
|
||
pr.salesAmountZone3,
|
||
pr.salesAmountZone4,
|
||
pr.afterSaleAmountZone1,
|
||
pr.afterSaleAmountZone2,
|
||
pr.afterSaleAmountZone3,
|
||
pr.afterSaleAmountZone4,
|
||
CASE WHEN pr.salesAmountZone1 > 0 THEN CAST(ROUND(pr.afterSaleAmountZone1 * 100.0 / pr.salesAmountZone1, 2) AS DECIMAL(18, 2)) ELSE 0 END AS afterSaleAmountRateZone1,
|
||
CASE WHEN pr.salesAmountZone2 > 0 THEN CAST(ROUND(pr.afterSaleAmountZone2 * 100.0 / pr.salesAmountZone2, 2) AS DECIMAL(18, 2)) ELSE 0 END AS afterSaleAmountRateZone2,
|
||
CASE WHEN pr.salesAmountZone3 > 0 THEN CAST(ROUND(pr.afterSaleAmountZone3 * 100.0 / pr.salesAmountZone3, 2) AS DECIMAL(18, 2)) ELSE 0 END AS afterSaleAmountRateZone3,
|
||
CASE WHEN pr.salesAmountZone4 > 0 THEN CAST(ROUND(pr.afterSaleAmountZone4 * 100.0 / pr.salesAmountZone4, 2) AS DECIMAL(18, 2)) ELSE 0 END AS afterSaleAmountRateZone4,
|
||
pr.purchaserName,
|
||
ISNULL(inv.stock_qty, 0) AS realtimeStock,
|
||
CAST(ROUND(ISNULL(rr.ship_count_30d, 0) / 30.0, 2) AS DECIMAL(18, 2)) AS avg30DaySales,
|
||
CASE
|
||
WHEN ISNULL(rr.ship_count_30d, 0) > 0
|
||
THEN CAST(ROUND(ISNULL(inv.stock_qty, 0) * 30.0 / rr.ship_count_30d, 1) AS DECIMAL(18, 1))
|
||
ELSE NULL
|
||
END AS estimatedSalesDays,
|
||
CAST(ROUND(fp.zone1_avg, 2) AS DECIMAL(18, 2)) AS shippingZone1Avg,
|
||
CAST(ROUND(fp.zone2_avg, 2) AS DECIMAL(18, 2)) AS shippingZone2Avg,
|
||
CAST(ROUND(fp.zone3_avg, 2) AS DECIMAL(18, 2)) AS shippingZone3Avg,
|
||
CAST(ROUND(fp.zone4_avg, 2) AS DECIMAL(18, 2)) AS shippingZone4Avg,
|
||
pr.totalCount
|
||
FROM paged_rows pr
|
||
LEFT JOIN rolling_rows rr ON rr.shop_key = pr.shopKey AND rr.sku_code = pr.skuCode
|
||
LEFT JOIN inventory_sku inv ON inv.sku_id = pr.skuCode
|
||
LEFT JOIN freight_pivot fp ON fp.shop_key = pr.shopKey AND fp.sku_code = pr.skuCode
|
||
ORDER BY pr.rn ASC
|
||
`;
|
||
|
||
const rows = await cloudDb.query(sqlText, params);
|
||
const total = Number(rows?.[0]?.totalCount || 0);
|
||
const normalizedRows = (rows || []).map((row) => ({
|
||
channelName: row.channelName || '未归类',
|
||
shopName: row.shopName || '',
|
||
spu: row.spu || '',
|
||
skuCode: row.skuCode || '',
|
||
skuImageUrl: row.skuImageUrl || null,
|
||
saleStatus: row.saleStatus || '未配置',
|
||
salesAmount: toNumber(row.salesAmount),
|
||
afterSaleAmount: toNumber(row.afterSaleAmount),
|
||
afterSaleAmountRate: toNumber(row.afterSaleAmountRate),
|
||
shipCount: toNumber(row.shipCount),
|
||
refundCount: toNumber(row.refundCount),
|
||
refundRate: toNumber(row.refundRate),
|
||
salesAmountZone1: toNumber(row.salesAmountZone1),
|
||
salesAmountZone2: toNumber(row.salesAmountZone2),
|
||
salesAmountZone3: toNumber(row.salesAmountZone3),
|
||
salesAmountZone4: toNumber(row.salesAmountZone4),
|
||
afterSaleAmountZone1: toNumber(row.afterSaleAmountZone1),
|
||
afterSaleAmountZone2: toNumber(row.afterSaleAmountZone2),
|
||
afterSaleAmountZone3: toNumber(row.afterSaleAmountZone3),
|
||
afterSaleAmountZone4: toNumber(row.afterSaleAmountZone4),
|
||
afterSaleAmountRateZone1: toNumber(row.afterSaleAmountRateZone1),
|
||
afterSaleAmountRateZone2: toNumber(row.afterSaleAmountRateZone2),
|
||
afterSaleAmountRateZone3: toNumber(row.afterSaleAmountRateZone3),
|
||
afterSaleAmountRateZone4: toNumber(row.afterSaleAmountRateZone4),
|
||
purchaserName: row.purchaserName || '未配置',
|
||
realtimeStock: toNumber(row.realtimeStock, 0),
|
||
avg30DaySales: toNumber(row.avg30DaySales),
|
||
estimatedSalesDays: row.estimatedSalesDays === null || row.estimatedSalesDays === undefined
|
||
? null
|
||
: toNumber(row.estimatedSalesDays, 1),
|
||
shippingZone1Avg: row.shippingZone1Avg === null || row.shippingZone1Avg === undefined ? null : toNumber(row.shippingZone1Avg),
|
||
shippingZone2Avg: row.shippingZone2Avg === null || row.shippingZone2Avg === undefined ? null : toNumber(row.shippingZone2Avg),
|
||
shippingZone3Avg: row.shippingZone3Avg === null || row.shippingZone3Avg === undefined ? null : toNumber(row.shippingZone3Avg),
|
||
shippingZone4Avg: row.shippingZone4Avg === null || row.shippingZone4Avg === undefined ? null : toNumber(row.shippingZone4Avg),
|
||
}));
|
||
|
||
return {
|
||
page: resolvedPage,
|
||
pageSize: resolvedPageSize,
|
||
total,
|
||
rows: normalizedRows,
|
||
dateRange: {
|
||
startDate: dateRange.startDate,
|
||
endDate: dateRange.endDate,
|
||
latestBizDate: dateRange.latestBizDate,
|
||
lookbackStartDate: dateRange.lookbackStartDate,
|
||
},
|
||
meta: {
|
||
source: 'direct-source',
|
||
cacheHit: false,
|
||
fallback: true,
|
||
},
|
||
};
|
||
}
|
||
|
||
function getCoverageStartDate(dateRange) {
|
||
return dayjs(dateRange.startDate).isBefore(dayjs(dateRange.lookbackStartDate), 'day')
|
||
? dateRange.startDate
|
||
: dateRange.lookbackStartDate;
|
||
}
|
||
|
||
async function buildChannelDefaultSnapshotPayload(config, { skipCoverage = false } = {}) {
|
||
if (!skipCoverage) {
|
||
const coverageStartDate = getCoverageStartDate(config);
|
||
await ensureChannelReadCoverage(coverageStartDate, config.endDate, config.latestBizDate);
|
||
}
|
||
|
||
const data = await getChannelListFromReadStore({
|
||
startDate: config.startDate,
|
||
endDate: config.endDate,
|
||
lookbackStartDate: config.lookbackStartDate,
|
||
latestBizDate: config.latestBizDate,
|
||
page: config.page,
|
||
pageSize: config.pageSize,
|
||
keyword: config.keyword,
|
||
sortField: config.sortField,
|
||
sortOrder: config.sortOrder,
|
||
channels: config.channels,
|
||
statuses: config.statuses,
|
||
purchasers: config.purchasers,
|
||
minAfterSaleAmountRate: config.minAfterSaleAmountRate,
|
||
maxAfterSaleAmountRate: config.maxAfterSaleAmountRate,
|
||
});
|
||
|
||
return {
|
||
...data,
|
||
meta: {
|
||
...(data.meta || {}),
|
||
snapshotMode: true,
|
||
defaultWindow: true,
|
||
},
|
||
};
|
||
}
|
||
|
||
async function refreshChannelDefaultSnapshot(config, { skipCoverage = false } = {}) {
|
||
const cacheKey = buildChannelDefaultSnapshotKey(config);
|
||
const rollingKey = buildChannelDefaultRollingSnapshotKey(config);
|
||
return refreshNamedSnapshot({
|
||
cacheKey,
|
||
rollingKey,
|
||
label: `渠道默认快照 ${config.startDate}~${config.endDate} page=${config.page}`,
|
||
buildValue: () => buildChannelDefaultSnapshotPayload(config, { skipCoverage }),
|
||
});
|
||
}
|
||
|
||
async function getChannelDefaultSnapshot(config) {
|
||
const cacheKey = buildChannelDefaultSnapshotKey(config);
|
||
const rollingKey = buildChannelDefaultRollingSnapshotKey(config);
|
||
return getNamedSnapshot({
|
||
cacheKey,
|
||
rollingKey,
|
||
refreshLogLabel: `渠道默认快照 ${config.startDate}~${config.endDate} page=${config.page}`,
|
||
requestedRange: {
|
||
startDate: config.startDate,
|
||
endDate: config.endDate,
|
||
latestBizDate: config.latestBizDate,
|
||
},
|
||
refreshSnapshot: () => refreshChannelDefaultSnapshot(config),
|
||
});
|
||
}
|
||
|
||
async function buildChannelIdDefaultSnapshotPayload(config, { skipCoverage = false, fastMode = false } = {}) {
|
||
const dateRange = {
|
||
startDate: config.startDate,
|
||
endDate: config.endDate,
|
||
latestBizDate: config.latestBizDate,
|
||
lookbackStartDate: config.lookbackStartDate || config.startDate,
|
||
};
|
||
|
||
const data = await getChannelIdAnalysisFromReadStore({
|
||
dateRange,
|
||
page: config.page,
|
||
pageSize: config.pageSize,
|
||
keyword: config.keyword,
|
||
sortField: config.sortField,
|
||
sortOrder: config.sortOrder,
|
||
skipCoverage,
|
||
fastMode,
|
||
});
|
||
|
||
return withSnapshotResponseMeta(data, {
|
||
snapshotMode: true,
|
||
defaultWindow: true,
|
||
});
|
||
}
|
||
|
||
async function refreshChannelIdDefaultSnapshot(config, { skipCoverage = false } = {}) {
|
||
const cacheKey = buildChannelIdDefaultSnapshotKey(config);
|
||
const rollingKey = buildChannelIdAnalysisRollingSnapshotKey(config);
|
||
return refreshNamedSnapshot({
|
||
cacheKey,
|
||
rollingKey,
|
||
label: `ID分析默认快照 ${config.startDate}~${config.endDate} page=${config.page}`,
|
||
buildValue: () => buildChannelIdDefaultSnapshotPayload(config, { skipCoverage }),
|
||
});
|
||
}
|
||
|
||
async function getChannelIdDefaultSnapshot(config) {
|
||
const cacheKey = buildChannelIdDefaultSnapshotKey(config);
|
||
const rollingKey = buildChannelIdAnalysisRollingSnapshotKey(config);
|
||
return getNamedSnapshot({
|
||
cacheKey,
|
||
rollingKey,
|
||
refreshLogLabel: `ID分析默认快照 ${config.startDate}~${config.endDate} page=${config.page}`,
|
||
requestedRange: {
|
||
startDate: config.startDate,
|
||
endDate: config.endDate,
|
||
latestBizDate: config.latestBizDate,
|
||
},
|
||
refreshSnapshot: () => refreshChannelIdDefaultSnapshot(config),
|
||
fallbackLoader: () => buildChannelIdDefaultFastFallback(config),
|
||
});
|
||
}
|
||
|
||
function normalizeChannelDashboardLimit(value) {
|
||
const parsed = Number.parseInt(value, 10);
|
||
if (!Number.isFinite(parsed) || parsed <= 0) return 30;
|
||
return Math.min(parsed, 80);
|
||
}
|
||
|
||
function buildChannelDashboardKeywordClause(keyword, alias = 'r') {
|
||
const keywordText = normalizeText(keyword);
|
||
if (!keywordText) {
|
||
return {
|
||
sql: '',
|
||
params: [],
|
||
};
|
||
}
|
||
|
||
const prefix = alias ? `${alias}.` : '';
|
||
const likeValue = `%${keywordText}%`;
|
||
return {
|
||
sql: `AND (
|
||
${prefix}channel_name LIKE ?
|
||
OR ${prefix}shop_name LIKE ?
|
||
OR ${prefix}sku_code LIKE ?
|
||
OR ${prefix}spu LIKE ?
|
||
OR ${prefix}purchaser_name LIKE ?
|
||
OR ${prefix}sale_status LIKE ?
|
||
)`,
|
||
params: [likeValue, likeValue, likeValue, likeValue, likeValue, likeValue],
|
||
};
|
||
}
|
||
|
||
function buildChannelDashboardBaseSql({ startDate, endDate, keyword }) {
|
||
const keywordClause = buildChannelDashboardKeywordClause(keyword, 'r');
|
||
return {
|
||
sql: `SELECT
|
||
COALESCE(NULLIF(r.channel_name, ''), '未归类') AS channelName,
|
||
COALESCE(NULLIF(r.shop_name, ''), r.shop_key, '') AS shopName,
|
||
r.shop_key AS shopKey,
|
||
r.sku_code AS skuCode,
|
||
COALESCE(NULLIF(MAX(r.spu), ''), r.sku_code) AS spu,
|
||
COALESCE(MAX(NULLIF(r.sku_image_url, '')), '') AS skuImageUrl,
|
||
COALESCE(NULLIF(MAX(r.sale_status), ''), '未配置') AS saleStatus,
|
||
COALESCE(NULLIF(MAX(r.purchaser_name), ''), '未配置') AS purchaserName,
|
||
COUNT(DISTINCT r.stat_date) AS statDayCount,
|
||
ROUND(SUM(r.sales_amount), 2) AS salesAmount,
|
||
ROUND(SUM(r.after_sale_amount), 2) AS afterSaleAmount,
|
||
ROUND(CASE WHEN SUM(r.sales_amount) > 0 THEN SUM(r.after_sale_amount) / SUM(r.sales_amount) ELSE 0 END, 6) AS afterSaleAmountRate,
|
||
ROUND(SUM(r.ship_count), 2) AS shipCount,
|
||
ROUND(SUM(r.refund_count), 2) AS refundCount,
|
||
ROUND(CASE WHEN SUM(r.ship_count) > 0 THEN SUM(r.refund_count) / SUM(r.ship_count) ELSE 0 END, 6) AS refundRate,
|
||
ROUND(SUM(r.sales_amount_zone1), 2) AS salesAmountZone1,
|
||
ROUND(SUM(r.sales_amount_zone2), 2) AS salesAmountZone2,
|
||
ROUND(SUM(r.sales_amount_zone3), 2) AS salesAmountZone3,
|
||
ROUND(SUM(r.sales_amount_zone4), 2) AS salesAmountZone4,
|
||
ROUND(SUM(r.after_sale_amount_zone1), 2) AS afterSaleAmountZone1,
|
||
ROUND(SUM(r.after_sale_amount_zone2), 2) AS afterSaleAmountZone2,
|
||
ROUND(SUM(r.after_sale_amount_zone3), 2) AS afterSaleAmountZone3,
|
||
ROUND(SUM(r.after_sale_amount_zone4), 2) AS afterSaleAmountZone4
|
||
FROM ${CHANNEL_READ_TABLE} r
|
||
WHERE r.stat_date BETWEEN ? AND ?
|
||
AND r.sku_code IS NOT NULL
|
||
AND r.sku_code <> ''
|
||
${keywordClause.sql}
|
||
GROUP BY r.channel_name, r.shop_name, r.shop_key, r.sku_code`,
|
||
params: [startDate, endDate, ...keywordClause.params],
|
||
};
|
||
}
|
||
|
||
function getChannelDashboardScoredSql(baseSql) {
|
||
return `WITH base AS (${baseSql}),
|
||
scored AS (
|
||
SELECT
|
||
base.*,
|
||
CASE WHEN base.afterSaleAmountRate >= 0.05 THEN 1 ELSE 0 END AS highAfterSaleFlag,
|
||
CASE WHEN base.refundRate >= 0.15 THEN 1 ELSE 0 END AS highRefundFlag,
|
||
CASE
|
||
WHEN base.shipCount > 0 THEN GREATEST(base.refundCount * base.salesAmount / base.shipCount, 0)
|
||
ELSE 0
|
||
END AS refundImpactAmount,
|
||
GREATEST(base.afterSaleAmount, 0)
|
||
+ CASE
|
||
WHEN base.shipCount > 0 THEN GREATEST(base.refundCount * base.salesAmount / base.shipCount, 0)
|
||
ELSE 0
|
||
END AS impactAmount
|
||
FROM base
|
||
)`;
|
||
}
|
||
|
||
function normalizeChannelDashboardSummary(row = {}) {
|
||
return {
|
||
skuShopCount: toNumber(row.skuShopCount, 0),
|
||
skuCount: toNumber(row.skuCount, 0),
|
||
spuCount: toNumber(row.spuCount, 0),
|
||
channelCount: toNumber(row.channelCount, 0),
|
||
shopCount: toNumber(row.shopCount, 0),
|
||
purchaserCount: toNumber(row.purchaserCount, 0),
|
||
salesAmount: toNumber(row.salesAmount, 2),
|
||
afterSaleAmount: toNumber(row.afterSaleAmount, 2),
|
||
afterSaleAmountRate: toNumber(row.afterSaleAmountRate, 6),
|
||
shipCount: toNumber(row.shipCount, 0),
|
||
refundCount: toNumber(row.refundCount, 0),
|
||
refundRate: toNumber(row.refundRate, 6),
|
||
highAfterSaleSkuCount: toNumber(row.highAfterSaleSkuCount, 0),
|
||
highRefundSkuCount: toNumber(row.highRefundSkuCount, 0),
|
||
impactAmount: toNumber(row.impactAmount, 2),
|
||
};
|
||
}
|
||
|
||
async function queryChannelDashboardSummary(base) {
|
||
const rows = await channelReadDb.query(
|
||
`${getChannelDashboardScoredSql(base.sql)}
|
||
SELECT
|
||
COUNT(1) AS skuShopCount,
|
||
COUNT(DISTINCT skuCode) AS skuCount,
|
||
COUNT(DISTINCT spu) AS spuCount,
|
||
COUNT(DISTINCT channelName) AS channelCount,
|
||
COUNT(DISTINCT shopKey) AS shopCount,
|
||
COUNT(DISTINCT purchaserName) AS purchaserCount,
|
||
ROUND(SUM(salesAmount), 2) AS salesAmount,
|
||
ROUND(SUM(afterSaleAmount), 2) AS afterSaleAmount,
|
||
ROUND(CASE WHEN SUM(salesAmount) > 0 THEN SUM(afterSaleAmount) / SUM(salesAmount) ELSE 0 END, 6) AS afterSaleAmountRate,
|
||
ROUND(SUM(shipCount), 2) AS shipCount,
|
||
ROUND(SUM(refundCount), 2) AS refundCount,
|
||
ROUND(CASE WHEN SUM(shipCount) > 0 THEN SUM(refundCount) / SUM(shipCount) ELSE 0 END, 6) AS refundRate,
|
||
SUM(highAfterSaleFlag) AS highAfterSaleSkuCount,
|
||
SUM(highRefundFlag) AS highRefundSkuCount,
|
||
ROUND(SUM(impactAmount), 2) AS impactAmount
|
||
FROM scored`,
|
||
base.params
|
||
);
|
||
|
||
return normalizeChannelDashboardSummary(rows?.[0] || {});
|
||
}
|
||
|
||
async function queryChannelDashboardTrend({ startDate, endDate, keyword }) {
|
||
const keywordClause = buildChannelDashboardKeywordClause(keyword, 'r');
|
||
const rows = await channelReadDb.query(
|
||
`SELECT
|
||
r.stat_date AS statDate,
|
||
COUNT(DISTINCT CONCAT(r.channel_name, '__', r.shop_key, '__', r.sku_code)) AS skuShopCount,
|
||
COUNT(DISTINCT r.sku_code) AS skuCount,
|
||
COUNT(DISTINCT r.shop_key) AS shopCount,
|
||
ROUND(SUM(r.sales_amount), 2) AS salesAmount,
|
||
ROUND(SUM(r.after_sale_amount), 2) AS afterSaleAmount,
|
||
ROUND(CASE WHEN SUM(r.sales_amount) > 0 THEN SUM(r.after_sale_amount) / SUM(r.sales_amount) ELSE 0 END, 6) AS afterSaleAmountRate,
|
||
ROUND(SUM(r.ship_count), 2) AS shipCount,
|
||
ROUND(SUM(r.refund_count), 2) AS refundCount,
|
||
ROUND(CASE WHEN SUM(r.ship_count) > 0 THEN SUM(r.refund_count) / SUM(r.ship_count) ELSE 0 END, 6) AS refundRate
|
||
FROM ${CHANNEL_READ_TABLE} r
|
||
WHERE r.stat_date BETWEEN ? AND ?
|
||
AND r.sku_code IS NOT NULL
|
||
AND r.sku_code <> ''
|
||
${keywordClause.sql}
|
||
GROUP BY r.stat_date
|
||
ORDER BY r.stat_date ASC`,
|
||
[startDate, endDate, ...keywordClause.params]
|
||
);
|
||
|
||
return (rows || []).map((row) => ({
|
||
statDate: normalizeDateValue(row.statDate),
|
||
skuShopCount: toNumber(row.skuShopCount, 0),
|
||
skuCount: toNumber(row.skuCount, 0),
|
||
shopCount: toNumber(row.shopCount, 0),
|
||
salesAmount: toNumber(row.salesAmount, 2),
|
||
afterSaleAmount: toNumber(row.afterSaleAmount, 2),
|
||
afterSaleAmountRate: toNumber(row.afterSaleAmountRate, 6),
|
||
shipCount: toNumber(row.shipCount, 0),
|
||
refundCount: toNumber(row.refundCount, 0),
|
||
refundRate: toNumber(row.refundRate, 6),
|
||
}));
|
||
}
|
||
|
||
function normalizeChannelDashboardDimensionRow(row = {}) {
|
||
return {
|
||
dimensionKey: row.dimensionKey || '',
|
||
dimensionValue: row.dimensionValue || '未配置',
|
||
subValue: row.subValue || '',
|
||
itemCount: toNumber(row.itemCount, 0),
|
||
salesAmount: toNumber(row.salesAmount, 2),
|
||
afterSaleAmount: toNumber(row.afterSaleAmount, 2),
|
||
afterSaleAmountRate: toNumber(row.afterSaleAmountRate, 6),
|
||
shipCount: toNumber(row.shipCount, 0),
|
||
refundCount: toNumber(row.refundCount, 0),
|
||
refundRate: toNumber(row.refundRate, 6),
|
||
impactAmount: toNumber(row.impactAmount, 2),
|
||
};
|
||
}
|
||
|
||
async function queryChannelDashboardDimensionGroups(base) {
|
||
const rows = await channelReadDb.query(
|
||
`${getChannelDashboardScoredSql(base.sql)},
|
||
ranked AS (
|
||
SELECT
|
||
scored.*,
|
||
SUM(salesAmount) OVER () AS totalSalesAmount,
|
||
SUM(salesAmount) OVER (
|
||
ORDER BY salesAmount DESC, skuCode ASC, shopKey ASC
|
||
ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
|
||
) AS salesAmountBefore
|
||
FROM scored
|
||
),
|
||
head_tail AS (
|
||
SELECT
|
||
*,
|
||
CASE
|
||
WHEN totalSalesAmount <= 0 THEN '尾部'
|
||
WHEN IFNULL(salesAmountBefore, 0) / totalSalesAmount < 0.3 THEN '头部'
|
||
WHEN IFNULL(salesAmountBefore, 0) / totalSalesAmount < 0.7 THEN '腰部'
|
||
ELSE '尾部'
|
||
END AS segmentName
|
||
FROM ranked
|
||
)
|
||
SELECT
|
||
'channel' AS dimensionKey,
|
||
channelName AS dimensionValue,
|
||
'' AS subValue,
|
||
COUNT(1) AS itemCount,
|
||
ROUND(SUM(salesAmount), 2) AS salesAmount,
|
||
ROUND(SUM(afterSaleAmount), 2) AS afterSaleAmount,
|
||
ROUND(CASE WHEN SUM(salesAmount) > 0 THEN SUM(afterSaleAmount) / SUM(salesAmount) ELSE 0 END, 6) AS afterSaleAmountRate,
|
||
ROUND(SUM(shipCount), 2) AS shipCount,
|
||
ROUND(SUM(refundCount), 2) AS refundCount,
|
||
ROUND(CASE WHEN SUM(shipCount) > 0 THEN SUM(refundCount) / SUM(shipCount) ELSE 0 END, 6) AS refundRate,
|
||
ROUND(SUM(impactAmount), 2) AS impactAmount
|
||
FROM scored
|
||
GROUP BY channelName
|
||
UNION ALL
|
||
SELECT
|
||
'shop',
|
||
shopName,
|
||
channelName,
|
||
COUNT(1),
|
||
ROUND(SUM(salesAmount), 2),
|
||
ROUND(SUM(afterSaleAmount), 2),
|
||
ROUND(CASE WHEN SUM(salesAmount) > 0 THEN SUM(afterSaleAmount) / SUM(salesAmount) ELSE 0 END, 6),
|
||
ROUND(SUM(shipCount), 2),
|
||
ROUND(SUM(refundCount), 2),
|
||
ROUND(CASE WHEN SUM(shipCount) > 0 THEN SUM(refundCount) / SUM(shipCount) ELSE 0 END, 6),
|
||
ROUND(SUM(impactAmount), 2)
|
||
FROM scored
|
||
GROUP BY channelName, shopKey, shopName
|
||
UNION ALL
|
||
SELECT
|
||
'purchaser',
|
||
purchaserName,
|
||
'',
|
||
COUNT(1),
|
||
ROUND(SUM(salesAmount), 2),
|
||
ROUND(SUM(afterSaleAmount), 2),
|
||
ROUND(CASE WHEN SUM(salesAmount) > 0 THEN SUM(afterSaleAmount) / SUM(salesAmount) ELSE 0 END, 6),
|
||
ROUND(SUM(shipCount), 2),
|
||
ROUND(SUM(refundCount), 2),
|
||
ROUND(CASE WHEN SUM(shipCount) > 0 THEN SUM(refundCount) / SUM(shipCount) ELSE 0 END, 6),
|
||
ROUND(SUM(impactAmount), 2)
|
||
FROM scored
|
||
GROUP BY purchaserName
|
||
UNION ALL
|
||
SELECT
|
||
'status',
|
||
saleStatus,
|
||
'',
|
||
COUNT(1),
|
||
ROUND(SUM(salesAmount), 2),
|
||
ROUND(SUM(afterSaleAmount), 2),
|
||
ROUND(CASE WHEN SUM(salesAmount) > 0 THEN SUM(afterSaleAmount) / SUM(salesAmount) ELSE 0 END, 6),
|
||
ROUND(SUM(shipCount), 2),
|
||
ROUND(SUM(refundCount), 2),
|
||
ROUND(CASE WHEN SUM(shipCount) > 0 THEN SUM(refundCount) / SUM(shipCount) ELSE 0 END, 6),
|
||
ROUND(SUM(impactAmount), 2)
|
||
FROM scored
|
||
GROUP BY saleStatus
|
||
UNION ALL
|
||
SELECT
|
||
'headTail',
|
||
segmentName,
|
||
'',
|
||
COUNT(1),
|
||
ROUND(SUM(salesAmount), 2),
|
||
ROUND(SUM(afterSaleAmount), 2),
|
||
ROUND(CASE WHEN SUM(salesAmount) > 0 THEN SUM(afterSaleAmount) / SUM(salesAmount) ELSE 0 END, 6),
|
||
ROUND(SUM(shipCount), 2),
|
||
ROUND(SUM(refundCount), 2),
|
||
ROUND(CASE WHEN SUM(shipCount) > 0 THEN SUM(refundCount) / SUM(shipCount) ELSE 0 END, 6),
|
||
ROUND(SUM(impactAmount), 2)
|
||
FROM head_tail
|
||
GROUP BY segmentName
|
||
UNION ALL
|
||
SELECT
|
||
'zone',
|
||
'一区',
|
||
'',
|
||
SUM(CASE WHEN salesAmountZone1 <> 0 OR afterSaleAmountZone1 <> 0 THEN 1 ELSE 0 END),
|
||
ROUND(SUM(salesAmountZone1), 2),
|
||
ROUND(SUM(afterSaleAmountZone1), 2),
|
||
ROUND(CASE WHEN SUM(salesAmountZone1) > 0 THEN SUM(afterSaleAmountZone1) / SUM(salesAmountZone1) ELSE 0 END, 6),
|
||
0,
|
||
0,
|
||
0,
|
||
ROUND(SUM(GREATEST(afterSaleAmountZone1, 0)), 2)
|
||
FROM scored
|
||
UNION ALL
|
||
SELECT
|
||
'zone',
|
||
'二区',
|
||
'',
|
||
SUM(CASE WHEN salesAmountZone2 <> 0 OR afterSaleAmountZone2 <> 0 THEN 1 ELSE 0 END),
|
||
ROUND(SUM(salesAmountZone2), 2),
|
||
ROUND(SUM(afterSaleAmountZone2), 2),
|
||
ROUND(CASE WHEN SUM(salesAmountZone2) > 0 THEN SUM(afterSaleAmountZone2) / SUM(salesAmountZone2) ELSE 0 END, 6),
|
||
0,
|
||
0,
|
||
0,
|
||
ROUND(SUM(GREATEST(afterSaleAmountZone2, 0)), 2)
|
||
FROM scored
|
||
UNION ALL
|
||
SELECT
|
||
'zone',
|
||
'三区',
|
||
'',
|
||
SUM(CASE WHEN salesAmountZone3 <> 0 OR afterSaleAmountZone3 <> 0 THEN 1 ELSE 0 END),
|
||
ROUND(SUM(salesAmountZone3), 2),
|
||
ROUND(SUM(afterSaleAmountZone3), 2),
|
||
ROUND(CASE WHEN SUM(salesAmountZone3) > 0 THEN SUM(afterSaleAmountZone3) / SUM(salesAmountZone3) ELSE 0 END, 6),
|
||
0,
|
||
0,
|
||
0,
|
||
ROUND(SUM(GREATEST(afterSaleAmountZone3, 0)), 2)
|
||
FROM scored
|
||
UNION ALL
|
||
SELECT
|
||
'zone',
|
||
'四区',
|
||
'',
|
||
SUM(CASE WHEN salesAmountZone4 <> 0 OR afterSaleAmountZone4 <> 0 THEN 1 ELSE 0 END),
|
||
ROUND(SUM(salesAmountZone4), 2),
|
||
ROUND(SUM(afterSaleAmountZone4), 2),
|
||
ROUND(CASE WHEN SUM(salesAmountZone4) > 0 THEN SUM(afterSaleAmountZone4) / SUM(salesAmountZone4) ELSE 0 END, 6),
|
||
0,
|
||
0,
|
||
0,
|
||
ROUND(SUM(GREATEST(afterSaleAmountZone4, 0)), 2)
|
||
FROM scored`,
|
||
base.params
|
||
);
|
||
|
||
const groups = {
|
||
channel: [],
|
||
shop: [],
|
||
purchaser: [],
|
||
status: [],
|
||
headTail: [],
|
||
zone: [],
|
||
};
|
||
|
||
(rows || []).forEach((row) => {
|
||
const normalized = normalizeChannelDashboardDimensionRow(row);
|
||
if (!groups[normalized.dimensionKey]) return;
|
||
groups[normalized.dimensionKey].push(normalized);
|
||
});
|
||
|
||
Object.keys(groups).forEach((key) => {
|
||
groups[key] = groups[key]
|
||
.sort((left, right) => (
|
||
(right.impactAmount - left.impactAmount)
|
||
|| (right.afterSaleAmount - left.afterSaleAmount)
|
||
|| (right.salesAmount - left.salesAmount)
|
||
|| (right.itemCount - left.itemCount)
|
||
))
|
||
.slice(0, key === 'shop' ? 15 : 12);
|
||
});
|
||
|
||
return groups;
|
||
}
|
||
|
||
function buildChannelImpactActionText(row = {}) {
|
||
const afterSaleRate = Number(row.afterSaleAmountRate || 0);
|
||
const refundRate = Number(row.refundRate || 0);
|
||
const afterSaleAmount = Number(row.afterSaleAmount || 0);
|
||
if (afterSaleRate >= 0.05 && refundRate >= 0.15) return '售后退货双高,优先复盘';
|
||
if (afterSaleRate >= 0.05) return '售后占比高,排查商品/履约';
|
||
if (refundRate >= 0.15) return '退货率高,核对质量与承诺';
|
||
if (afterSaleAmount > 0) return '售后金额高,关注大额退款';
|
||
return '持续观察';
|
||
}
|
||
|
||
async function queryChannelDashboardImpactRows(base, limit) {
|
||
const rows = await channelReadDb.query(
|
||
`${getChannelDashboardScoredSql(base.sql)}
|
||
SELECT
|
||
channelName,
|
||
shopName,
|
||
shopKey,
|
||
skuCode,
|
||
spu,
|
||
skuImageUrl,
|
||
saleStatus,
|
||
purchaserName,
|
||
salesAmount,
|
||
afterSaleAmount,
|
||
afterSaleAmountRate,
|
||
shipCount,
|
||
refundCount,
|
||
refundRate,
|
||
ROUND(refundImpactAmount, 2) AS refundImpactAmount,
|
||
ROUND(impactAmount, 2) AS impactAmount
|
||
FROM scored
|
||
WHERE impactAmount > 0
|
||
OR highAfterSaleFlag = 1
|
||
OR highRefundFlag = 1
|
||
ORDER BY impactAmount DESC, afterSaleAmount DESC, salesAmount DESC, skuCode ASC
|
||
LIMIT ?`,
|
||
[...base.params, limit]
|
||
);
|
||
|
||
return (rows || []).map((row, index) => ({
|
||
rank: index + 1,
|
||
channelName: row.channelName || '未归类',
|
||
shopName: row.shopName || '--',
|
||
shopKey: row.shopKey || '',
|
||
skuCode: row.skuCode || '',
|
||
spu: row.spu || '',
|
||
skuImageUrl: row.skuImageUrl || null,
|
||
saleStatus: row.saleStatus || '未配置',
|
||
purchaserName: row.purchaserName || '未配置',
|
||
salesAmount: toNumber(row.salesAmount, 2),
|
||
afterSaleAmount: toNumber(row.afterSaleAmount, 2),
|
||
afterSaleAmountRate: toNumber(row.afterSaleAmountRate, 6),
|
||
shipCount: toNumber(row.shipCount, 0),
|
||
refundCount: toNumber(row.refundCount, 0),
|
||
refundRate: toNumber(row.refundRate, 6),
|
||
refundImpactAmount: toNumber(row.refundImpactAmount, 2),
|
||
impactAmount: toNumber(row.impactAmount, 2),
|
||
actionText: buildChannelImpactActionText(row),
|
||
}));
|
||
}
|
||
|
||
function buildChannelDashboardMetricCards(summary, dateRange) {
|
||
return [
|
||
{
|
||
key: 'salesAmount',
|
||
label: '区间销售额',
|
||
value: summary.salesAmount,
|
||
format: 'currency',
|
||
desc: `${dateRange.startDate} 至 ${dateRange.endDate}`,
|
||
},
|
||
{
|
||
key: 'shipCount',
|
||
label: '发货件数',
|
||
value: summary.shipCount,
|
||
suffix: '件',
|
||
desc: `${summary.skuCount.toLocaleString('zh-CN')} 个SKU / ${summary.spuCount.toLocaleString('zh-CN')} 个SPU`,
|
||
},
|
||
{
|
||
key: 'afterSaleAmount',
|
||
label: '售后金额',
|
||
value: summary.afterSaleAmount,
|
||
format: 'currency',
|
||
desc: `高售后 ${summary.highAfterSaleSkuCount.toLocaleString('zh-CN')} 个SKU`,
|
||
},
|
||
{
|
||
key: 'afterSaleAmountRate',
|
||
label: '售后占比',
|
||
value: summary.afterSaleAmountRate,
|
||
format: 'percent',
|
||
desc: '售后金额 / 销售额',
|
||
},
|
||
{
|
||
key: 'refundRate',
|
||
label: '退货率',
|
||
value: summary.refundRate,
|
||
format: 'percent',
|
||
desc: `高退货 ${summary.highRefundSkuCount.toLocaleString('zh-CN')} 个SKU`,
|
||
},
|
||
{
|
||
key: 'impactAmount',
|
||
label: '预计影响金额',
|
||
value: summary.impactAmount,
|
||
format: 'currency',
|
||
desc: '售后金额 + 退货按客单折算',
|
||
},
|
||
];
|
||
}
|
||
|
||
async function getChannelDashboard(options = {}) {
|
||
const dateRange = await resolveDateRange(options.startDate, options.endDate);
|
||
const limit = normalizeChannelDashboardLimit(options.limit);
|
||
const cacheKey = buildChannelDashboardCacheKey({
|
||
...dateRange,
|
||
keyword: options.keyword,
|
||
limit,
|
||
});
|
||
const staleCacheKey = cacheKey.replace(
|
||
`channel:dashboard:${CHANNEL_DASHBOARD_CACHE_VERSION}:`,
|
||
`channel:dashboard-stale:${CHANNEL_DASHBOARD_CACHE_VERSION}:`
|
||
);
|
||
|
||
const cached = await dataPageCache.getOrSetCachedPayload({
|
||
key: cacheKey,
|
||
memoryTtlSeconds: CHANNEL_DASHBOARD_MEMORY_TTL_SECONDS,
|
||
redisTtlSeconds: CHANNEL_DASHBOARD_CACHE_TTL_SECONDS,
|
||
staleKey: staleCacheKey,
|
||
staleMemoryTtlSeconds: CHANNEL_DASHBOARD_STALE_MEMORY_TTL_SECONDS,
|
||
staleRedisTtlSeconds: CHANNEL_DASHBOARD_STALE_REDIS_TTL_SECONDS,
|
||
returnStaleWhileRevalidate: true,
|
||
label: `channel:dashboard ${dateRange.startDate}~${dateRange.endDate}`,
|
||
loader: async () => {
|
||
const coverageStartDate = getCoverageStartDate(dateRange);
|
||
const refreshedRanges = await ensureChannelReadCoverage(coverageStartDate, dateRange.endDate, dateRange.latestBizDate);
|
||
await ensurePlanningChannelDailyReadSchema();
|
||
const base = buildChannelDashboardBaseSql({
|
||
startDate: dateRange.startDate,
|
||
endDate: dateRange.endDate,
|
||
keyword: options.keyword,
|
||
});
|
||
|
||
const summary = await queryChannelDashboardSummary(base);
|
||
const trendRows = await queryChannelDashboardTrend({
|
||
startDate: dateRange.startDate,
|
||
endDate: dateRange.endDate,
|
||
keyword: options.keyword,
|
||
});
|
||
const dimensionGroups = await queryChannelDashboardDimensionGroups(base);
|
||
const impactRows = await queryChannelDashboardImpactRows(base, limit);
|
||
|
||
return {
|
||
dateRange,
|
||
summary,
|
||
metricCards: buildChannelDashboardMetricCards(summary, dateRange),
|
||
trendRows,
|
||
dimensionGroups,
|
||
impactRows,
|
||
meta: {
|
||
source: CHANNEL_READ_TABLE,
|
||
refreshedRanges,
|
||
keyword: normalizeText(options.keyword),
|
||
limit,
|
||
impactRule: '影响金额 = 售后金额 + 退货件数按客单折算金额',
|
||
},
|
||
};
|
||
},
|
||
shouldCache: (payload) => Boolean(payload?.dateRange?.endDate),
|
||
});
|
||
|
||
return {
|
||
...cached.value,
|
||
meta: {
|
||
...(cached.value?.meta || {}),
|
||
cacheHit: cached.hit !== 'miss',
|
||
cacheLayer: cached.hit,
|
||
staleWhileRevalidate: String(cached.hit || '').startsWith('stale-'),
|
||
},
|
||
};
|
||
}
|
||
|
||
function normalizeReadStoreSpuRows(rows = []) {
|
||
return (rows || []).map((row) => ({
|
||
spu: row.spu || '',
|
||
skuCode: row.skuCode || row.skuCodeText || '',
|
||
skuCodeText: row.skuCodeText || row.skuCode || '',
|
||
skuCount: toNumber(row.skuCount, 0),
|
||
channelCount: toNumber(row.channelCount, 0),
|
||
shopCount: toNumber(row.shopCount, 0),
|
||
skuImageUrl: row.skuImageUrl || null,
|
||
saleStatus: row.saleStatus || '未配置',
|
||
purchaserName: row.purchaserName || '未配置',
|
||
salesAmount: toNumber(row.salesAmount),
|
||
afterSaleAmount: toNumber(row.afterSaleAmount),
|
||
afterSaleAmountRate: toNumber(row.afterSaleAmountRate),
|
||
shipCount: toNumber(row.shipCount),
|
||
refundCount: toNumber(row.refundCount),
|
||
refundRate: toNumber(row.refundRate),
|
||
salesAmountZone1: toNumber(row.salesAmountZone1),
|
||
salesAmountZone2: toNumber(row.salesAmountZone2),
|
||
salesAmountZone3: toNumber(row.salesAmountZone3),
|
||
salesAmountZone4: toNumber(row.salesAmountZone4),
|
||
afterSaleAmountZone1: toNumber(row.afterSaleAmountZone1),
|
||
afterSaleAmountZone2: toNumber(row.afterSaleAmountZone2),
|
||
afterSaleAmountZone3: toNumber(row.afterSaleAmountZone3),
|
||
afterSaleAmountZone4: toNumber(row.afterSaleAmountZone4),
|
||
afterSaleAmountRateZone1: toNumber(row.afterSaleAmountRateZone1),
|
||
afterSaleAmountRateZone2: toNumber(row.afterSaleAmountRateZone2),
|
||
afterSaleAmountRateZone3: toNumber(row.afterSaleAmountRateZone3),
|
||
afterSaleAmountRateZone4: toNumber(row.afterSaleAmountRateZone4),
|
||
}));
|
||
}
|
||
|
||
function buildChannelStockMonitorEmptyDashboardPayload(dateRange, stockMonitorMonths) {
|
||
const metricCards = [
|
||
{ key: 'riskSkuCount', label: '风险SKU', value: 0, precision: 0, desc: '库存低于生产周期或已缺货的 SKU 数' },
|
||
{ key: 'warningSkuCount', label: '需提醒SKU', value: 0, precision: 0, desc: '当前库存低于生产周期的 SKU 数' },
|
||
{ key: 'stockOutSkuCount', label: '缺货SKU', value: 0, precision: 0, desc: '当前缺货或断货标记的 SKU 数' },
|
||
{ key: 'estimatedImpactQty', label: '预计影响件数', value: 0, precision: 0, suffix: '件', desc: '按缺口与近均销估算的影响件数' },
|
||
{ key: 'factoryCount', label: '涉及工厂', value: 0, precision: 0, desc: '受当前库存风险影响的供应工厂数' },
|
||
];
|
||
|
||
return {
|
||
metricCards,
|
||
trendRows: listDatesInRange(dateRange.startDate, dateRange.endDate).map((statDate) => ({
|
||
statDate,
|
||
warningSkuCount: 0,
|
||
stockOutSkuCount: 0,
|
||
estimatedImpactQty: 0,
|
||
})),
|
||
monthlyRows: (stockMonitorMonths || []).map((month) => ({
|
||
monthKey: month.key,
|
||
salesQty: 0,
|
||
stockOutCount: 0,
|
||
warningCount: 0,
|
||
})),
|
||
impactRows: [],
|
||
dimensionGroups: {
|
||
supplier: [],
|
||
purchase: [],
|
||
category: [],
|
||
warehouse: [],
|
||
skuStatus: [],
|
||
callType: [],
|
||
},
|
||
dateRange: {
|
||
startDate: dateRange.startDate,
|
||
endDate: dateRange.endDate,
|
||
latestDate: dateRange.latestBizDate,
|
||
latestBizDate: dateRange.latestBizDate,
|
||
},
|
||
meta: {
|
||
source: 'read-store-spu-stock-monitor-dashboard',
|
||
stockMonitorSourceTable: STOCK_MONITOR_BACKUP_TABLE,
|
||
stockMonitorMonths,
|
||
stockMonitorWarningRule: 'jsStock < produceCycle',
|
||
stockMonitorWarningText: STOCK_MONITOR_WARNING_TEXT,
|
||
},
|
||
};
|
||
}
|
||
|
||
function matchesStockMonitorDashboardKeyword(row, keyword) {
|
||
const normalizedKeyword = normalizeText(keyword).toLowerCase();
|
||
if (!normalizedKeyword) return true;
|
||
|
||
const searchableTexts = [
|
||
row.skuCode,
|
||
row.skuName,
|
||
row.spu,
|
||
row.factoryName,
|
||
row.purchase,
|
||
row.purchaserName,
|
||
row.category,
|
||
row.categoryName,
|
||
row.material,
|
||
row.saleStatus,
|
||
];
|
||
|
||
return searchableTexts.some((value) => normalizeText(value).toLowerCase().includes(normalizedKeyword));
|
||
}
|
||
|
||
function calculateStockMonitorEstimatedImpactQty(row, rangeDayCount) {
|
||
const stock = toNullableNumber(row.stockMonitorStock, 2);
|
||
const produceCycle = toNullableNumber(row.stockMonitorProduceCycle, 2);
|
||
const stockOutCount = Math.max(0, toNumber(row.stockMonitorOutOfStock, 0));
|
||
const rangeSalesQty = Math.max(0, toNumber(row.shipCount, 2));
|
||
const avgDailySales = Math.max(
|
||
0,
|
||
toNullableNumber(row.avg30DaySales, 2) ?? (rangeDayCount > 0 ? toNumber(rangeSalesQty / rangeDayCount, 2) : 0)
|
||
);
|
||
const stockGap = stock !== null && produceCycle !== null ? Math.max(produceCycle - stock, 0) : 0;
|
||
|
||
let estimatedImpactQty = stockGap > 0 && avgDailySales > 0 ? stockGap * avgDailySales : 0;
|
||
if (estimatedImpactQty <= 0 && stockOutCount > 0 && avgDailySales > 0) {
|
||
estimatedImpactQty = Math.max(avgDailySales, avgDailySales * stockOutCount);
|
||
}
|
||
|
||
return toNumber(estimatedImpactQty, 0);
|
||
}
|
||
|
||
function buildStockMonitorActionText(row, estimatedImpactQty) {
|
||
const stockOutCount = Math.max(0, toNumber(row.stockMonitorOutOfStock, 0));
|
||
if (stockOutCount > 0 && estimatedImpactQty >= 30) {
|
||
return '紧急补货';
|
||
}
|
||
if (row.stockMonitorWarningRequired) {
|
||
return row.stockMonitorWarningText || STOCK_MONITOR_WARNING_TEXT;
|
||
}
|
||
if (stockOutCount > 0) {
|
||
return '检查补货';
|
||
}
|
||
if (estimatedImpactQty > 0) {
|
||
return '联系采购确认';
|
||
}
|
||
return '低风险观察';
|
||
}
|
||
|
||
function resolveStockMonitorDimensionValue(row, dimensionKey) {
|
||
switch (dimensionKey) {
|
||
case 'supplier':
|
||
return row.factoryName || '未配置';
|
||
case 'purchase':
|
||
return row.purchase || row.purchaserName || '未配置';
|
||
case 'category':
|
||
return row.category || row.categoryName || row.material || '未配置';
|
||
case 'warehouse':
|
||
return row.warehouseName || '未配置';
|
||
case 'skuStatus':
|
||
return row.skuStatus || row.saleStatus || '未配置';
|
||
case 'callType':
|
||
return row.callType || '未配置';
|
||
default:
|
||
return '未配置';
|
||
}
|
||
}
|
||
|
||
function buildStockMonitorDimensionGroups(rows = []) {
|
||
const dimensionKeys = ['supplier', 'purchase', 'category', 'warehouse', 'skuStatus', 'callType'];
|
||
return Object.fromEntries(dimensionKeys.map((dimensionKey) => {
|
||
const bucketMap = new Map();
|
||
|
||
rows.forEach((row) => {
|
||
const dimensionValue = resolveStockMonitorDimensionValue(row, dimensionKey);
|
||
const bucket = bucketMap.get(dimensionValue) || {
|
||
dimensionKey,
|
||
dimensionValue,
|
||
skuCount: 0,
|
||
warningSkuCount: 0,
|
||
stockOutSkuCount: 0,
|
||
estimatedImpactQty: 0,
|
||
};
|
||
|
||
bucket.skuCount += 1;
|
||
bucket.warningSkuCount += row.stockMonitorWarningRequired ? 1 : 0;
|
||
bucket.stockOutSkuCount += row.stockOutFlag ? 1 : 0;
|
||
bucket.estimatedImpactQty += toNumber(row.estimatedImpactQty, 0);
|
||
bucketMap.set(dimensionValue, bucket);
|
||
});
|
||
|
||
const groupedRows = Array.from(bucketMap.values())
|
||
.map((bucket) => ({
|
||
...bucket,
|
||
estimatedImpactQty: toNumber(bucket.estimatedImpactQty, 0),
|
||
}))
|
||
.sort((left, right) => (
|
||
right.estimatedImpactQty - left.estimatedImpactQty
|
||
|| right.warningSkuCount - left.warningSkuCount
|
||
|| right.stockOutSkuCount - left.stockOutSkuCount
|
||
|| right.skuCount - left.skuCount
|
||
|| left.dimensionValue.localeCompare(right.dimensionValue, 'zh-CN')
|
||
));
|
||
|
||
return [dimensionKey, groupedRows];
|
||
}));
|
||
}
|
||
|
||
async function fetchChannelReadSkuDailySales(skuCodes, startDate, endDate) {
|
||
const uniqueSkuCodes = uniqueText(skuCodes);
|
||
if (!uniqueSkuCodes.length) return [];
|
||
|
||
await ensurePlanningChannelDailyReadSchema();
|
||
const dailyRows = [];
|
||
for (const skuChunk of chunkArray(uniqueSkuCodes, 300)) {
|
||
const rows = await channelReadDb.query(
|
||
`SELECT
|
||
stat_date AS statDate,
|
||
sku_code AS skuCode,
|
||
ROUND(SUM(ship_count), 2) AS shipCount
|
||
FROM ${CHANNEL_READ_TABLE}
|
||
WHERE stat_date BETWEEN ? AND ?
|
||
AND sku_code IN (${skuChunk.map(() => '?').join(', ')})
|
||
GROUP BY stat_date, sku_code
|
||
ORDER BY stat_date ASC, sku_code ASC`,
|
||
[startDate, endDate, ...skuChunk]
|
||
);
|
||
dailyRows.push(...(rows || []));
|
||
}
|
||
|
||
return dailyRows;
|
||
}
|
||
|
||
function buildStockMonitorTrendRows(rows, dailySalesRows, dateRange) {
|
||
const trendMap = new Map(listDatesInRange(dateRange.startDate, dateRange.endDate).map((statDate) => [statDate, {
|
||
statDate,
|
||
warningSkuKeys: new Set(),
|
||
stockOutSkuKeys: new Set(),
|
||
estimatedImpactQty: 0,
|
||
}]));
|
||
|
||
if (!rows.length) {
|
||
return Array.from(trendMap.values()).map((trend) => ({
|
||
statDate: trend.statDate,
|
||
warningSkuCount: 0,
|
||
stockOutSkuCount: 0,
|
||
estimatedImpactQty: 0,
|
||
}));
|
||
}
|
||
|
||
const rowMap = new Map(rows.map((row) => [normalizeSkuCodeKey(row.skuCode), row]));
|
||
(dailySalesRows || []).forEach((dailyRow) => {
|
||
const statDate = normalizeDateValue(dailyRow.statDate);
|
||
const trend = trendMap.get(statDate);
|
||
const row = rowMap.get(normalizeSkuCodeKey(dailyRow.skuCode));
|
||
if (!trend || !row) return;
|
||
|
||
if (row.stockMonitorWarningRequired) {
|
||
trend.warningSkuKeys.add(row.skuCode);
|
||
}
|
||
if (row.stockOutFlag) {
|
||
trend.stockOutSkuKeys.add(row.skuCode);
|
||
}
|
||
|
||
const shipCount = Math.max(0, toNumber(dailyRow.shipCount, 2));
|
||
if (row.estimatedImpactQty > 0 && row.rangeSalesQty > 0) {
|
||
trend.estimatedImpactQty += row.estimatedImpactQty * (shipCount / row.rangeSalesQty);
|
||
} else if (row.stockOutFlag && shipCount > 0) {
|
||
trend.estimatedImpactQty += shipCount;
|
||
}
|
||
});
|
||
|
||
return Array.from(trendMap.values()).map((trend) => ({
|
||
statDate: trend.statDate,
|
||
warningSkuCount: trend.warningSkuKeys.size,
|
||
stockOutSkuCount: trend.stockOutSkuKeys.size,
|
||
estimatedImpactQty: toNumber(trend.estimatedImpactQty, 0),
|
||
}));
|
||
}
|
||
|
||
function buildStockMonitorMonthlyRows(rows, stockMonitorMonths) {
|
||
const monthlyMap = new Map((stockMonitorMonths || []).map((month) => [month.key, {
|
||
monthKey: month.key,
|
||
salesQty: 0,
|
||
stockOutCount: 0,
|
||
warningCount: 0,
|
||
}]));
|
||
|
||
rows.forEach((row) => {
|
||
(row.stockMonitorMonthly || []).forEach((monthItem) => {
|
||
const bucket = monthlyMap.get(monthItem.monthKey);
|
||
if (!bucket) return;
|
||
|
||
bucket.salesQty += toNumber(monthItem.monthlySalesQty, 0);
|
||
bucket.stockOutCount += toNumber(monthItem.stockOutCount, 0);
|
||
bucket.warningCount += toNumber(monthItem.warningCount, 0);
|
||
});
|
||
});
|
||
|
||
return (stockMonitorMonths || []).map((month) => {
|
||
const bucket = monthlyMap.get(month.key) || {
|
||
monthKey: month.key,
|
||
salesQty: 0,
|
||
stockOutCount: 0,
|
||
warningCount: 0,
|
||
};
|
||
|
||
return {
|
||
monthKey: bucket.monthKey,
|
||
salesQty: toNumber(bucket.salesQty, 0),
|
||
stockOutCount: toNumber(bucket.stockOutCount, 0),
|
||
warningCount: toNumber(bucket.warningCount, 0),
|
||
};
|
||
});
|
||
}
|
||
|
||
async function fetchStockMonitorDashboardCandidateInfo(stockMonitorMonths = []) {
|
||
if (!stockMonitorMonths.length) {
|
||
return {
|
||
candidateSkuCodes: [],
|
||
failureMessages: [],
|
||
sourceUnavailable: false,
|
||
};
|
||
}
|
||
|
||
const queryWindow = buildStockMonitorQueryWindow(stockMonitorMonths);
|
||
if (!queryWindow) {
|
||
return {
|
||
candidateSkuCodes: [],
|
||
failureMessages: [],
|
||
sourceUnavailable: false,
|
||
};
|
||
}
|
||
|
||
try {
|
||
const candidateRows = await cloudDb.queryWithFreshConnection(
|
||
`SELECT DISTINCT
|
||
${STOCK_MONITOR_SKU_EXPR_SQL} AS skuCode
|
||
FROM ${STOCK_MONITOR_BACKUP_TABLE} WITH (NOLOCK)
|
||
WHERE TRY_CONVERT(datetime, [date]) >= @startDate
|
||
AND TRY_CONVERT(datetime, [date]) < @endExclusive
|
||
AND (
|
||
(
|
||
TRY_CONVERT(DECIMAL(18, 2), [jsStock]) IS NOT NULL
|
||
AND TRY_CONVERT(DECIMAL(18, 2), [produceCycle]) IS NOT NULL
|
||
AND TRY_CONVERT(DECIMAL(18, 2), [jsStock]) < TRY_CONVERT(DECIMAL(18, 2), [produceCycle])
|
||
)
|
||
OR ISNULL(TRY_CONVERT(DECIMAL(18, 2), [outOfStock]), 0) > 0
|
||
)`,
|
||
{
|
||
startDate: queryWindow.startDateValue,
|
||
endExclusive: queryWindow.endExclusiveValue,
|
||
},
|
||
CHANNEL_STOCK_MONITOR_QUERY_OPTIONS
|
||
);
|
||
|
||
return {
|
||
candidateSkuCodes: uniqueText((candidateRows || []).map((row) => row.skuCode)),
|
||
failureMessages: [],
|
||
sourceUnavailable: false,
|
||
};
|
||
} catch (error) {
|
||
const message = error?.message || String(error || 'unknown');
|
||
console.warn('[Channel] 查询库存监控候选SKU失败:', message);
|
||
return {
|
||
candidateSkuCodes: [],
|
||
failureMessages: [message],
|
||
sourceUnavailable: true,
|
||
};
|
||
}
|
||
}
|
||
|
||
function shouldRejectStockMonitorDashboardPayload(candidateInfo, diagnostics) {
|
||
return (
|
||
Number(candidateInfo?.candidateSkuCodes?.length || 0) > 0
|
||
&& Number(diagnostics?.failedChunkCount || 0) > 0
|
||
&& Number(diagnostics?.succeededChunkCount || 0) === 0
|
||
&& !diagnostics?.hasData
|
||
);
|
||
}
|
||
|
||
function shouldCacheStockMonitorDashboardPayload(payload) {
|
||
if (!payload?.dateRange?.endDate) {
|
||
return false;
|
||
}
|
||
|
||
if (payload?.meta?.stockMonitorSourceUnavailable) {
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
async function loadChannelStockMonitorDashboard(options, resolvedDateRange, { skipCoverage = false } = {}) {
|
||
const dateRange = resolvedDateRange || await resolveDateRange(options.startDate, options.endDate);
|
||
const coverageStartDate = getCoverageStartDate(dateRange);
|
||
const coverage = skipCoverage
|
||
? {
|
||
refreshedRanges: [],
|
||
usedStaleData: false,
|
||
coverageMode: 'snapshot-skip',
|
||
}
|
||
: await resolveChannelReadCoverage(
|
||
coverageStartDate,
|
||
dateRange.endDate,
|
||
dateRange.latestBizDate,
|
||
{
|
||
allowStale: true,
|
||
label: '库存销售影响仪表盘',
|
||
}
|
||
);
|
||
const refreshedRanges = coverage.refreshedRanges;
|
||
const stockMonitorMonths = buildStockMonitorMonthBuckets(dateRange.endDate);
|
||
const candidateInfo = await fetchStockMonitorDashboardCandidateInfo(stockMonitorMonths);
|
||
const emptyPayload = buildChannelStockMonitorEmptyDashboardPayload(dateRange, stockMonitorMonths);
|
||
const resolvedLimit = safePageSize(options.limit, 50);
|
||
|
||
if (candidateInfo.sourceUnavailable) {
|
||
return {
|
||
...emptyPayload,
|
||
dateRange: {
|
||
...emptyPayload.dateRange,
|
||
latestDate: dateRange.latestBizDate,
|
||
},
|
||
meta: {
|
||
...(emptyPayload.meta || {}),
|
||
refreshedRanges,
|
||
usedStaleData: coverage.usedStaleData,
|
||
coverageMode: coverage.coverageMode,
|
||
coverageSkipped: skipCoverage,
|
||
stockMonitorSourceUnavailable: true,
|
||
stockMonitorFailureMessages: candidateInfo.failureMessages || [],
|
||
},
|
||
};
|
||
}
|
||
|
||
if (!candidateInfo.candidateSkuCodes.length) {
|
||
return {
|
||
...emptyPayload,
|
||
dateRange: {
|
||
...emptyPayload.dateRange,
|
||
latestDate: dateRange.latestBizDate,
|
||
},
|
||
meta: {
|
||
...(emptyPayload.meta || {}),
|
||
refreshedRanges,
|
||
readStoreCoverageMode: coverage.coverageMode,
|
||
staleRequestedWindow: coverage.usedStaleData,
|
||
totalSkuCount: 0,
|
||
candidateSkuCount: 0,
|
||
},
|
||
};
|
||
}
|
||
|
||
const { sql, params } = buildReadStoreSpuFilteredSql({
|
||
startDate: dateRange.startDate,
|
||
endDate: dateRange.endDate,
|
||
keyword: '',
|
||
statuses: options.statuses,
|
||
purchasers: options.purchasers,
|
||
minAfterSaleAmountRate: options.minAfterSaleAmountRate,
|
||
maxAfterSaleAmountRate: options.maxAfterSaleAmountRate,
|
||
skuCodes: candidateInfo.candidateSkuCodes,
|
||
});
|
||
|
||
await ensurePlanningChannelDailyReadSchema();
|
||
const baseRows = await channelReadDb.query(
|
||
`SELECT * FROM (${sql}) base ORDER BY base.shipCount DESC, base.spu ASC`,
|
||
params
|
||
);
|
||
|
||
if (!baseRows.length) {
|
||
return emptyPayload;
|
||
}
|
||
|
||
const candidateSkuSet = new Set(candidateInfo.candidateSkuCodes.map(normalizeSkuCodeKey));
|
||
const normalizedRows = normalizeReadStoreSpuRows(baseRows)
|
||
.filter((row) => candidateSkuSet.has(normalizeSkuCodeKey(row.skuCode || row.skuCodeText)));
|
||
if (!normalizedRows.length) {
|
||
return {
|
||
...emptyPayload,
|
||
dateRange: {
|
||
...emptyPayload.dateRange,
|
||
latestDate: dateRange.latestBizDate,
|
||
},
|
||
meta: {
|
||
...(emptyPayload.meta || {}),
|
||
refreshedRanges,
|
||
readStoreCoverageMode: coverage.coverageMode,
|
||
staleRequestedWindow: coverage.usedStaleData,
|
||
totalSkuCount: 0,
|
||
candidateSkuCount: candidateInfo.candidateSkuCodes.length,
|
||
},
|
||
};
|
||
}
|
||
|
||
const enrichedResult = await enrichStockMonitorDashboardRowsDetailed(normalizedRows, stockMonitorMonths);
|
||
const enrichedRows = enrichedResult.rows;
|
||
const stockMonitorDiagnostics = enrichedResult.diagnostics;
|
||
const partialFailures = (stockMonitorDiagnostics.failureMessages || []).slice(0, 5);
|
||
if (shouldRejectStockMonitorDashboardPayload(candidateInfo, stockMonitorDiagnostics)) {
|
||
const stockMonitorError = new Error(partialFailures[0] || '库存监控源数据暂不可用');
|
||
stockMonitorError.code = 'STOCK_MONITOR_SOURCE_UNAVAILABLE';
|
||
stockMonitorError.partialFailureMessages = partialFailures;
|
||
throw stockMonitorError;
|
||
}
|
||
|
||
const rangeDayCount = Math.max(dayjs(dateRange.endDate).diff(dayjs(dateRange.startDate), 'day') + 1, 1);
|
||
const dashboardRows = enrichedRows.map((row) => {
|
||
const rangeSalesQty = Math.max(0, toNumber(row.shipCount, 0));
|
||
const estimatedImpactQty = calculateStockMonitorEstimatedImpactQty(row, rangeDayCount);
|
||
const stockOutFlag = Math.max(0, toNumber(row.stockMonitorOutOfStock, 0)) > 0;
|
||
const purchase = row.nonSpuOwner || row.purchaserName || '未配置';
|
||
const category = row.categoryName || row.material || '未配置';
|
||
|
||
return {
|
||
...row,
|
||
skuName: row.skuName || row.spu || row.skuCode,
|
||
purchase,
|
||
category,
|
||
warehouseName: '未配置',
|
||
skuStatus: row.saleStatus || '未配置',
|
||
callType: '未配置',
|
||
rangeSalesQty,
|
||
estimatedImpactQty,
|
||
stockOutFlag,
|
||
actionText: buildStockMonitorActionText(row, estimatedImpactQty),
|
||
jsStock: row.stockMonitorStock,
|
||
jsUseDays: row.stockMonitorStockUseDays,
|
||
produceCycle: row.stockMonitorProduceCycle,
|
||
};
|
||
}).filter((row) => matchesStockMonitorDashboardKeyword(row, options.keyword));
|
||
|
||
if (!dashboardRows.length) {
|
||
return {
|
||
...emptyPayload,
|
||
dateRange: {
|
||
...emptyPayload.dateRange,
|
||
latestDate: dateRange.latestBizDate,
|
||
},
|
||
meta: {
|
||
...(emptyPayload.meta || {}),
|
||
refreshedRanges,
|
||
readStoreCoverageMode: coverage.coverageMode,
|
||
staleRequestedWindow: coverage.usedStaleData,
|
||
totalSkuCount: 0,
|
||
candidateSkuCount: candidateInfo.candidateSkuCodes.length,
|
||
},
|
||
};
|
||
}
|
||
|
||
const dimensionGroups = buildStockMonitorDimensionGroups(dashboardRows);
|
||
const monthlyRows = buildStockMonitorMonthlyRows(dashboardRows, stockMonitorMonths);
|
||
const impactedRows = dashboardRows
|
||
.filter((row) => row.estimatedImpactQty > 0 || row.stockMonitorWarningRequired || row.stockOutFlag)
|
||
.sort((left, right) => (
|
||
right.estimatedImpactQty - left.estimatedImpactQty
|
||
|| Number(right.stockOutFlag) - Number(left.stockOutFlag)
|
||
|| Number(right.stockMonitorWarningRequired) - Number(left.stockMonitorWarningRequired)
|
||
|| right.rangeSalesQty - left.rangeSalesQty
|
||
|| left.skuCode.localeCompare(right.skuCode, 'en')
|
||
));
|
||
const impactRows = impactedRows.slice(0, resolvedLimit).map((row, index) => ({
|
||
rank: index + 1,
|
||
skuCode: row.skuCode,
|
||
skuName: row.skuName || row.spu || row.skuCode,
|
||
factoryName: row.factoryName || '未配置',
|
||
purchase: row.purchase || '未配置',
|
||
category: row.category || '未配置',
|
||
skuStatus: row.skuStatus || '未配置',
|
||
jsStock: row.jsStock,
|
||
jsUseDays: row.jsUseDays,
|
||
produceCycle: row.produceCycle,
|
||
rangeSalesQty: row.rangeSalesQty,
|
||
estimatedImpactQty: row.estimatedImpactQty,
|
||
actionText: row.actionText,
|
||
}));
|
||
|
||
const dailySalesRows = await fetchChannelReadSkuDailySales(
|
||
dashboardRows.map((row) => row.skuCode),
|
||
dateRange.startDate,
|
||
dateRange.endDate
|
||
);
|
||
const trendRows = buildStockMonitorTrendRows(dashboardRows, dailySalesRows, dateRange);
|
||
const warningSkuCount = dashboardRows.filter((row) => row.stockMonitorWarningRequired).length;
|
||
const stockOutSkuCount = dashboardRows.filter((row) => row.stockOutFlag).length;
|
||
const riskSkuCount = dashboardRows.filter((row) => row.stockMonitorWarningRequired || row.stockOutFlag || row.estimatedImpactQty > 0).length;
|
||
const estimatedImpactQtyTotal = toNumber(
|
||
dashboardRows.reduce((total, row) => total + toNumber(row.estimatedImpactQty, 0), 0),
|
||
0
|
||
);
|
||
const factoryCount = uniqueText(
|
||
dashboardRows
|
||
.filter((row) => row.estimatedImpactQty > 0 || row.stockMonitorWarningRequired || row.stockOutFlag)
|
||
.map((row) => row.factoryName)
|
||
).length;
|
||
const latestDate = dashboardRows
|
||
.map((row) => normalizeDateValue(row.stockMonitorLatestDate))
|
||
.filter(Boolean)
|
||
.sort()
|
||
.slice(-1)[0] || dateRange.latestBizDate;
|
||
|
||
return {
|
||
metricCards: [
|
||
{ key: 'riskSkuCount', label: '风险SKU', value: riskSkuCount, precision: 0, desc: '库存低于生产周期或已缺货的 SKU 数' },
|
||
{ key: 'warningSkuCount', label: '需提醒SKU', value: warningSkuCount, precision: 0, desc: '当前库存低于生产周期的 SKU 数' },
|
||
{ key: 'stockOutSkuCount', label: '缺货SKU', value: stockOutSkuCount, precision: 0, desc: '当前缺货或断货标记的 SKU 数' },
|
||
{ key: 'estimatedImpactQty', label: '预计影响件数', value: estimatedImpactQtyTotal, precision: 0, suffix: '件', desc: '按缺口与近均销估算的影响件数' },
|
||
{ key: 'factoryCount', label: '涉及工厂', value: factoryCount, precision: 0, desc: '受当前库存风险影响的供应工厂数' },
|
||
],
|
||
trendRows,
|
||
monthlyRows,
|
||
impactRows,
|
||
dimensionGroups,
|
||
dateRange: {
|
||
startDate: dateRange.startDate,
|
||
endDate: dateRange.endDate,
|
||
latestDate,
|
||
latestBizDate: dateRange.latestBizDate,
|
||
},
|
||
meta: {
|
||
source: 'read-store-spu-stock-monitor-dashboard',
|
||
refreshedRanges,
|
||
readStoreCoverageMode: coverage.coverageMode,
|
||
staleRequestedWindow: coverage.usedStaleData,
|
||
partialData: partialFailures.length > 0,
|
||
partialFailureMessages: partialFailures,
|
||
totalSkuCount: dashboardRows.length,
|
||
candidateSkuCount: candidateInfo.candidateSkuCodes.length,
|
||
stockMonitorSourceTable: STOCK_MONITOR_BACKUP_TABLE,
|
||
stockMonitorSourceUnavailable: partialFailures.length > 0 && !stockMonitorDiagnostics.hasData,
|
||
stockMonitorMonths,
|
||
stockMonitorWarningRule: 'jsStock < produceCycle',
|
||
stockMonitorWarningText: STOCK_MONITOR_WARNING_TEXT,
|
||
},
|
||
};
|
||
}
|
||
|
||
async function getChannelStockMonitorDashboard(options = {}) {
|
||
const dateRange = await resolveDateRange(options.startDate, options.endDate);
|
||
const resolvedLimit = safePageSize(options.limit, 50);
|
||
if (isDefaultChannelStockMonitorDashboardQuery(options, dateRange, resolvedLimit)) {
|
||
const snapshotConfig = buildChannelStockMonitorDashboardDefaultSnapshotConfig(dateRange.latestBizDate, resolvedLimit);
|
||
const snapshot = await getChannelStockMonitorDashboardDefaultSnapshot(snapshotConfig);
|
||
|
||
return {
|
||
...snapshot.value,
|
||
meta: {
|
||
...(snapshot.value?.meta || {}),
|
||
cacheHit: snapshot.hit !== 'miss-default',
|
||
cacheLayer: snapshot.hit,
|
||
},
|
||
};
|
||
}
|
||
|
||
const cacheKey = dataPageCache.buildHashedCacheKey(
|
||
'channel:stock-monitor-dashboard',
|
||
{
|
||
...options,
|
||
...dateRange,
|
||
limit: resolvedLimit,
|
||
syncVersion: CHANNEL_READ_SYNC_VERSION,
|
||
},
|
||
CHANNEL_STOCK_MONITOR_DASHBOARD_CACHE_VERSION
|
||
);
|
||
|
||
const cached = await dataPageCache.getOrSetCachedPayload({
|
||
key: cacheKey,
|
||
memoryTtlSeconds: CHANNEL_SPU_LIST_MEMORY_TTL_SECONDS,
|
||
redisTtlSeconds: CHANNEL_SPU_LIST_CACHE_TTL_SECONDS,
|
||
label: `channel:stock-monitor-dashboard ${dateRange.startDate}~${dateRange.endDate}`,
|
||
loader: () => loadChannelStockMonitorDashboard({ ...options, limit: resolvedLimit }, dateRange),
|
||
shouldCache: shouldCacheStockMonitorDashboardPayload,
|
||
});
|
||
|
||
return {
|
||
...cached.value,
|
||
meta: {
|
||
...(cached.value?.meta || {}),
|
||
cacheHit: cached.hit !== 'miss',
|
||
cacheLayer: cached.hit,
|
||
},
|
||
};
|
||
}
|
||
|
||
async function getChannelSpuList(options) {
|
||
const resolvedPage = safePage(options.page);
|
||
const resolvedPageSize = safePageSize(options.pageSize);
|
||
const dateRange = await resolveDateRange(options.startDate, options.endDate);
|
||
const stockMonitorMonths = buildStockMonitorMonthBuckets(dateRange.endDate);
|
||
const cacheKey = buildChannelSpuListCacheKey({
|
||
...options,
|
||
...dateRange,
|
||
page: resolvedPage,
|
||
pageSize: resolvedPageSize,
|
||
});
|
||
|
||
const cached = await dataPageCache.getOrSetCachedPayload({
|
||
key: cacheKey,
|
||
memoryTtlSeconds: CHANNEL_SPU_LIST_MEMORY_TTL_SECONDS,
|
||
redisTtlSeconds: CHANNEL_SPU_LIST_CACHE_TTL_SECONDS,
|
||
label: `channel:spu-list ${dateRange.startDate}~${dateRange.endDate} page=${resolvedPage}`,
|
||
loader: async () => {
|
||
const coverageStartDate = getCoverageStartDate(dateRange);
|
||
const coverage = await resolveChannelReadCoverage(
|
||
coverageStartDate,
|
||
dateRange.endDate,
|
||
dateRange.latestBizDate,
|
||
{
|
||
allowStale: true,
|
||
label: 'SKU维度列表',
|
||
}
|
||
);
|
||
const refreshedRanges = coverage.refreshedRanges;
|
||
const { sql, params } = buildReadStoreSpuFilteredSql({
|
||
startDate: dateRange.startDate,
|
||
endDate: dateRange.endDate,
|
||
keyword: options.keyword,
|
||
statuses: options.statuses,
|
||
purchasers: options.purchasers,
|
||
minAfterSaleAmountRate: options.minAfterSaleAmountRate,
|
||
maxAfterSaleAmountRate: options.maxAfterSaleAmountRate,
|
||
});
|
||
|
||
const total = await countReadStoreSpuRows({
|
||
startDate: dateRange.startDate,
|
||
endDate: dateRange.endDate,
|
||
keyword: options.keyword,
|
||
statuses: options.statuses,
|
||
purchasers: options.purchasers,
|
||
minAfterSaleAmountRate: options.minAfterSaleAmountRate,
|
||
maxAfterSaleAmountRate: options.maxAfterSaleAmountRate,
|
||
}, sql, params);
|
||
if (total === 0) {
|
||
return {
|
||
page: resolvedPage,
|
||
pageSize: resolvedPageSize,
|
||
total: 0,
|
||
rows: [],
|
||
dateRange,
|
||
meta: {
|
||
source: 'read-store-spu',
|
||
refreshedRanges,
|
||
readStoreCoverageMode: coverage.coverageMode,
|
||
staleRequestedWindow: coverage.usedStaleData,
|
||
stockMonitorSourceTable: STOCK_MONITOR_BACKUP_TABLE,
|
||
stockMonitorMonths,
|
||
stockMonitorWarningRule: 'jsStock < produceCycle',
|
||
stockMonitorWarningText: STOCK_MONITOR_WARNING_TEXT,
|
||
},
|
||
};
|
||
}
|
||
|
||
const offset = (resolvedPage - 1) * resolvedPageSize;
|
||
const orderSql = `${resolveSpuReadSortExpression(options.sortField)} ${resolveSortOrder(options.sortOrder)}, base.spu ASC`;
|
||
await ensurePlanningChannelDailyReadSchema();
|
||
const pageRows = await channelReadDb.query(
|
||
`SELECT * FROM (${sql}) base ORDER BY ${orderSql} LIMIT ? OFFSET ?`,
|
||
[...params, resolvedPageSize, offset]
|
||
);
|
||
|
||
const normalizedRows = (pageRows || []).map((row) => ({
|
||
spu: row.spu || '',
|
||
skuCode: row.skuCode || row.skuCodeText || '',
|
||
skuCodeText: row.skuCodeText || '',
|
||
skuCount: toNumber(row.skuCount, 0),
|
||
channelCount: toNumber(row.channelCount, 0),
|
||
shopCount: toNumber(row.shopCount, 0),
|
||
skuImageUrl: row.skuImageUrl || null,
|
||
saleStatus: row.saleStatus || '未配置',
|
||
purchaserName: row.purchaserName || '未配置',
|
||
salesAmount: toNumber(row.salesAmount),
|
||
afterSaleAmount: toNumber(row.afterSaleAmount),
|
||
afterSaleAmountRate: toNumber(row.afterSaleAmountRate),
|
||
shipCount: toNumber(row.shipCount),
|
||
refundCount: toNumber(row.refundCount),
|
||
refundRate: toNumber(row.refundRate),
|
||
salesAmountZone1: toNumber(row.salesAmountZone1),
|
||
salesAmountZone2: toNumber(row.salesAmountZone2),
|
||
salesAmountZone3: toNumber(row.salesAmountZone3),
|
||
salesAmountZone4: toNumber(row.salesAmountZone4),
|
||
afterSaleAmountZone1: toNumber(row.afterSaleAmountZone1),
|
||
afterSaleAmountZone2: toNumber(row.afterSaleAmountZone2),
|
||
afterSaleAmountZone3: toNumber(row.afterSaleAmountZone3),
|
||
afterSaleAmountZone4: toNumber(row.afterSaleAmountZone4),
|
||
afterSaleAmountRateZone1: toNumber(row.afterSaleAmountRateZone1),
|
||
afterSaleAmountRateZone2: toNumber(row.afterSaleAmountRateZone2),
|
||
afterSaleAmountRateZone3: toNumber(row.afterSaleAmountRateZone3),
|
||
afterSaleAmountRateZone4: toNumber(row.afterSaleAmountRateZone4),
|
||
}));
|
||
|
||
const rows = await enrichSkuRows(
|
||
normalizedRows,
|
||
dateRange.startDate,
|
||
dateRange.endDate,
|
||
dateRange.lookbackStartDate,
|
||
stockMonitorMonths
|
||
);
|
||
|
||
return {
|
||
page: resolvedPage,
|
||
pageSize: resolvedPageSize,
|
||
total,
|
||
rows,
|
||
dateRange,
|
||
meta: {
|
||
source: 'read-store-spu',
|
||
refreshedRanges,
|
||
readStoreCoverageMode: coverage.coverageMode,
|
||
staleRequestedWindow: coverage.usedStaleData,
|
||
stockMonitorSourceTable: STOCK_MONITOR_BACKUP_TABLE,
|
||
stockMonitorMonths,
|
||
stockMonitorWarningRule: 'jsStock < produceCycle',
|
||
stockMonitorWarningText: STOCK_MONITOR_WARNING_TEXT,
|
||
},
|
||
};
|
||
},
|
||
});
|
||
|
||
return {
|
||
...cached.value,
|
||
meta: {
|
||
...(cached.value?.meta || {}),
|
||
cacheHit: cached.hit !== 'miss',
|
||
cacheLayer: cached.hit,
|
||
},
|
||
};
|
||
}
|
||
|
||
function buildLargeCustomerMonthColumnSelect(months) {
|
||
return months.flatMap((month) => {
|
||
const aliasSuffix = month.replace('-', '');
|
||
return [
|
||
`ROUND(SUM(CASE WHEN period_month = '${month}' THEN total_amount ELSE 0 END), 2) AS monthAmount${aliasSuffix}`,
|
||
`ROUND(SUM(CASE WHEN period_month = '${month}' THEN total_goods_count ELSE 0 END), 2) AS monthGoodsCount${aliasSuffix}`,
|
||
`SUM(CASE WHEN period_month = '${month}' THEN order_count ELSE 0 END) AS monthOrderCount${aliasSuffix}`,
|
||
];
|
||
}).join(',\n ');
|
||
}
|
||
|
||
function normalizeLargeCustomerDashboardLimit(value) {
|
||
const parsed = Number.parseInt(value, 10);
|
||
if (!Number.isFinite(parsed) || parsed <= 0) return 30;
|
||
return Math.min(parsed, 80);
|
||
}
|
||
|
||
function buildLargeCustomerDashboardWhere({ monthRange, minSingleOrderQty, keyword, channels, shops }, alias = 'r') {
|
||
const prefix = alias ? `${alias}.` : '';
|
||
const params = [monthRange.startMonth, monthRange.endMonth, minSingleOrderQty];
|
||
const whereParts = [
|
||
`${prefix}period_month BETWEEN ? AND ?`,
|
||
`${prefix}min_single_order_qty = ?`,
|
||
];
|
||
|
||
const keywordText = String(keyword || '').trim();
|
||
if (keywordText) {
|
||
const keywordValue = `%${keywordText}%`;
|
||
whereParts.push(`(
|
||
${prefix}buyer_id LIKE ?
|
||
OR ${prefix}buyer_name LIKE ?
|
||
OR ${prefix}channel_name LIKE ?
|
||
OR ${prefix}shop_name LIKE ?
|
||
)`);
|
||
params.push(keywordValue, keywordValue, keywordValue, keywordValue);
|
||
}
|
||
|
||
addMysqlListFilter(whereParts, params, parseListParam(channels), `${prefix}channel_name`);
|
||
addMysqlListFilter(whereParts, params, parseListParam(shops), `${prefix}shop_name`);
|
||
|
||
return {
|
||
whereSql: whereParts.join(' AND '),
|
||
params,
|
||
};
|
||
}
|
||
|
||
function buildLargeCustomerDashboardBaseSql(filters) {
|
||
const where = buildLargeCustomerDashboardWhere(filters, 'r');
|
||
return {
|
||
sql: `SELECT
|
||
r.buyer_id AS buyerId,
|
||
COALESCE(MAX(NULLIF(r.buyer_name, '')), r.buyer_id) AS buyerName,
|
||
COALESCE(NULLIF(r.channel_name, ''), '未归类') AS channelName,
|
||
COALESCE(NULLIF(r.shop_name, ''), '未归类') AS shopName,
|
||
ROUND(SUM(r.total_amount), 2) AS totalAmount,
|
||
ROUND(SUM(r.total_goods_count), 2) AS totalGoodsCount,
|
||
SUM(r.order_count) AS orderCount,
|
||
COUNT(DISTINCT r.period_month) AS activeMonthCount,
|
||
MIN(r.first_order_date) AS firstOrderDate,
|
||
MAX(r.last_order_date) AS lastOrderDate,
|
||
ROUND(CASE WHEN SUM(r.order_count) > 0 THEN SUM(r.total_amount) / SUM(r.order_count) ELSE 0 END, 2) AS avgOrderAmount,
|
||
ROUND(CASE WHEN SUM(r.order_count) > 0 THEN SUM(r.total_goods_count) / SUM(r.order_count) ELSE 0 END, 2) AS avgGoodsPerOrder
|
||
FROM ${CHANNEL_LARGE_CUSTOMER_READ_TABLE} r
|
||
WHERE ${where.whereSql}
|
||
GROUP BY r.buyer_id, r.channel_name, r.shop_name`,
|
||
params: where.params,
|
||
};
|
||
}
|
||
|
||
function getLargeCustomerDashboardScoredSql(baseSql) {
|
||
return `WITH base AS (${baseSql}),
|
||
ranked AS (
|
||
SELECT
|
||
base.*,
|
||
SUM(totalAmount) OVER () AS grandTotalAmount,
|
||
SUM(totalAmount) OVER (
|
||
ORDER BY totalAmount DESC, buyerId ASC, channelName ASC, shopName ASC
|
||
ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
|
||
) AS amountBefore
|
||
FROM base
|
||
),
|
||
scored AS (
|
||
SELECT
|
||
ranked.*,
|
||
CASE
|
||
WHEN grandTotalAmount <= 0 THEN '尾部'
|
||
WHEN IFNULL(amountBefore, 0) / grandTotalAmount < 0.3 THEN '头部'
|
||
WHEN IFNULL(amountBefore, 0) / grandTotalAmount < 0.7 THEN '腰部'
|
||
ELSE '尾部'
|
||
END AS segmentName,
|
||
CASE
|
||
WHEN orderCount >= 10 THEN '高频10次+'
|
||
WHEN orderCount >= 3 THEN '复购3-9次'
|
||
WHEN activeMonthCount >= 2 THEN '跨月复购'
|
||
ELSE '单次大单'
|
||
END AS frequencyName
|
||
FROM ranked
|
||
)`;
|
||
}
|
||
|
||
function normalizeLargeCustomerDashboardSummary(row = {}) {
|
||
return {
|
||
customerGroupCount: toNumber(row.customerGroupCount, 0),
|
||
buyerCount: toNumber(row.buyerCount, 0),
|
||
channelCount: toNumber(row.channelCount, 0),
|
||
shopCount: toNumber(row.shopCount, 0),
|
||
totalAmount: toNumber(row.totalAmount, 2),
|
||
totalGoodsCount: toNumber(row.totalGoodsCount, 0),
|
||
orderCount: toNumber(row.orderCount, 0),
|
||
avgOrderAmount: toNumber(row.avgOrderAmount, 2),
|
||
avgGoodsPerOrder: toNumber(row.avgGoodsPerOrder, 2),
|
||
repeatCustomerCount: toNumber(row.repeatCustomerCount, 0),
|
||
multiMonthCustomerCount: toNumber(row.multiMonthCustomerCount, 0),
|
||
};
|
||
}
|
||
|
||
async function queryLargeCustomerDashboardSummary(base) {
|
||
const rows = await database.local.query(
|
||
`${getLargeCustomerDashboardScoredSql(base.sql)}
|
||
SELECT
|
||
COUNT(1) AS customerGroupCount,
|
||
COUNT(DISTINCT buyerId) AS buyerCount,
|
||
COUNT(DISTINCT channelName) AS channelCount,
|
||
COUNT(DISTINCT shopName) AS shopCount,
|
||
ROUND(SUM(totalAmount), 2) AS totalAmount,
|
||
ROUND(SUM(totalGoodsCount), 2) AS totalGoodsCount,
|
||
SUM(orderCount) AS orderCount,
|
||
ROUND(CASE WHEN SUM(orderCount) > 0 THEN SUM(totalAmount) / SUM(orderCount) ELSE 0 END, 2) AS avgOrderAmount,
|
||
ROUND(CASE WHEN SUM(orderCount) > 0 THEN SUM(totalGoodsCount) / SUM(orderCount) ELSE 0 END, 2) AS avgGoodsPerOrder,
|
||
SUM(CASE WHEN orderCount >= 2 THEN 1 ELSE 0 END) AS repeatCustomerCount,
|
||
SUM(CASE WHEN activeMonthCount >= 2 THEN 1 ELSE 0 END) AS multiMonthCustomerCount
|
||
FROM scored`,
|
||
base.params
|
||
);
|
||
|
||
return normalizeLargeCustomerDashboardSummary(rows?.[0] || {});
|
||
}
|
||
|
||
async function queryLargeCustomerDashboardTrend({ monthRange, minSingleOrderQty, keyword, channels, shops }) {
|
||
const where = buildLargeCustomerDashboardWhere({ monthRange, minSingleOrderQty, keyword, channels, shops }, 'r');
|
||
const rows = await database.local.query(
|
||
`SELECT
|
||
r.period_month AS periodMonth,
|
||
COUNT(1) AS customerGroupCount,
|
||
COUNT(DISTINCT r.buyer_id) AS buyerCount,
|
||
COUNT(DISTINCT r.channel_name) AS channelCount,
|
||
COUNT(DISTINCT r.shop_name) AS shopCount,
|
||
ROUND(SUM(r.total_amount), 2) AS totalAmount,
|
||
ROUND(SUM(r.total_goods_count), 2) AS totalGoodsCount,
|
||
SUM(r.order_count) AS orderCount,
|
||
ROUND(CASE WHEN SUM(r.order_count) > 0 THEN SUM(r.total_amount) / SUM(r.order_count) ELSE 0 END, 2) AS avgOrderAmount,
|
||
ROUND(CASE WHEN SUM(r.order_count) > 0 THEN SUM(r.total_goods_count) / SUM(r.order_count) ELSE 0 END, 2) AS avgGoodsPerOrder
|
||
FROM ${CHANNEL_LARGE_CUSTOMER_READ_TABLE} r
|
||
WHERE ${where.whereSql}
|
||
GROUP BY r.period_month
|
||
ORDER BY r.period_month ASC`,
|
||
where.params
|
||
);
|
||
|
||
return (rows || []).map((row) => ({
|
||
periodMonth: row.periodMonth || '',
|
||
customerGroupCount: toNumber(row.customerGroupCount, 0),
|
||
buyerCount: toNumber(row.buyerCount, 0),
|
||
channelCount: toNumber(row.channelCount, 0),
|
||
shopCount: toNumber(row.shopCount, 0),
|
||
totalAmount: toNumber(row.totalAmount, 2),
|
||
totalGoodsCount: toNumber(row.totalGoodsCount, 0),
|
||
orderCount: toNumber(row.orderCount, 0),
|
||
avgOrderAmount: toNumber(row.avgOrderAmount, 2),
|
||
avgGoodsPerOrder: toNumber(row.avgGoodsPerOrder, 2),
|
||
}));
|
||
}
|
||
|
||
function normalizeLargeCustomerDimensionRow(row = {}) {
|
||
return {
|
||
dimensionKey: row.dimensionKey || '',
|
||
dimensionValue: row.dimensionValue || '未归类',
|
||
subValue: row.subValue || '',
|
||
customerGroupCount: toNumber(row.customerGroupCount, 0),
|
||
buyerCount: toNumber(row.buyerCount, 0),
|
||
totalAmount: toNumber(row.totalAmount, 2),
|
||
totalGoodsCount: toNumber(row.totalGoodsCount, 0),
|
||
orderCount: toNumber(row.orderCount, 0),
|
||
avgOrderAmount: toNumber(row.avgOrderAmount, 2),
|
||
repeatCustomerCount: toNumber(row.repeatCustomerCount, 0),
|
||
multiMonthCustomerCount: toNumber(row.multiMonthCustomerCount, 0),
|
||
};
|
||
}
|
||
|
||
async function queryLargeCustomerDashboardDimensions(base) {
|
||
const rows = await database.local.query(
|
||
`${getLargeCustomerDashboardScoredSql(base.sql)}
|
||
SELECT
|
||
'channel' AS dimensionKey,
|
||
channelName AS dimensionValue,
|
||
'' AS subValue,
|
||
COUNT(1) AS customerGroupCount,
|
||
COUNT(DISTINCT buyerId) AS buyerCount,
|
||
ROUND(SUM(totalAmount), 2) AS totalAmount,
|
||
ROUND(SUM(totalGoodsCount), 2) AS totalGoodsCount,
|
||
SUM(orderCount) AS orderCount,
|
||
ROUND(CASE WHEN SUM(orderCount) > 0 THEN SUM(totalAmount) / SUM(orderCount) ELSE 0 END, 2) AS avgOrderAmount,
|
||
SUM(CASE WHEN orderCount >= 2 THEN 1 ELSE 0 END) AS repeatCustomerCount,
|
||
SUM(CASE WHEN activeMonthCount >= 2 THEN 1 ELSE 0 END) AS multiMonthCustomerCount
|
||
FROM scored
|
||
GROUP BY channelName
|
||
UNION ALL
|
||
SELECT
|
||
'shop',
|
||
shopName,
|
||
channelName,
|
||
COUNT(1),
|
||
COUNT(DISTINCT buyerId),
|
||
ROUND(SUM(totalAmount), 2),
|
||
ROUND(SUM(totalGoodsCount), 2),
|
||
SUM(orderCount),
|
||
ROUND(CASE WHEN SUM(orderCount) > 0 THEN SUM(totalAmount) / SUM(orderCount) ELSE 0 END, 2),
|
||
SUM(CASE WHEN orderCount >= 2 THEN 1 ELSE 0 END),
|
||
SUM(CASE WHEN activeMonthCount >= 2 THEN 1 ELSE 0 END)
|
||
FROM scored
|
||
GROUP BY channelName, shopName
|
||
UNION ALL
|
||
SELECT
|
||
'headTail',
|
||
segmentName,
|
||
'',
|
||
COUNT(1),
|
||
COUNT(DISTINCT buyerId),
|
||
ROUND(SUM(totalAmount), 2),
|
||
ROUND(SUM(totalGoodsCount), 2),
|
||
SUM(orderCount),
|
||
ROUND(CASE WHEN SUM(orderCount) > 0 THEN SUM(totalAmount) / SUM(orderCount) ELSE 0 END, 2),
|
||
SUM(CASE WHEN orderCount >= 2 THEN 1 ELSE 0 END),
|
||
SUM(CASE WHEN activeMonthCount >= 2 THEN 1 ELSE 0 END)
|
||
FROM scored
|
||
GROUP BY segmentName
|
||
UNION ALL
|
||
SELECT
|
||
'frequency',
|
||
frequencyName,
|
||
'',
|
||
COUNT(1),
|
||
COUNT(DISTINCT buyerId),
|
||
ROUND(SUM(totalAmount), 2),
|
||
ROUND(SUM(totalGoodsCount), 2),
|
||
SUM(orderCount),
|
||
ROUND(CASE WHEN SUM(orderCount) > 0 THEN SUM(totalAmount) / SUM(orderCount) ELSE 0 END, 2),
|
||
SUM(CASE WHEN orderCount >= 2 THEN 1 ELSE 0 END),
|
||
SUM(CASE WHEN activeMonthCount >= 2 THEN 1 ELSE 0 END)
|
||
FROM scored
|
||
GROUP BY frequencyName`,
|
||
base.params
|
||
);
|
||
|
||
const groups = {
|
||
channel: [],
|
||
shop: [],
|
||
headTail: [],
|
||
frequency: [],
|
||
};
|
||
|
||
(rows || []).forEach((row) => {
|
||
const normalized = normalizeLargeCustomerDimensionRow(row);
|
||
if (!groups[normalized.dimensionKey]) return;
|
||
groups[normalized.dimensionKey].push(normalized);
|
||
});
|
||
|
||
Object.keys(groups).forEach((key) => {
|
||
groups[key] = groups[key]
|
||
.sort((left, right) => (
|
||
(right.totalAmount - left.totalAmount)
|
||
|| (right.orderCount - left.orderCount)
|
||
|| (right.buyerCount - left.buyerCount)
|
||
))
|
||
.slice(0, key === 'shop' ? 15 : 12);
|
||
});
|
||
|
||
return groups;
|
||
}
|
||
|
||
function buildLargeCustomerActionText(row = {}) {
|
||
const totalAmount = Number(row.totalAmount || 0);
|
||
const orderCount = Number(row.orderCount || 0);
|
||
const activeMonthCount = Number(row.activeMonthCount || 0);
|
||
if (totalAmount >= 100000 && activeMonthCount >= 3) return '高价值连续采购,重点维护';
|
||
if (totalAmount >= 50000) return '高价值客户,专人维护';
|
||
if (orderCount >= 10) return '高频复购,跟进权益';
|
||
if (activeMonthCount >= 2) return '跨月复购,推进复购计划';
|
||
return '大单客户,跟进复购';
|
||
}
|
||
|
||
async function queryLargeCustomerDashboardTopRows(base, limit) {
|
||
const rows = await database.local.query(
|
||
`${getLargeCustomerDashboardScoredSql(base.sql)}
|
||
SELECT
|
||
buyerId,
|
||
buyerName,
|
||
channelName,
|
||
shopName,
|
||
totalAmount,
|
||
totalGoodsCount,
|
||
orderCount,
|
||
activeMonthCount,
|
||
firstOrderDate,
|
||
lastOrderDate,
|
||
avgOrderAmount,
|
||
avgGoodsPerOrder,
|
||
segmentName,
|
||
frequencyName
|
||
FROM scored
|
||
ORDER BY totalAmount DESC, orderCount DESC, buyerId ASC
|
||
LIMIT ?`,
|
||
[...base.params, limit]
|
||
);
|
||
|
||
return (rows || []).map((row, index) => ({
|
||
rank: index + 1,
|
||
buyerId: row.buyerId || '',
|
||
buyerName: row.buyerName || row.buyerId || '',
|
||
channelName: row.channelName || '未归类',
|
||
shopName: row.shopName || '未归类',
|
||
totalAmount: toNumber(row.totalAmount, 2),
|
||
totalGoodsCount: toNumber(row.totalGoodsCount, 0),
|
||
orderCount: toNumber(row.orderCount, 0),
|
||
activeMonthCount: toNumber(row.activeMonthCount, 0),
|
||
firstOrderDate: normalizeDateValue(row.firstOrderDate),
|
||
lastOrderDate: normalizeDateValue(row.lastOrderDate),
|
||
avgOrderAmount: toNumber(row.avgOrderAmount, 2),
|
||
avgGoodsPerOrder: toNumber(row.avgGoodsPerOrder, 2),
|
||
segmentName: row.segmentName || '尾部',
|
||
frequencyName: row.frequencyName || '单次大单',
|
||
actionText: buildLargeCustomerActionText(row),
|
||
}));
|
||
}
|
||
|
||
function buildLargeCustomerDashboardMetricCards(summary, monthRange) {
|
||
return [
|
||
{
|
||
key: 'buyerCount',
|
||
label: '大客户数',
|
||
value: summary.buyerCount,
|
||
suffix: '个',
|
||
desc: `${summary.channelCount.toLocaleString('zh-CN')} 个渠道 / ${summary.shopCount.toLocaleString('zh-CN')} 个店铺`,
|
||
},
|
||
{
|
||
key: 'totalAmount',
|
||
label: '累计购买金额',
|
||
value: summary.totalAmount,
|
||
format: 'currency',
|
||
desc: `${monthRange.startMonth} 至 ${monthRange.endMonth}`,
|
||
},
|
||
{
|
||
key: 'totalGoodsCount',
|
||
label: '累计购买件数',
|
||
value: summary.totalGoodsCount,
|
||
suffix: '件',
|
||
desc: `达标订单 ${summary.orderCount.toLocaleString('zh-CN')} 单`,
|
||
},
|
||
{
|
||
key: 'avgOrderAmount',
|
||
label: '平均客单价',
|
||
value: summary.avgOrderAmount,
|
||
format: 'currency',
|
||
desc: `均件数 ${summary.avgGoodsPerOrder.toLocaleString('zh-CN')} 件/单`,
|
||
},
|
||
{
|
||
key: 'repeatCustomerCount',
|
||
label: '复购客户',
|
||
value: summary.repeatCustomerCount,
|
||
suffix: '个',
|
||
desc: '达标订单 2 单及以上',
|
||
},
|
||
{
|
||
key: 'multiMonthCustomerCount',
|
||
label: '跨月复购客户',
|
||
value: summary.multiMonthCustomerCount,
|
||
suffix: '个',
|
||
desc: '覆盖 2 个月及以上',
|
||
},
|
||
];
|
||
}
|
||
|
||
async function getChannelLargeCustomerDashboard(options = {}) {
|
||
const minSingleOrderQty = safeMinSingleOrderQty(options.minSingleOrderQty);
|
||
const monthRange = resolveLargeCustomerMonthRange(options.startMonth, options.endMonth);
|
||
const limit = normalizeLargeCustomerDashboardLimit(options.limit);
|
||
const channelFilters = parseListParam(options.channels);
|
||
const shopFilters = parseListParam(options.shops);
|
||
const cacheKey = buildChannelLargeCustomerDashboardCacheKey({
|
||
...options,
|
||
startMonth: monthRange.startMonth,
|
||
endMonth: monthRange.endMonth,
|
||
minSingleOrderQty,
|
||
channels: channelFilters,
|
||
shops: shopFilters,
|
||
limit,
|
||
});
|
||
|
||
const cached = await dataPageCache.getOrSetCachedPayload({
|
||
key: cacheKey,
|
||
memoryTtlSeconds: CHANNEL_LARGE_CUSTOMER_DASHBOARD_MEMORY_TTL_SECONDS,
|
||
redisTtlSeconds: CHANNEL_LARGE_CUSTOMER_DASHBOARD_CACHE_TTL_SECONDS,
|
||
label: `channel:large-customers:dashboard ${monthRange.startMonth}~${monthRange.endMonth}`,
|
||
loader: async () => {
|
||
const baseFilters = {
|
||
monthRange,
|
||
minSingleOrderQty,
|
||
keyword: options.keyword,
|
||
channels: channelFilters,
|
||
shops: shopFilters,
|
||
};
|
||
const base = buildLargeCustomerDashboardBaseSql(baseFilters);
|
||
const summary = await queryLargeCustomerDashboardSummary(base);
|
||
const trendRows = await queryLargeCustomerDashboardTrend(baseFilters);
|
||
const dimensionGroups = await queryLargeCustomerDashboardDimensions(base);
|
||
const topRows = await queryLargeCustomerDashboardTopRows(base, limit);
|
||
const syncInfoMap = await getLargeCustomerSyncInfo(monthRange.months, minSingleOrderQty);
|
||
const syncInfo = monthRange.months.map((month) => syncInfoMap.get(month) || {
|
||
periodMonth: month,
|
||
minSingleOrderQty,
|
||
syncVersion: null,
|
||
rowCount: 0,
|
||
status: 'missing',
|
||
errorMessage: null,
|
||
syncedAt: null,
|
||
});
|
||
|
||
return {
|
||
monthRange,
|
||
minSingleOrderQty,
|
||
summary,
|
||
metricCards: buildLargeCustomerDashboardMetricCards(summary, monthRange),
|
||
trendRows,
|
||
dimensionGroups,
|
||
topRows,
|
||
syncInfo,
|
||
meta: {
|
||
source: CHANNEL_LARGE_CUSTOMER_READ_TABLE,
|
||
orderSource: CHANNEL_LARGE_CUSTOMER_ORDER_TABLE,
|
||
keyword: normalizeText(options.keyword),
|
||
channels: channelFilters,
|
||
shops: shopFilters,
|
||
limit,
|
||
},
|
||
};
|
||
},
|
||
shouldCache: (payload) => Boolean(payload?.monthRange?.endMonth),
|
||
});
|
||
|
||
return {
|
||
...cached.value,
|
||
meta: {
|
||
...(cached.value?.meta || {}),
|
||
cacheHit: cached.hit !== 'miss',
|
||
cacheLayer: cached.hit,
|
||
},
|
||
};
|
||
}
|
||
|
||
async function getLargeCustomerSyncInfo(months, minSingleOrderQty) {
|
||
if (!months.length) return new Map();
|
||
|
||
const rows = await database.local.query(
|
||
`SELECT period_month, min_single_order_qty, sync_version, row_count, status, error_message, synced_at
|
||
FROM ${CHANNEL_LARGE_CUSTOMER_SYNC_TABLE}
|
||
WHERE min_single_order_qty = ?
|
||
AND period_month IN (${months.map(() => '?').join(', ')})`,
|
||
[minSingleOrderQty, ...months]
|
||
);
|
||
|
||
const map = new Map();
|
||
(rows || []).forEach((row) => {
|
||
map.set(row.period_month, {
|
||
periodMonth: row.period_month,
|
||
minSingleOrderQty: Number(row.min_single_order_qty || minSingleOrderQty),
|
||
syncVersion: row.sync_version || null,
|
||
rowCount: Number(row.row_count || 0),
|
||
status: row.status || 'failed',
|
||
errorMessage: row.error_message || null,
|
||
syncedAt: row.synced_at || null,
|
||
});
|
||
});
|
||
return map;
|
||
}
|
||
|
||
function shouldSyncLargeCustomerMonth(syncInfo, periodMonth, force = false) {
|
||
if (force) return true;
|
||
if (!syncInfo || syncInfo.status !== 'success') return true;
|
||
if (syncInfo.syncVersion !== getLargeCustomerSyncVersion(periodMonth)) return true;
|
||
|
||
const currentMonth = dayjs().format('YYYY-MM');
|
||
if (periodMonth !== currentMonth) {
|
||
return false;
|
||
}
|
||
|
||
if (!syncInfo.syncedAt) {
|
||
return true;
|
||
}
|
||
|
||
return dayjs(syncInfo.syncedAt).isBefore(
|
||
dayjs().subtract(CHANNEL_LARGE_CUSTOMER_CURRENT_MONTH_SYNC_MAX_AGE_MINUTES, 'minute')
|
||
);
|
||
}
|
||
|
||
function shouldUseLargeCustomerLegacySource(periodMonth) {
|
||
return String(periodMonth || '') <= CHANNEL_LARGE_CUSTOMER_LEGACY_SOURCE_END_MONTH;
|
||
}
|
||
|
||
function getLargeCustomerSyncVersion(periodMonth) {
|
||
return shouldUseLargeCustomerLegacySource(periodMonth)
|
||
? CHANNEL_LARGE_CUSTOMER_LEGACY_SYNC_VERSION
|
||
: CHANNEL_LARGE_CUSTOMER_SYNC_VERSION;
|
||
}
|
||
|
||
async function markLargeCustomerSyncFailure(periodMonth, minSingleOrderQty, error) {
|
||
const message = String(error?.message || error || '未知错误').slice(0, 1000);
|
||
const syncVersion = getLargeCustomerSyncVersion(periodMonth);
|
||
await database.local.query(
|
||
`INSERT INTO ${CHANNEL_LARGE_CUSTOMER_SYNC_TABLE}
|
||
(period_month, min_single_order_qty, sync_version, row_count, status, error_message, synced_at)
|
||
VALUES (?, ?, ?, 0, 'failed', ?, NOW())
|
||
ON DUPLICATE KEY UPDATE
|
||
sync_version = VALUES(sync_version),
|
||
row_count = VALUES(row_count),
|
||
status = VALUES(status),
|
||
error_message = VALUES(error_message),
|
||
synced_at = VALUES(synced_at)`,
|
||
[periodMonth, minSingleOrderQty, syncVersion, message]
|
||
);
|
||
}
|
||
|
||
async function fetchLargeCustomerMonthFromSource(periodMonth, minSingleOrderQty) {
|
||
if (shouldUseLargeCustomerLegacySource(periodMonth)) {
|
||
return fetchLargeCustomerMonthFromLegacySource(periodMonth, minSingleOrderQty);
|
||
}
|
||
|
||
const startDate = dayjs(`${periodMonth}-01`).startOf('month').format('YYYY-MM-DD');
|
||
const nextMonthStart = dayjs(`${periodMonth}-01`).add(1, 'month').format('YYYY-MM-DD');
|
||
const params = {
|
||
periodMonth,
|
||
startDate,
|
||
nextMonthStart,
|
||
minSingleOrderQty,
|
||
};
|
||
const excludedSkuSql = buildExcludedSkuInClause(params);
|
||
|
||
const sqlText = `
|
||
SET NOCOUNT ON;
|
||
|
||
IF OBJECT_ID('tempdb..#qmcust_orders') IS NOT NULL DROP TABLE #qmcust_orders;
|
||
IF OBJECT_ID('tempdb..#qmcust_item_qty') IS NOT NULL DROP TABLE #qmcust_item_qty;
|
||
|
||
SELECT
|
||
o.o_id,
|
||
normalized.order_no,
|
||
normalized.channel_name,
|
||
normalized.shop_name,
|
||
buyer.buyer_id,
|
||
buyer.buyer_name,
|
||
TRY_CONVERT(date, o.pay_date) AS order_date,
|
||
ISNULL(TRY_CONVERT(DECIMAL(18, 2), o.pay_amount), 0) AS order_amount
|
||
INTO #qmcust_orders
|
||
FROM ${CHANNEL_LARGE_CUSTOMER_ORDER_SOURCE_TABLE} o
|
||
CROSS APPLY (
|
||
SELECT
|
||
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), o.so_id))), N'') AS order_no,
|
||
COALESCE(NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), o.shop_site))), N''), N'未归类') AS channel_name,
|
||
COALESCE(NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), o.shop_name))), N''), N'未归类') AS shop_name,
|
||
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), o.shop_buyer_id))), N'') AS buyer_display_name,
|
||
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), o.open_id))), N'') AS buyer_open_id
|
||
) normalized
|
||
CROSS APPLY (
|
||
SELECT
|
||
CASE
|
||
WHEN normalized.buyer_display_name IS NULL AND normalized.buyer_open_id IS NULL
|
||
THEN CONCAT(N'未识别买家-', normalized.order_no)
|
||
WHEN normalized.buyer_display_name IS NOT NULL
|
||
AND normalized.buyer_open_id IS NOT NULL
|
||
AND normalized.buyer_display_name LIKE N'%*%'
|
||
THEN CONCAT(normalized.buyer_display_name, N'-', normalized.buyer_open_id)
|
||
ELSE COALESCE(normalized.buyer_display_name, normalized.buyer_open_id)
|
||
END AS buyer_id,
|
||
COALESCE(normalized.buyer_display_name, normalized.buyer_open_id, CONCAT(N'未识别买家-', normalized.order_no)) AS buyer_name
|
||
) buyer
|
||
WHERE o.pay_date >= @startDate
|
||
AND o.pay_date < @nextMonthStart
|
||
AND normalized.order_no IS NOT NULL;
|
||
|
||
CREATE CLUSTERED INDEX IX_qmcust_orders_oid ON #qmcust_orders(o_id);
|
||
CREATE NONCLUSTERED INDEX IX_qmcust_orders_group ON #qmcust_orders(order_no, channel_name, shop_name, buyer_id);
|
||
|
||
SELECT
|
||
q.o_id,
|
||
SUM(
|
||
CASE
|
||
WHEN sku.normalized_sku_code IS NULL THEN 0
|
||
WHEN UPPER(sku.normalized_sku_code) IN (${excludedSkuSql}) THEN 0
|
||
WHEN ISNULL(TRY_CONVERT(INT, i.is_gift), 0) = 1 THEN 0
|
||
ELSE ISNULL(TRY_CONVERT(DECIMAL(18, 2), i.qty), 0)
|
||
END
|
||
) AS goods_count
|
||
INTO #qmcust_item_qty
|
||
FROM #qmcust_orders q
|
||
LEFT JOIN ${CHANNEL_LARGE_CUSTOMER_ORDER_ITEM_SOURCE_TABLE} i ON i.o_id = q.o_id
|
||
CROSS APPLY (
|
||
SELECT NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), COALESCE(i.sku_id, i.shop_sku_id, i.i_id)))), N'') AS normalized_sku_code
|
||
) sku
|
||
GROUP BY q.o_id;
|
||
|
||
CREATE UNIQUE CLUSTERED INDEX IX_qmcust_item_qty_oid ON #qmcust_item_qty(o_id);
|
||
|
||
SELECT
|
||
@periodMonth AS periodMonth,
|
||
grouped.channel_name AS channelName,
|
||
grouped.shop_name AS shopName,
|
||
grouped.buyer_id AS buyerId,
|
||
grouped.buyer_name AS buyerName,
|
||
grouped.order_no AS orderNo,
|
||
CONVERT(varchar(10), grouped.order_date, 23) AS orderDate,
|
||
CAST(ROUND(grouped.order_amount, 2) AS DECIMAL(18, 2)) AS orderAmount,
|
||
CAST(ROUND(grouped.order_goods_count, 2) AS DECIMAL(18, 2)) AS orderGoodsCount
|
||
FROM (
|
||
SELECT
|
||
q.channel_name,
|
||
q.shop_name,
|
||
q.buyer_id,
|
||
MAX(q.buyer_name) AS buyer_name,
|
||
q.order_no,
|
||
MIN(q.order_date) AS order_date,
|
||
SUM(q.order_amount) AS order_amount,
|
||
SUM(ISNULL(iq.goods_count, 0)) AS order_goods_count
|
||
FROM #qmcust_orders q
|
||
LEFT JOIN #qmcust_item_qty iq ON iq.o_id = q.o_id
|
||
GROUP BY
|
||
q.channel_name,
|
||
q.shop_name,
|
||
q.buyer_id,
|
||
q.order_no
|
||
HAVING SUM(ISNULL(iq.goods_count, 0)) >= @minSingleOrderQty
|
||
) grouped
|
||
ORDER BY grouped.order_amount DESC;
|
||
`;
|
||
|
||
return cloudDb.query(sqlText, params, {
|
||
requestTimeout: Number.parseInt(process.env.CHANNEL_LARGE_CUSTOMER_SOURCE_TIMEOUT_MS || '600000', 10),
|
||
maxAttempts: 1,
|
||
});
|
||
}
|
||
|
||
async function fetchLargeCustomerMonthFromLegacySource(periodMonth, minSingleOrderQty) {
|
||
const startDate = dayjs(`${periodMonth}-01`).startOf('month').format('YYYY-MM-DD');
|
||
const endDate = dayjs(`${periodMonth}-01`).endOf('month').format('YYYY-MM-DD');
|
||
const params = {
|
||
periodMonth,
|
||
startDate,
|
||
endDate,
|
||
minSingleOrderQty,
|
||
};
|
||
const excludedSkuSql = buildExcludedSkuInClause(params);
|
||
|
||
const sqlText = `
|
||
SET NOCOUNT ON;
|
||
|
||
IF OBJECT_ID('tempdb..#sales_lines') IS NOT NULL DROP TABLE #sales_lines;
|
||
IF OBJECT_ID('tempdb..#sales_order_keys') IS NOT NULL DROP TABLE #sales_order_keys;
|
||
IF OBJECT_ID('tempdb..#buyer_rows') IS NOT NULL DROP TABLE #buyer_rows;
|
||
IF OBJECT_ID('tempdb..#buyer_by_sku') IS NOT NULL DROP TABLE #buyer_by_sku;
|
||
IF OBJECT_ID('tempdb..#buyer_by_order') IS NOT NULL DROP TABLE #buyer_by_order;
|
||
|
||
SELECT
|
||
TRY_CONVERT(date, s.stat_date) AS stat_date,
|
||
normalized.order_no,
|
||
normalized.channel_name,
|
||
normalized.shop_name,
|
||
normalized.sku_code,
|
||
SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), s.sales_amount), 0)) AS sales_amount,
|
||
SUM(
|
||
CASE
|
||
WHEN ISNULL(TRY_CONVERT(DECIMAL(18, 2), s.goods_count), 0) > 0
|
||
THEN ISNULL(TRY_CONVERT(DECIMAL(18, 2), s.goods_count), 0)
|
||
ELSE 0
|
||
END
|
||
) AS goods_count
|
||
INTO #sales_lines
|
||
FROM ${CHANNEL_LARGE_CUSTOMER_LEGACY_ORDER_SOURCE_TABLE} s
|
||
CROSS APPLY (
|
||
SELECT
|
||
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), s.sourceno))), N'') AS order_no,
|
||
COALESCE(NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), s.platform))), N''), N'未归类') AS channel_name,
|
||
COALESCE(NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), s.shop_name))), N''), N'未归类') AS shop_name,
|
||
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), s.skuoid))), N'') AS sku_code
|
||
) normalized
|
||
WHERE s.stat_date BETWEEN @startDate AND @endDate
|
||
AND normalized.order_no IS NOT NULL
|
||
AND normalized.sku_code IS NOT NULL
|
||
AND UPPER(normalized.sku_code) NOT IN (${excludedSkuSql})
|
||
GROUP BY
|
||
TRY_CONVERT(date, s.stat_date),
|
||
normalized.order_no,
|
||
normalized.channel_name,
|
||
normalized.shop_name,
|
||
normalized.sku_code
|
||
HAVING SUM(
|
||
CASE
|
||
WHEN ISNULL(TRY_CONVERT(DECIMAL(18, 2), s.goods_count), 0) > 0
|
||
THEN ISNULL(TRY_CONVERT(DECIMAL(18, 2), s.goods_count), 0)
|
||
ELSE 0
|
||
END
|
||
) > 0;
|
||
|
||
CREATE CLUSTERED INDEX IX_sales_lines_order_sku ON #sales_lines(order_no, sku_code);
|
||
CREATE NONCLUSTERED INDEX IX_sales_lines_order_group ON #sales_lines(order_no, channel_name, shop_name);
|
||
|
||
SELECT DISTINCT order_no
|
||
INTO #sales_order_keys
|
||
FROM #sales_lines;
|
||
|
||
CREATE UNIQUE CLUSTERED INDEX IX_sales_order_keys_order ON #sales_order_keys(order_no);
|
||
|
||
SELECT
|
||
normalized.order_no,
|
||
normalized.sku_code,
|
||
normalized.buyer_name
|
||
INTO #buyer_rows
|
||
FROM [JSDataWarehouse].[dim].[dim_erp_sctrade_info_di] b
|
||
CROSS APPLY (
|
||
SELECT
|
||
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), b.sourceno))), N'') AS order_no,
|
||
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), b.skuoid))), N'') AS sku_code,
|
||
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), b.buynicker))), N'') AS buyer_name
|
||
) normalized
|
||
INNER JOIN #sales_order_keys ok ON ok.order_no = normalized.order_no
|
||
WHERE normalized.order_no IS NOT NULL
|
||
AND normalized.buyer_name IS NOT NULL
|
||
AND b.stat_date BETWEEN DATEADD(day, -120, TRY_CONVERT(date, @startDate))
|
||
AND DATEADD(day, 120, TRY_CONVERT(date, @endDate));
|
||
|
||
CREATE CLUSTERED INDEX IX_buyer_rows_order_sku ON #buyer_rows(order_no, sku_code);
|
||
CREATE NONCLUSTERED INDEX IX_buyer_rows_order ON #buyer_rows(order_no) INCLUDE (buyer_name);
|
||
|
||
SELECT order_no, sku_code, MAX(buyer_name) AS buyer_name
|
||
INTO #buyer_by_sku
|
||
FROM #buyer_rows
|
||
WHERE sku_code IS NOT NULL
|
||
GROUP BY order_no, sku_code;
|
||
|
||
CREATE UNIQUE CLUSTERED INDEX IX_buyer_by_sku_order_sku ON #buyer_by_sku(order_no, sku_code);
|
||
|
||
SELECT order_no, MAX(buyer_name) AS buyer_name
|
||
INTO #buyer_by_order
|
||
FROM #buyer_rows
|
||
GROUP BY order_no
|
||
HAVING COUNT(DISTINCT buyer_name) = 1;
|
||
|
||
CREATE UNIQUE CLUSTERED INDEX IX_buyer_by_order_order ON #buyer_by_order(order_no);
|
||
|
||
SELECT
|
||
@periodMonth AS periodMonth,
|
||
grouped.channel_name AS channelName,
|
||
grouped.shop_name AS shopName,
|
||
grouped.buyer_name AS buyerId,
|
||
grouped.buyer_name AS buyerName,
|
||
grouped.order_no AS orderNo,
|
||
CONVERT(varchar(10), grouped.order_date, 23) AS orderDate,
|
||
CAST(ROUND(grouped.order_amount, 2) AS DECIMAL(18, 2)) AS orderAmount,
|
||
CAST(ROUND(grouped.order_goods_count, 2) AS DECIMAL(18, 2)) AS orderGoodsCount
|
||
FROM (
|
||
SELECT
|
||
COALESCE(bs.buyer_name, bo.buyer_name, CONCAT(N'未识别买家-', s.order_no)) AS buyer_name,
|
||
s.channel_name,
|
||
s.shop_name,
|
||
s.order_no,
|
||
MIN(s.stat_date) AS order_date,
|
||
SUM(s.sales_amount) AS order_amount,
|
||
SUM(s.goods_count) AS order_goods_count
|
||
FROM #sales_lines s
|
||
LEFT JOIN #buyer_by_sku bs ON bs.order_no = s.order_no AND bs.sku_code = s.sku_code
|
||
LEFT JOIN #buyer_by_order bo ON bo.order_no = s.order_no
|
||
GROUP BY
|
||
COALESCE(bs.buyer_name, bo.buyer_name, CONCAT(N'未识别买家-', s.order_no)),
|
||
s.channel_name,
|
||
s.shop_name,
|
||
s.order_no
|
||
HAVING SUM(s.goods_count) >= @minSingleOrderQty
|
||
) grouped
|
||
ORDER BY grouped.order_amount DESC;
|
||
`;
|
||
|
||
return cloudDb.query(sqlText, params, {
|
||
requestTimeout: Number.parseInt(process.env.CHANNEL_LARGE_CUSTOMER_SOURCE_TIMEOUT_MS || '600000', 10),
|
||
maxAttempts: 1,
|
||
});
|
||
}
|
||
|
||
async function persistLargeCustomerMonth(periodMonth, minSingleOrderQty, rows) {
|
||
const syncVersion = getLargeCustomerSyncVersion(periodMonth);
|
||
const normalizedOrders = (rows || []).map((row) => ({
|
||
periodMonth,
|
||
channelName: row.channelName || '未归类',
|
||
shopName: row.shopName || '未归类',
|
||
buyerId: String(row.buyerId || row.buyerName || '').trim(),
|
||
buyerName: String(row.buyerName || row.buyerId || '').trim(),
|
||
orderNo: String(row.orderNo || '').trim(),
|
||
orderDate: normalizeDateValue(row.orderDate),
|
||
orderAmount: toNumber(row.orderAmount),
|
||
orderGoodsCount: toNumber(row.orderGoodsCount),
|
||
})).filter((row) => row.buyerId && row.orderNo && row.orderGoodsCount > 0);
|
||
|
||
const summaryMap = new Map();
|
||
normalizedOrders.forEach((row) => {
|
||
const key = `${row.periodMonth}\u0000${row.channelName}\u0000${row.shopName}\u0000${row.buyerId}`;
|
||
if (!summaryMap.has(key)) {
|
||
summaryMap.set(key, {
|
||
periodMonth: row.periodMonth,
|
||
channelName: row.channelName,
|
||
shopName: row.shopName,
|
||
buyerId: row.buyerId,
|
||
buyerName: row.buyerName || row.buyerId,
|
||
totalAmount: 0,
|
||
totalGoodsCount: 0,
|
||
orderNos: new Set(),
|
||
firstOrderDate: row.orderDate,
|
||
lastOrderDate: row.orderDate,
|
||
});
|
||
}
|
||
|
||
const summary = summaryMap.get(key);
|
||
summary.totalAmount += row.orderAmount;
|
||
summary.totalGoodsCount += row.orderGoodsCount;
|
||
summary.orderNos.add(row.orderNo);
|
||
if (row.buyerName && row.buyerName.length > summary.buyerName.length) {
|
||
summary.buyerName = row.buyerName;
|
||
}
|
||
if (row.orderDate && (!summary.firstOrderDate || row.orderDate < summary.firstOrderDate)) {
|
||
summary.firstOrderDate = row.orderDate;
|
||
}
|
||
if (row.orderDate && (!summary.lastOrderDate || row.orderDate > summary.lastOrderDate)) {
|
||
summary.lastOrderDate = row.orderDate;
|
||
}
|
||
});
|
||
|
||
const normalizedRows = Array.from(summaryMap.values()).map((row) => ({
|
||
...row,
|
||
totalAmount: toNumber(row.totalAmount),
|
||
totalGoodsCount: toNumber(row.totalGoodsCount),
|
||
orderCount: row.orderNos.size,
|
||
}));
|
||
|
||
const connection = await database.local.pool.getConnection();
|
||
try {
|
||
await connection.beginTransaction();
|
||
await connection.query(
|
||
`DELETE FROM ${CHANNEL_LARGE_CUSTOMER_ORDER_TABLE}
|
||
WHERE period_month = ? AND min_single_order_qty = ?`,
|
||
[periodMonth, minSingleOrderQty]
|
||
);
|
||
await connection.query(
|
||
`DELETE FROM ${CHANNEL_LARGE_CUSTOMER_READ_TABLE}
|
||
WHERE period_month = ? AND min_single_order_qty = ?`,
|
||
[periodMonth, minSingleOrderQty]
|
||
);
|
||
|
||
if (normalizedOrders.length > 0) {
|
||
const orderColumns = [
|
||
'period_month',
|
||
'channel_name',
|
||
'shop_name',
|
||
'buyer_id',
|
||
'buyer_name',
|
||
'min_single_order_qty',
|
||
'order_no',
|
||
'order_date',
|
||
'order_amount',
|
||
'order_goods_count',
|
||
];
|
||
|
||
for (let index = 0; index < normalizedOrders.length; index += 500) {
|
||
const chunk = normalizedOrders.slice(index, index + 500);
|
||
const placeholders = chunk.map(() => `(${orderColumns.map(() => '?').join(', ')})`).join(', ');
|
||
const params = [];
|
||
chunk.forEach((row) => {
|
||
params.push(
|
||
row.periodMonth,
|
||
row.channelName,
|
||
row.shopName,
|
||
row.buyerId,
|
||
row.buyerName,
|
||
minSingleOrderQty,
|
||
row.orderNo,
|
||
row.orderDate,
|
||
row.orderAmount,
|
||
row.orderGoodsCount
|
||
);
|
||
});
|
||
|
||
await connection.query(
|
||
`INSERT INTO ${CHANNEL_LARGE_CUSTOMER_ORDER_TABLE} (${orderColumns.join(', ')})
|
||
VALUES ${placeholders}`,
|
||
params
|
||
);
|
||
}
|
||
}
|
||
|
||
if (normalizedRows.length > 0) {
|
||
const columns = [
|
||
'period_month',
|
||
'channel_name',
|
||
'shop_name',
|
||
'buyer_id',
|
||
'buyer_name',
|
||
'min_single_order_qty',
|
||
'total_amount',
|
||
'total_goods_count',
|
||
'order_count',
|
||
'first_order_date',
|
||
'last_order_date',
|
||
];
|
||
|
||
for (let index = 0; index < normalizedRows.length; index += 500) {
|
||
const chunk = normalizedRows.slice(index, index + 500);
|
||
const placeholders = chunk.map(() => `(${columns.map(() => '?').join(', ')})`).join(', ');
|
||
const params = [];
|
||
chunk.forEach((row) => {
|
||
params.push(
|
||
row.periodMonth,
|
||
row.channelName,
|
||
row.shopName,
|
||
row.buyerId,
|
||
row.buyerName,
|
||
minSingleOrderQty,
|
||
row.totalAmount,
|
||
row.totalGoodsCount,
|
||
row.orderCount,
|
||
row.firstOrderDate,
|
||
row.lastOrderDate
|
||
);
|
||
});
|
||
|
||
await connection.query(
|
||
`INSERT INTO ${CHANNEL_LARGE_CUSTOMER_READ_TABLE} (${columns.join(', ')})
|
||
VALUES ${placeholders}`,
|
||
params
|
||
);
|
||
}
|
||
}
|
||
|
||
await connection.query(
|
||
`INSERT INTO ${CHANNEL_LARGE_CUSTOMER_SYNC_TABLE}
|
||
(period_month, min_single_order_qty, sync_version, row_count, status, error_message, synced_at)
|
||
VALUES (?, ?, ?, ?, 'success', NULL, NOW())
|
||
ON DUPLICATE KEY UPDATE
|
||
sync_version = VALUES(sync_version),
|
||
row_count = VALUES(row_count),
|
||
status = VALUES(status),
|
||
error_message = VALUES(error_message),
|
||
synced_at = VALUES(synced_at)`,
|
||
[periodMonth, minSingleOrderQty, syncVersion, normalizedRows.length]
|
||
);
|
||
|
||
await connection.commit();
|
||
} catch (error) {
|
||
await connection.rollback();
|
||
throw error;
|
||
} finally {
|
||
connection.release();
|
||
}
|
||
|
||
await clearChannelLargeCustomerCaches();
|
||
return normalizedRows.length;
|
||
}
|
||
|
||
async function syncLargeCustomerMonth(periodMonth, minSingleOrderQty) {
|
||
try {
|
||
const rows = await fetchLargeCustomerMonthFromSource(periodMonth, minSingleOrderQty);
|
||
return await persistLargeCustomerMonth(periodMonth, minSingleOrderQty, rows);
|
||
} catch (error) {
|
||
await markLargeCustomerSyncFailure(periodMonth, minSingleOrderQty, error).catch(() => {});
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async function syncChannelLargeCustomerMonths(months, minSingleOrderQty, { force = false } = {}) {
|
||
const syncInfoMap = await getLargeCustomerSyncInfo(months, minSingleOrderQty);
|
||
const monthsToSync = months.filter((month) => shouldSyncLargeCustomerMonth(syncInfoMap.get(month), month, force));
|
||
const syncedMonths = [];
|
||
|
||
for (const month of monthsToSync) {
|
||
const rowCount = await syncLargeCustomerMonth(month, minSingleOrderQty);
|
||
syncedMonths.push({ periodMonth: month, rowCount });
|
||
}
|
||
|
||
return syncedMonths;
|
||
}
|
||
|
||
async function getChannelLargeCustomerList(options = {}) {
|
||
const resolvedPage = safePage(options.page);
|
||
const resolvedPageSize = safePageSize(options.pageSize);
|
||
const minSingleOrderQty = safeMinSingleOrderQty(options.minSingleOrderQty);
|
||
const monthRange = resolveLargeCustomerMonthRange(options.startMonth, options.endMonth);
|
||
const autoSync = String(options.autoSync ?? 'false') === 'true';
|
||
const channelFilters = parseListParam(options.channels);
|
||
const shopFilters = parseListParam(options.shops);
|
||
|
||
let syncedMonths = [];
|
||
if (autoSync) {
|
||
syncedMonths = await syncChannelLargeCustomerMonths(monthRange.months, minSingleOrderQty);
|
||
}
|
||
|
||
const cacheKey = buildChannelLargeCustomerCacheKey({
|
||
...options,
|
||
startMonth: monthRange.startMonth,
|
||
endMonth: monthRange.endMonth,
|
||
page: resolvedPage,
|
||
pageSize: resolvedPageSize,
|
||
minSingleOrderQty,
|
||
channels: channelFilters,
|
||
shops: shopFilters,
|
||
});
|
||
|
||
const cached = await dataPageCache.getOrSetCachedPayload({
|
||
key: cacheKey,
|
||
memoryTtlSeconds: CHANNEL_LARGE_CUSTOMER_MEMORY_TTL_SECONDS,
|
||
redisTtlSeconds: CHANNEL_LARGE_CUSTOMER_CACHE_TTL_SECONDS,
|
||
label: `channel:large-customers ${monthRange.startMonth}~${monthRange.endMonth} page=${resolvedPage}`,
|
||
loader: async () => {
|
||
const params = [monthRange.startMonth, monthRange.endMonth, minSingleOrderQty];
|
||
const whereParts = [
|
||
'period_month BETWEEN ? AND ?',
|
||
'min_single_order_qty = ?',
|
||
];
|
||
|
||
const keyword = String(options.keyword || '').trim();
|
||
if (keyword) {
|
||
whereParts.push(`(
|
||
buyer_id LIKE ?
|
||
OR buyer_name LIKE ?
|
||
OR channel_name LIKE ?
|
||
OR shop_name LIKE ?
|
||
)`);
|
||
const keywordValue = `%${keyword}%`;
|
||
params.push(keywordValue, keywordValue, keywordValue, keywordValue);
|
||
}
|
||
|
||
addMysqlListFilter(whereParts, params, channelFilters, 'channel_name');
|
||
addMysqlListFilter(whereParts, params, shopFilters, 'shop_name');
|
||
|
||
const monthColumnSql = buildLargeCustomerMonthColumnSelect(monthRange.months);
|
||
const whereSql = whereParts.join(' AND ');
|
||
const baseSql = `
|
||
SELECT
|
||
buyer_id AS buyerId,
|
||
MAX(buyer_name) AS buyerName,
|
||
channel_name AS channelName,
|
||
shop_name AS shopName,
|
||
ROUND(SUM(total_amount), 2) AS totalAmount,
|
||
ROUND(SUM(total_goods_count), 2) AS totalGoodsCount,
|
||
SUM(order_count) AS orderCount,
|
||
MIN(first_order_date) AS firstOrderDate,
|
||
MAX(last_order_date) AS lastOrderDate,
|
||
${monthColumnSql}
|
||
FROM ${CHANNEL_LARGE_CUSTOMER_READ_TABLE}
|
||
WHERE ${whereSql}
|
||
GROUP BY buyer_id, channel_name, shop_name
|
||
`;
|
||
|
||
const countRows = await database.local.query(`SELECT COUNT(*) AS totalCount FROM (${baseSql}) counted`, params);
|
||
const total = Number(countRows?.[0]?.totalCount || 0);
|
||
const offset = (resolvedPage - 1) * resolvedPageSize;
|
||
const orderSql = `${resolveLargeCustomerSortExpression(options.sortField)} ${resolveSortOrder(options.sortOrder)}, base.buyerId ASC`;
|
||
const pageRows = total > 0
|
||
? await database.local.query(
|
||
`SELECT * FROM (${baseSql}) base ORDER BY ${orderSql} LIMIT ? OFFSET ?`,
|
||
[...params, resolvedPageSize, offset]
|
||
)
|
||
: [];
|
||
|
||
const availableChannelRows = await database.local.query(
|
||
`SELECT
|
||
channel_name AS channelName,
|
||
ROUND(SUM(total_amount), 2) AS totalAmount,
|
||
SUM(order_count) AS orderCount
|
||
FROM ${CHANNEL_LARGE_CUSTOMER_READ_TABLE}
|
||
WHERE period_month BETWEEN ? AND ?
|
||
AND min_single_order_qty = ?
|
||
GROUP BY channel_name
|
||
ORDER BY totalAmount DESC, channelName ASC`,
|
||
[monthRange.startMonth, monthRange.endMonth, minSingleOrderQty]
|
||
);
|
||
|
||
const shopOptionWhereParts = [
|
||
'period_month BETWEEN ? AND ?',
|
||
'min_single_order_qty = ?',
|
||
];
|
||
const shopOptionParams = [monthRange.startMonth, monthRange.endMonth, minSingleOrderQty];
|
||
addMysqlListFilter(shopOptionWhereParts, shopOptionParams, channelFilters, 'channel_name');
|
||
const availableShopRows = await database.local.query(
|
||
`SELECT
|
||
shop_name AS shopName,
|
||
ROUND(SUM(total_amount), 2) AS totalAmount,
|
||
SUM(order_count) AS orderCount
|
||
FROM ${CHANNEL_LARGE_CUSTOMER_READ_TABLE}
|
||
WHERE ${shopOptionWhereParts.join(' AND ')}
|
||
GROUP BY shop_name
|
||
ORDER BY totalAmount DESC, shopName ASC`,
|
||
shopOptionParams
|
||
);
|
||
|
||
const syncInfoMap = await getLargeCustomerSyncInfo(monthRange.months, minSingleOrderQty);
|
||
const syncInfo = monthRange.months.map((month) => syncInfoMap.get(month) || {
|
||
periodMonth: month,
|
||
minSingleOrderQty,
|
||
syncVersion: null,
|
||
rowCount: 0,
|
||
status: 'missing',
|
||
errorMessage: null,
|
||
syncedAt: null,
|
||
});
|
||
|
||
const rows = (pageRows || []).map((row) => {
|
||
const monthlyMetrics = monthRange.months.map((month) => {
|
||
const aliasSuffix = month.replace('-', '');
|
||
return {
|
||
month,
|
||
amount: toNumber(row[`monthAmount${aliasSuffix}`]),
|
||
goodsCount: toNumber(row[`monthGoodsCount${aliasSuffix}`]),
|
||
orderCount: Number.parseInt(row[`monthOrderCount${aliasSuffix}`], 10) || 0,
|
||
};
|
||
});
|
||
|
||
return {
|
||
buyerId: row.buyerId || '',
|
||
buyerName: row.buyerName || row.buyerId || '',
|
||
channelName: row.channelName || '未归类',
|
||
shopName: row.shopName || '未归类',
|
||
totalAmount: toNumber(row.totalAmount),
|
||
totalGoodsCount: toNumber(row.totalGoodsCount),
|
||
orderCount: Number.parseInt(row.orderCount, 10) || 0,
|
||
firstOrderDate: normalizeDateValue(row.firstOrderDate),
|
||
lastOrderDate: normalizeDateValue(row.lastOrderDate),
|
||
monthlyMetrics,
|
||
};
|
||
});
|
||
|
||
return {
|
||
page: resolvedPage,
|
||
pageSize: resolvedPageSize,
|
||
total,
|
||
rows,
|
||
availableChannels: (availableChannelRows || []).map((row) => ({
|
||
channelName: row.channelName || '未归类',
|
||
totalAmount: toNumber(row.totalAmount),
|
||
orderCount: Number.parseInt(row.orderCount, 10) || 0,
|
||
})),
|
||
availableShops: (availableShopRows || []).map((row) => ({
|
||
shopName: row.shopName || '未归类',
|
||
totalAmount: toNumber(row.totalAmount),
|
||
orderCount: Number.parseInt(row.orderCount, 10) || 0,
|
||
})),
|
||
activeChannels: channelFilters,
|
||
activeShops: shopFilters,
|
||
monthRange,
|
||
minSingleOrderQty,
|
||
syncInfo,
|
||
meta: {
|
||
source: 'large-customer-read-store',
|
||
syncedMonths,
|
||
},
|
||
};
|
||
},
|
||
});
|
||
|
||
return {
|
||
...cached.value,
|
||
meta: {
|
||
...(cached.value?.meta || {}),
|
||
cacheHit: cached.hit !== 'miss',
|
||
cacheLayer: cached.hit,
|
||
syncedMonths,
|
||
},
|
||
};
|
||
}
|
||
|
||
async function getChannelLargeCustomerDetail(options = {}) {
|
||
const minSingleOrderQty = safeMinSingleOrderQty(options.minSingleOrderQty);
|
||
const monthRange = resolveLargeCustomerMonthRange(options.startMonth, options.endMonth);
|
||
const buyerId = String(options.buyerId || '').trim();
|
||
const channelName = String(options.channelName || '').trim();
|
||
const shopName = String(options.shopName || '').trim();
|
||
const channelFilters = parseListParam(options.channels);
|
||
const shopFilters = parseListParam(options.shops);
|
||
const requestedLimit = Number.parseInt(options.limit, 10);
|
||
const limit = Number.isFinite(requestedLimit) && requestedLimit > 0
|
||
? Math.min(requestedLimit, 2000)
|
||
: 1000;
|
||
|
||
if (!buyerId) {
|
||
return {
|
||
rows: [],
|
||
total: 0,
|
||
monthRange,
|
||
minSingleOrderQty,
|
||
};
|
||
}
|
||
|
||
const cacheKey = buildChannelLargeCustomerDetailCacheKey({
|
||
...options,
|
||
startMonth: monthRange.startMonth,
|
||
endMonth: monthRange.endMonth,
|
||
buyerId,
|
||
channelName,
|
||
shopName,
|
||
channels: channelFilters,
|
||
shops: shopFilters,
|
||
minSingleOrderQty,
|
||
limit,
|
||
});
|
||
|
||
const cached = await dataPageCache.getOrSetCachedPayload({
|
||
key: cacheKey,
|
||
memoryTtlSeconds: CHANNEL_LARGE_CUSTOMER_DETAIL_MEMORY_TTL_SECONDS,
|
||
redisTtlSeconds: CHANNEL_LARGE_CUSTOMER_DETAIL_CACHE_TTL_SECONDS,
|
||
label: `channel:large-customers:detail ${monthRange.startMonth}~${monthRange.endMonth} buyer=${buyerId}`,
|
||
loader: async () => {
|
||
const whereParts = [
|
||
'period_month BETWEEN ? AND ?',
|
||
'min_single_order_qty = ?',
|
||
'buyer_id = ?',
|
||
];
|
||
const params = [monthRange.startMonth, monthRange.endMonth, minSingleOrderQty, buyerId];
|
||
|
||
if (channelName) {
|
||
whereParts.push('channel_name = ?');
|
||
params.push(channelName);
|
||
} else {
|
||
addMysqlListFilter(whereParts, params, channelFilters, 'channel_name');
|
||
}
|
||
if (shopName) {
|
||
whereParts.push('shop_name = ?');
|
||
params.push(shopName);
|
||
} else {
|
||
addMysqlListFilter(whereParts, params, shopFilters, 'shop_name');
|
||
}
|
||
|
||
const whereSql = whereParts.join(' AND ');
|
||
const countRows = await database.local.query(
|
||
`SELECT COUNT(*) AS totalCount
|
||
FROM ${CHANNEL_LARGE_CUSTOMER_ORDER_TABLE}
|
||
WHERE ${whereSql}`,
|
||
params
|
||
);
|
||
const rows = await database.local.query(
|
||
`SELECT
|
||
period_month AS periodMonth,
|
||
channel_name AS channelName,
|
||
shop_name AS shopName,
|
||
buyer_id AS buyerId,
|
||
buyer_name AS buyerName,
|
||
order_no AS orderNo,
|
||
order_date AS orderDate,
|
||
order_amount AS orderAmount,
|
||
order_goods_count AS orderGoodsCount
|
||
FROM ${CHANNEL_LARGE_CUSTOMER_ORDER_TABLE}
|
||
WHERE ${whereSql}
|
||
ORDER BY order_date DESC, order_amount DESC, order_no DESC
|
||
LIMIT ?`,
|
||
[...params, limit]
|
||
);
|
||
|
||
return {
|
||
rows: (rows || []).map((row) => ({
|
||
periodMonth: row.periodMonth,
|
||
channelName: row.channelName || '未归类',
|
||
shopName: row.shopName || '未归类',
|
||
buyerId: row.buyerId || '',
|
||
buyerName: row.buyerName || row.buyerId || '',
|
||
orderNo: row.orderNo || '',
|
||
orderDate: normalizeDateValue(row.orderDate),
|
||
orderAmount: toNumber(row.orderAmount),
|
||
orderGoodsCount: toNumber(row.orderGoodsCount),
|
||
})),
|
||
total: Number(countRows?.[0]?.totalCount || 0),
|
||
monthRange,
|
||
minSingleOrderQty,
|
||
};
|
||
},
|
||
});
|
||
|
||
return {
|
||
...cached.value,
|
||
meta: {
|
||
...(cached.value?.meta || {}),
|
||
cacheHit: cached.hit !== 'miss',
|
||
cacheLayer: cached.hit,
|
||
},
|
||
};
|
||
}
|
||
|
||
async function refreshChannelLargeCustomerReadStore(options = {}) {
|
||
const minSingleOrderQty = safeMinSingleOrderQty(options.minSingleOrderQty);
|
||
const monthRange = resolveLargeCustomerMonthRange(options.startMonth, options.endMonth);
|
||
const syncedMonths = await syncChannelLargeCustomerMonths(monthRange.months, minSingleOrderQty, { force: true });
|
||
|
||
const data = await getChannelLargeCustomerList({
|
||
...options,
|
||
startMonth: monthRange.startMonth,
|
||
endMonth: monthRange.endMonth,
|
||
minSingleOrderQty,
|
||
autoSync: false,
|
||
});
|
||
|
||
return {
|
||
...data,
|
||
meta: {
|
||
...(data.meta || {}),
|
||
refreshed: true,
|
||
syncedMonths,
|
||
},
|
||
};
|
||
}
|
||
|
||
async function prewarmChannelDefaultSnapshots({ forceRefresh = false, skipCoverage = false } = {}) {
|
||
const latestBizDate = await getLatestChannelBizDate();
|
||
const baseConfig = buildChannelDefaultSnapshotConfig(latestBizDate, 1, CHANNEL_PREWARM_PAGE_SIZE);
|
||
if (!skipCoverage) {
|
||
const coverageStartDate = getCoverageStartDate(baseConfig);
|
||
await ensureChannelReadCoverage(coverageStartDate, baseConfig.endDate, baseConfig.latestBizDate);
|
||
}
|
||
|
||
for (let page = 1; page <= CHANNEL_PREWARM_MAX_PAGES; page += 1) {
|
||
const config = buildChannelDefaultSnapshotConfig(latestBizDate, page, CHANNEL_PREWARM_PAGE_SIZE);
|
||
if (forceRefresh) {
|
||
await refreshChannelDefaultSnapshot(config, { skipCoverage: true });
|
||
continue;
|
||
}
|
||
|
||
await getChannelDefaultSnapshot(config);
|
||
}
|
||
|
||
return baseConfig;
|
||
}
|
||
|
||
async function prewarmChannelIdDefaultSnapshots({ forceRefresh = false, skipCoverage = false } = {}) {
|
||
const latestBizDate = await getLatestChannelBizDate();
|
||
const baseConfig = buildChannelIdDefaultSnapshotConfig(latestBizDate, 1, CHANNEL_PREWARM_PAGE_SIZE);
|
||
if (!skipCoverage) {
|
||
await ensureChannelIdReadCoverage(baseConfig.startDate, baseConfig.endDate, baseConfig.latestBizDate);
|
||
}
|
||
|
||
for (let page = 1; page <= CHANNEL_ID_ANALYSIS_PREWARM_MAX_PAGES; page += 1) {
|
||
const config = buildChannelIdDefaultSnapshotConfig(latestBizDate, page, CHANNEL_PREWARM_PAGE_SIZE);
|
||
await refreshChannelIdDefaultSnapshot(config, { skipCoverage: true });
|
||
if (!forceRefresh) {
|
||
await getChannelIdDefaultSnapshot(config);
|
||
}
|
||
}
|
||
|
||
return baseConfig;
|
||
}
|
||
|
||
async function prewarmChannelIdDashboardDefaultSnapshot({ forceRefresh = false, skipCoverage = false } = {}) {
|
||
const latestBizDate = await getLatestChannelBizDate();
|
||
const config = buildChannelIdDashboardDefaultSnapshotConfig(latestBizDate, 50);
|
||
if (!skipCoverage) {
|
||
await ensureChannelIdReadCoverage(config.startDate, config.endDate, config.latestBizDate);
|
||
}
|
||
|
||
await refreshChannelIdDashboardDefaultSnapshot(config, { skipCoverage: true });
|
||
if (!forceRefresh) {
|
||
await getChannelIdDashboardDefaultSnapshot(config);
|
||
}
|
||
|
||
return config;
|
||
}
|
||
|
||
async function getChannelList(options) {
|
||
const resolvedPage = safePage(options.page);
|
||
const resolvedPageSize = safePageSize(options.pageSize);
|
||
const dateRange = await resolveDateRange(options.startDate, options.endDate);
|
||
|
||
if (isDefaultChannelQuery(options, dateRange, resolvedPage, resolvedPageSize)) {
|
||
const snapshotConfig = buildChannelDefaultSnapshotConfig(dateRange.latestBizDate, resolvedPage, resolvedPageSize);
|
||
const snapshot = await getChannelDefaultSnapshot(snapshotConfig);
|
||
|
||
return {
|
||
...snapshot.value,
|
||
meta: {
|
||
...(snapshot.value?.meta || {}),
|
||
cacheHit: snapshot.hit !== 'miss-default',
|
||
cacheLayer: snapshot.hit,
|
||
},
|
||
};
|
||
}
|
||
|
||
const cacheKey = buildChannelListCacheKey({
|
||
...options,
|
||
...dateRange,
|
||
page: resolvedPage,
|
||
pageSize: resolvedPageSize,
|
||
});
|
||
|
||
try {
|
||
const cached = await dataPageCache.getOrSetCachedPayload({
|
||
key: cacheKey,
|
||
memoryTtlSeconds: CHANNEL_LIST_MEMORY_TTL_SECONDS,
|
||
redisTtlSeconds: CHANNEL_LIST_CACHE_TTL_SECONDS,
|
||
label: `channel:list ${dateRange.startDate}~${dateRange.endDate} page=${resolvedPage}`,
|
||
loader: async () => {
|
||
const coverageStartDate = getCoverageStartDate(dateRange);
|
||
const refreshedRanges = await ensureChannelReadCoverage(coverageStartDate, dateRange.endDate, dateRange.latestBizDate);
|
||
const data = await getChannelListFromReadStore({
|
||
startDate: dateRange.startDate,
|
||
endDate: dateRange.endDate,
|
||
lookbackStartDate: dateRange.lookbackStartDate,
|
||
latestBizDate: dateRange.latestBizDate,
|
||
page: resolvedPage,
|
||
pageSize: resolvedPageSize,
|
||
keyword: options.keyword,
|
||
sortField: options.sortField,
|
||
sortOrder: options.sortOrder,
|
||
channels: options.channels,
|
||
statuses: options.statuses,
|
||
purchasers: options.purchasers,
|
||
minAfterSaleAmountRate: options.minAfterSaleAmountRate,
|
||
maxAfterSaleAmountRate: options.maxAfterSaleAmountRate,
|
||
});
|
||
|
||
return {
|
||
...data,
|
||
meta: {
|
||
...(data.meta || {}),
|
||
refreshedRanges,
|
||
},
|
||
};
|
||
},
|
||
});
|
||
|
||
return {
|
||
...cached.value,
|
||
meta: {
|
||
...(cached.value?.meta || {}),
|
||
cacheHit: cached.hit !== 'miss',
|
||
cacheLayer: cached.hit,
|
||
},
|
||
};
|
||
} catch (error) {
|
||
console.warn('[Channel] read store 查询失败,回退到业务库直查:', error.message);
|
||
const fallback = await getChannelListDirect({
|
||
...options,
|
||
page: resolvedPage,
|
||
pageSize: resolvedPageSize,
|
||
}, dateRange);
|
||
await redis.setex(cacheKey, CHANNEL_LIST_CACHE_TTL_SECONDS, fallback);
|
||
return fallback;
|
||
}
|
||
}
|
||
|
||
async function prewarmChannelHotData({ forceRefresh = false, skipCoverage = false } = {}) {
|
||
try {
|
||
if (forceRefresh) {
|
||
await clearChannelReadCaches();
|
||
await clearChannelLargeCustomerCaches();
|
||
}
|
||
|
||
const config = await prewarmChannelDefaultSnapshots({ forceRefresh, skipCoverage });
|
||
await prewarmChannelIdDefaultSnapshots({ forceRefresh, skipCoverage });
|
||
await prewarmChannelIdDashboardDefaultSnapshot({ forceRefresh, skipCoverage });
|
||
await prewarmChannelStockMonitorDashboardDefaultSnapshot({ forceRefresh, skipCoverage });
|
||
const warmTasks = [
|
||
getChannelDashboard({
|
||
startDate: config.startDate,
|
||
endDate: config.endDate,
|
||
limit: 50,
|
||
}),
|
||
getChannelSpuList({
|
||
startDate: config.startDate,
|
||
endDate: config.endDate,
|
||
page: 1,
|
||
pageSize: CHANNEL_PREWARM_PAGE_SIZE,
|
||
sortField: CHANNEL_PREWARM_SORT_FIELD,
|
||
sortOrder: CHANNEL_PREWARM_SORT_ORDER,
|
||
}),
|
||
getChannelLargeCustomerList({
|
||
page: 1,
|
||
pageSize: CHANNEL_PREWARM_PAGE_SIZE,
|
||
autoSync: false,
|
||
}),
|
||
getChannelLargeCustomerDashboard({
|
||
limit: 50,
|
||
}),
|
||
getChannelStockMonitorDashboard({
|
||
startDate: config.startDate,
|
||
endDate: config.endDate,
|
||
limit: 50,
|
||
}),
|
||
];
|
||
const warmResults = await Promise.allSettled(warmTasks);
|
||
const failedCount = warmResults.filter((item) => item.status === 'rejected').length;
|
||
console.log(
|
||
`[ChannelCache] 已预热渠道热数据,时间范围 ${config.startDate} ~ ${config.endDate},默认列表页数 1~${CHANNEL_PREWARM_MAX_PAGES},附加任务失败 ${failedCount} 个`
|
||
);
|
||
} catch (error) {
|
||
console.warn('[ChannelCache] 渠道热数据预热失败:', error.message);
|
||
}
|
||
}
|
||
|
||
async function getChannelIdAnalysisList(options) {
|
||
const resolvedPage = safePage(options.page);
|
||
const resolvedPageSize = safePageSize(options.pageSize);
|
||
const dateRange = await resolveDateRange(options.startDate, options.endDate);
|
||
|
||
if (isDefaultChannelIdAnalysisQuery(options, dateRange, resolvedPage, resolvedPageSize)) {
|
||
const snapshotConfig = buildChannelIdDefaultSnapshotConfig(
|
||
dateRange.latestBizDate,
|
||
resolvedPage,
|
||
resolvedPageSize
|
||
);
|
||
const snapshot = await getChannelIdDefaultSnapshot(snapshotConfig);
|
||
|
||
return {
|
||
...snapshot.value,
|
||
meta: {
|
||
...(snapshot.value?.meta || {}),
|
||
cacheHit: snapshot.hit !== 'miss-default',
|
||
cacheLayer: snapshot.hit,
|
||
},
|
||
};
|
||
}
|
||
|
||
const cacheKey = buildChannelIdAnalysisCacheKey({
|
||
...options,
|
||
...dateRange,
|
||
page: resolvedPage,
|
||
pageSize: resolvedPageSize,
|
||
});
|
||
|
||
const cached = await dataPageCache.getOrSetCachedPayload({
|
||
key: cacheKey,
|
||
memoryTtlSeconds: CHANNEL_ID_ANALYSIS_MEMORY_TTL_SECONDS,
|
||
redisTtlSeconds: CHANNEL_ID_ANALYSIS_CACHE_TTL_SECONDS,
|
||
label: `channel:id-analysis ${dateRange.startDate}~${dateRange.endDate} page=${resolvedPage}`,
|
||
loader: async () => {
|
||
return getChannelIdAnalysisFromReadStore({
|
||
dateRange,
|
||
page: resolvedPage,
|
||
pageSize: resolvedPageSize,
|
||
keyword: options.keyword,
|
||
sortField: options.sortField,
|
||
sortOrder: options.sortOrder,
|
||
});
|
||
|
||
const params = {
|
||
startDate: dateRange.startDate,
|
||
endDate: dateRange.endDate,
|
||
rowStart: (resolvedPage - 1) * resolvedPageSize + 1,
|
||
rowEnd: resolvedPage * resolvedPageSize,
|
||
};
|
||
const excludedSkuSql = buildExcludedSkuInClause(params);
|
||
const whereParts = [];
|
||
const keyword = String(options.keyword || '').trim();
|
||
|
||
if (keyword) {
|
||
params.keyword = `%${keyword}%`;
|
||
whereParts.push(`(
|
||
sm.channelName LIKE @keyword
|
||
OR sm.shopName LIKE @keyword
|
||
OR sm.itemId LIKE @keyword
|
||
OR ps.primarySkuCode LIKE @keyword
|
||
OR COALESCE(NULLIF(sk.productCode, N''), ps.primarySkuCode, sm.itemId) LIKE @keyword
|
||
OR ${CHANNEL_ID_OPERATOR_SQL} LIKE @keyword
|
||
)`);
|
||
}
|
||
|
||
const whereSql = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '';
|
||
const orderSql = `${resolveChannelIdSortExpression(options.sortField)} ${resolveSortOrder(options.sortOrder)}, fr.itemId ASC`;
|
||
|
||
const sqlText = `
|
||
WITH source_metrics AS (
|
||
SELECT
|
||
COALESCE(NULLIF(LTRIM(RTRIM(d.platform)), N''), N'未归类') AS channelName,
|
||
LTRIM(RTRIM(d.shop_name)) AS shopName,
|
||
REPLACE(REPLACE(LTRIM(RTRIM(d.shop_name)), N'_', N''), N'-', N'') AS shopKey,
|
||
LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), d.item_id))) AS itemId,
|
||
SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.real_performance), 0)) AS performanceAmount,
|
||
SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.real_conclude_fee), 0)) AS feeAmount,
|
||
SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.sales_amount), 0)) AS transactionAmount,
|
||
SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.after_sale_amount), 0)) AS afterSaleAmount,
|
||
SUM(
|
||
COALESCE(
|
||
TRY_CONVERT(DECIMAL(18, 2), d.push_fee_total),
|
||
ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.push_fee), 0) + ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.shared_push_fee_sum), 0)
|
||
)
|
||
) AS pushFeeAmount,
|
||
SUM(
|
||
COALESCE(
|
||
TRY_CONVERT(DECIMAL(18, 2), d.marketing_fee_total),
|
||
ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.marketing_fee), 0) + ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.shared_market_fee_sum), 0)
|
||
)
|
||
) AS marketingFeeAmount,
|
||
SUM(
|
||
ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.clear_sales_cost), 0)
|
||
+ ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.send_freight), 0)
|
||
) AS grossCostAmount,
|
||
SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.goods_count), 0)) AS goodsCount,
|
||
SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.refund_count), 0)) AS refundCount,
|
||
COUNT(DISTINCT CASE
|
||
WHEN d.sourceno IS NOT NULL AND LTRIM(RTRIM(d.sourceno)) <> N'' THEN LTRIM(RTRIM(d.sourceno))
|
||
ELSE NULL
|
||
END) AS orderCount,
|
||
COUNT(DISTINCT CASE
|
||
WHEN d.sourceno IS NOT NULL
|
||
AND LTRIM(RTRIM(d.sourceno)) <> N''
|
||
AND COALESCE(
|
||
TRY_CONVERT(DECIMAL(18, 2), d.install_fee_total),
|
||
ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.install_fee), 0) + ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.shared_install_fee_sum), 0)
|
||
) > 0
|
||
THEN LTRIM(RTRIM(d.sourceno))
|
||
ELSE NULL
|
||
END) AS installOrderCount
|
||
FROM ${CHANNEL_SOURCE_TABLE} d
|
||
WHERE d.stat_date BETWEEN @startDate AND @endDate
|
||
AND d.shop_name IS NOT NULL
|
||
AND LTRIM(RTRIM(d.shop_name)) <> N''
|
||
AND d.item_id IS NOT NULL
|
||
AND LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), d.item_id))) <> N''
|
||
AND (
|
||
d.skuoid IS NULL
|
||
OR LTRIM(RTRIM(d.skuoid)) = N''
|
||
OR UPPER(LTRIM(RTRIM(d.skuoid))) NOT IN (${excludedSkuSql})
|
||
)
|
||
${CHANNEL_NON_VIRTUAL_SOURCE_FILTER_SQL}
|
||
GROUP BY
|
||
COALESCE(NULLIF(LTRIM(RTRIM(d.platform)), N''), N'未归类'),
|
||
LTRIM(RTRIM(d.shop_name)),
|
||
REPLACE(REPLACE(LTRIM(RTRIM(d.shop_name)), N'_', N''), N'-', N''),
|
||
LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), d.item_id)))
|
||
),
|
||
sku_metrics AS (
|
||
SELECT
|
||
REPLACE(REPLACE(LTRIM(RTRIM(d.shop_name)), N'_', N''), N'-', N'') AS shopKey,
|
||
LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), d.item_id))) AS itemId,
|
||
LTRIM(RTRIM(d.skuoid)) AS skuCode,
|
||
SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.real_performance), 0)) AS skuPerformanceAmount
|
||
FROM ${CHANNEL_SOURCE_TABLE} d
|
||
WHERE d.stat_date BETWEEN @startDate AND @endDate
|
||
AND d.shop_name IS NOT NULL
|
||
AND LTRIM(RTRIM(d.shop_name)) <> N''
|
||
AND d.item_id IS NOT NULL
|
||
AND LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), d.item_id))) <> N''
|
||
AND d.skuoid IS NOT NULL
|
||
AND LTRIM(RTRIM(d.skuoid)) <> N''
|
||
AND UPPER(LTRIM(RTRIM(d.skuoid))) NOT IN (${excludedSkuSql})
|
||
${CHANNEL_NON_VIRTUAL_SOURCE_FILTER_SQL}
|
||
GROUP BY
|
||
REPLACE(REPLACE(LTRIM(RTRIM(d.shop_name)), N'_', N''), N'-', N''),
|
||
LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), d.item_id))),
|
||
LTRIM(RTRIM(d.skuoid))
|
||
),
|
||
sku_counts AS (
|
||
SELECT
|
||
shopKey,
|
||
itemId,
|
||
COUNT(1) AS skuCount
|
||
FROM sku_metrics
|
||
GROUP BY shopKey, itemId
|
||
),
|
||
primary_sku AS (
|
||
SELECT shopKey, itemId, skuCode AS primarySkuCode
|
||
FROM (
|
||
SELECT
|
||
sm.shopKey,
|
||
sm.itemId,
|
||
sm.skuCode,
|
||
ROW_NUMBER() OVER (
|
||
PARTITION BY sm.shopKey, sm.itemId
|
||
ORDER BY sm.skuPerformanceAmount DESC, sm.skuCode ASC
|
||
) AS rn
|
||
FROM sku_metrics sm
|
||
) ranked
|
||
WHERE rn = 1
|
||
),
|
||
sku_dim AS (
|
||
SELECT
|
||
base.primarySkuCode,
|
||
COALESCE(NULLIF(lib.product_code, N''), NULLIF(jsku.product_code, N''), base.primarySkuCode) AS productCode,
|
||
COALESCE(
|
||
NULLIF(lib.sale_status, N''),
|
||
CASE WHEN ISNULL(jsku.enabled, 0) = 1 AND ISNULL(jsku.stock_disabled, 0) = 0 THEN N'销售' ELSE N'未配置' END
|
||
) AS saleStatus,
|
||
COALESCE(NULLIF(jsku.pic_big, N''), NULLIF(jsku.pic, N'')) AS skuImageUrl
|
||
FROM (SELECT DISTINCT primarySkuCode FROM primary_sku WHERE primarySkuCode IS NOT NULL AND primarySkuCode <> N'') base
|
||
OUTER APPLY (
|
||
SELECT TOP 1
|
||
NULLIF(LTRIM(RTRIM(l.ItemNum)), N'') AS product_code,
|
||
COALESCE(NULLIF(LTRIM(RTRIM(l.ProStatus)), N''), N'未配置') AS sale_status
|
||
FROM dbo.fys_SkuDataLibraryNew l
|
||
WHERE l.SkuCode = base.primarySkuCode
|
||
ORDER BY CASE WHEN l.ProStatus = N'销售' THEN 0 ELSE 1 END, l.CreationTime DESC
|
||
) lib
|
||
OUTER APPLY (
|
||
SELECT TOP 1
|
||
${PRODUCT_CODE_EXPR_SQL} AS product_code,
|
||
ISNULL(j.enabled, 0) AS enabled,
|
||
ISNULL(j.stock_disabled, 0) AS stock_disabled,
|
||
NULLIF(LTRIM(RTRIM(j.pic)), N'') AS pic,
|
||
NULLIF(LTRIM(RTRIM(j.pic_big)), N'') AS pic_big
|
||
FROM dbo.jst_sku j
|
||
WHERE j.sku_id = base.primarySkuCode
|
||
) jsku
|
||
),
|
||
filtered_rows AS (
|
||
SELECT
|
||
sm.channelName,
|
||
sm.shopName,
|
||
sm.shopKey,
|
||
sm.itemId,
|
||
ISNULL(sc.skuCount, 0) AS skuCount,
|
||
ps.primarySkuCode,
|
||
COALESCE(NULLIF(sk.productCode, N''), ps.primarySkuCode, sm.itemId) AS primarySpu,
|
||
sk.skuImageUrl,
|
||
COALESCE(NULLIF(sk.saleStatus, N''), N'未配置') AS saleStatus,
|
||
${CHANNEL_ID_OPERATOR_SQL} AS purchaserName,
|
||
CAST(ROUND(sm.performanceAmount, 2) AS DECIMAL(18, 2)) AS performanceAmount,
|
||
CAST(ROUND(sm.feeAmount, 2) AS DECIMAL(18, 2)) AS feeAmount,
|
||
CAST(ROUND(CASE WHEN sm.performanceAmount > 0 THEN sm.feeAmount / sm.performanceAmount ELSE 0 END, 6) AS DECIMAL(18, 6)) AS feeRatio,
|
||
CAST(ROUND(sm.transactionAmount, 2) AS DECIMAL(18, 2)) AS transactionAmount,
|
||
CAST(ROUND(sm.afterSaleAmount, 2) AS DECIMAL(18, 2)) AS afterSaleAmount,
|
||
CAST(ROUND(sm.pushFeeAmount, 2) AS DECIMAL(18, 2)) AS pushFeeAmount,
|
||
CAST(ROUND(sm.marketingFeeAmount, 2) AS DECIMAL(18, 2)) AS marketingFeeAmount,
|
||
CAST(ROUND(sm.grossCostAmount, 2) AS DECIMAL(18, 2)) AS grossCostAmount,
|
||
CAST(ROUND(CASE WHEN sm.performanceAmount > 0 THEN (sm.performanceAmount - sm.grossCostAmount) / sm.performanceAmount ELSE 0 END, 6) AS DECIMAL(18, 6)) AS grossMarginRate,
|
||
CAST(ROUND(CASE WHEN sm.performanceAmount > 0 THEN sm.pushFeeAmount / sm.performanceAmount ELSE 0 END, 6) AS DECIMAL(18, 6)) AS sourcePromotionFeeRatio,
|
||
CAST(ROUND(CASE WHEN sm.performanceAmount > 0 THEN sm.marketingFeeAmount / sm.performanceAmount ELSE 0 END, 6) AS DECIMAL(18, 6)) AS marketingFeeRatio,
|
||
CAST(sm.orderCount AS INT) AS orderCount,
|
||
CAST(sm.installOrderCount AS INT) AS installOrderCount,
|
||
CAST(ROUND(CASE WHEN sm.orderCount > 0 THEN sm.installOrderCount * 1.0 / sm.orderCount ELSE 0 END, 6) AS DECIMAL(18, 6)) AS installOrderRatio,
|
||
CAST(ROUND(CASE WHEN sm.performanceAmount > 0 THEN sm.afterSaleAmount / sm.performanceAmount ELSE 0 END, 6) AS DECIMAL(18, 6)) AS idAfterSaleRate,
|
||
CAST(ROUND(CASE WHEN sm.performanceAmount > 0 THEN (sm.transactionAmount - sm.performanceAmount) / sm.performanceAmount ELSE 0 END, 6) AS DECIMAL(18, 6)) AS performanceRefundRate,
|
||
CAST(ROUND(sm.goodsCount, 2) AS DECIMAL(18, 2)) AS goodsCount,
|
||
CAST(ROUND(sm.refundCount, 2) AS DECIMAL(18, 2)) AS refundCount
|
||
FROM source_metrics sm
|
||
LEFT JOIN sku_counts sc
|
||
ON sc.shopKey = sm.shopKey AND sc.itemId = sm.itemId
|
||
LEFT JOIN primary_sku ps
|
||
ON ps.shopKey = sm.shopKey AND ps.itemId = sm.itemId
|
||
LEFT JOIN sku_dim sk
|
||
ON sk.primarySkuCode = ps.primarySkuCode
|
||
OUTER APPLY (
|
||
SELECT TOP 1
|
||
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.purchaser))), N'') AS operatorName
|
||
FROM dwd.dim_ope_spu_data_df d
|
||
WHERE d.spu = COALESCE(NULLIF(sk.productCode, N''), ps.primarySkuCode, sm.itemId)
|
||
AND NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.purchaser))), N'') IS NOT NULL
|
||
) md
|
||
OUTER APPLY (
|
||
SELECT TOP 1
|
||
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), v.oper))), N'') AS operatorName
|
||
FROM dbo.V_caterAndOper v
|
||
WHERE v.spu = COALESCE(NULLIF(sk.productCode, N''), ps.primarySkuCode, sm.itemId)
|
||
AND NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), v.oper))), N'') IS NOT NULL
|
||
) mv
|
||
OUTER APPLY (
|
||
SELECT TOP 1
|
||
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.oper))), N'') AS operatorName
|
||
FROM dbo.op_spuPrincipal_log l
|
||
WHERE l.spu = COALESCE(NULLIF(sk.productCode, N''), ps.primarySkuCode, sm.itemId)
|
||
AND NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), l.oper))), N'') IS NOT NULL
|
||
ORDER BY l.created DESC
|
||
) ml
|
||
OUTER APPLY (
|
||
SELECT TOP 1
|
||
NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), c.oper))), N'') AS operatorName
|
||
FROM dbo.fys_SkuDataLibrary_SpuConfig c
|
||
WHERE COALESCE(NULLIF(sk.productCode, N''), ps.primarySkuCode, sm.itemId) LIKE c.spu + N'%'
|
||
AND NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), c.oper))), N'') IS NOT NULL
|
||
ORDER BY LEN(c.spu) DESC
|
||
) mc
|
||
${whereSql}
|
||
),
|
||
ranked_rows AS (
|
||
SELECT
|
||
fr.*,
|
||
COUNT(1) OVER() AS totalCount,
|
||
SUM(fr.performanceAmount) OVER (PARTITION BY fr.purchaserName) AS purchaserPerformanceTotal,
|
||
ISNULL(
|
||
SUM(fr.performanceAmount) OVER (
|
||
PARTITION BY fr.purchaserName
|
||
ORDER BY fr.performanceAmount DESC, fr.itemId ASC
|
||
ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
|
||
),
|
||
0
|
||
) AS purchaserPerformanceBefore,
|
||
ROW_NUMBER() OVER (ORDER BY ${orderSql}) AS rn
|
||
FROM filtered_rows fr
|
||
)
|
||
SELECT *
|
||
FROM ranked_rows
|
||
WHERE rn BETWEEN @rowStart AND @rowEnd
|
||
ORDER BY rn ASC
|
||
`;
|
||
|
||
const rawRows = await cloudDb.query(sqlText, params);
|
||
const total = Number(rawRows?.[0]?.totalCount || 0);
|
||
if (!total) {
|
||
return {
|
||
page: resolvedPage,
|
||
pageSize: resolvedPageSize,
|
||
total: 0,
|
||
rows: [],
|
||
dateRange,
|
||
meta: {
|
||
source: 'direct-source-id',
|
||
},
|
||
};
|
||
}
|
||
|
||
const pddItemIds = uniqueText(
|
||
(rawRows || [])
|
||
.filter((row) => isPddChannelName(row.channelName))
|
||
.map((row) => row.itemId)
|
||
);
|
||
const [pddMetricMap, basicRankMetricMap, promoteMetricMap] = await Promise.all([
|
||
fetchChannelIdPddMetrics(pddItemIds, dateRange.startDate, dateRange.endDate),
|
||
fetchChannelIdBasicRankMetrics(rawRows || [], dateRange.startDate, dateRange.endDate),
|
||
fetchChannelIdPromoteMetrics(rawRows || [], dateRange.startDate, dateRange.endDate),
|
||
]);
|
||
const periodType = buildChannelIdPeriodType(dateRange);
|
||
|
||
const rows = (rawRows || []).map((row) => {
|
||
const itemId = normalizeText(row.itemId);
|
||
const shopKey = normalizeText(row.shopKey);
|
||
const performanceAmount = toNumber(row.performanceAmount);
|
||
const feeRatio = toNullableNumber(row.feeRatio, 6);
|
||
const idAfterSaleRate = toNullableNumber(row.idAfterSaleRate, 6);
|
||
const performanceRefundRate = toNullableNumber(row.performanceRefundRate, 6);
|
||
const pddMetrics = pddMetricMap.get(itemId) || {};
|
||
const basicRankMetrics = basicRankMetricMap.get(`${shopKey}__${itemId}`) || {};
|
||
const promoteMetrics = promoteMetricMap.get(`${shopKey}__${itemId}`) || {};
|
||
const sourcePromotionFeeRatio = toNullableNumber(row.sourcePromotionFeeRatio, 6);
|
||
const normalizedPddGlobalTakeRate = normalizeRatioValue(pddMetrics.globalTakeRate, 6);
|
||
const pddPromotionFeeRatio = (
|
||
Number.isFinite(Number(normalizedPddGlobalTakeRate)) && Number(normalizedPddGlobalTakeRate) > 0
|
||
? normalizedPddGlobalTakeRate
|
||
: null
|
||
)
|
||
?? (
|
||
Number.isFinite(Number(pddMetrics.spend)) && performanceAmount > 0
|
||
? toNullableNumber(Number(pddMetrics.spend) / performanceAmount, 6)
|
||
: null
|
||
);
|
||
const promotionFeeRatio = pddPromotionFeeRatio ?? sourcePromotionFeeRatio;
|
||
const targetFeeRatio = getChannelIdFeeThreshold(row.channelName);
|
||
const targetPromotionFeeRatio = (
|
||
Number.isFinite(Number(promotionFeeRatio)) &&
|
||
Number.isFinite(Number(feeRatio))
|
||
)
|
||
? toNullableNumber(Number(promotionFeeRatio) - (Number(feeRatio) - targetFeeRatio), 6)
|
||
: null;
|
||
const roi = Number.isFinite(Number(promotionFeeRatio)) && Number(promotionFeeRatio) > 0
|
||
? toNullableNumber(1 / Number(promotionFeeRatio), 2)
|
||
: null;
|
||
const targetRoi = Number.isFinite(Number(pddMetrics.targetRoi)) && Number(pddMetrics.targetRoi) > 0
|
||
? pddMetrics.targetRoi
|
||
: (
|
||
Number.isFinite(Number(targetPromotionFeeRatio)) && Number(targetPromotionFeeRatio) > 0
|
||
? toNullableNumber(1 / Number(targetPromotionFeeRatio), 2)
|
||
: null
|
||
);
|
||
const refundRate = pddMetrics.refundRate ?? (
|
||
Number(row.goodsCount) > 0
|
||
? toNullableNumber(Number(row.refundCount) / Number(row.goodsCount), 6)
|
||
: null
|
||
);
|
||
|
||
return {
|
||
channelName: row.channelName || '未归类',
|
||
shopName: row.shopName || '--',
|
||
purchaserName: row.purchaserName || '未配置',
|
||
imageUrl: pddMetrics.imageUrl || row.skuImageUrl || null,
|
||
itemId,
|
||
periodType,
|
||
anomalyTag: buildChannelIdAnomalyTag({
|
||
channelName: row.channelName,
|
||
feeRatio: Number(feeRatio),
|
||
idAfterSaleRate: Number(idAfterSaleRate),
|
||
}),
|
||
headMidTailLabel: buildChannelIdHeadTailLabel(row.purchaserPerformanceTotal, row.purchaserPerformanceBefore),
|
||
performanceAmount,
|
||
feeRatio,
|
||
grossMarginRate: toNullableNumber(row.grossMarginRate, 6),
|
||
promotionFeeRatio,
|
||
marketingFeeRatio: toNullableNumber(row.marketingFeeRatio, 6),
|
||
installOrderRatio: toNullableNumber(row.installOrderRatio, 6),
|
||
idAfterSaleRate,
|
||
performanceRefundRate,
|
||
roi,
|
||
targetRoi,
|
||
goodsUv: pddMetrics.goodsUv ?? basicRankMetrics.goodsUv ?? null,
|
||
payOrdrAmt: pddMetrics.payOrdrAmt ?? basicRankMetrics.payment ?? toNullableNumber(row.transactionAmount, 2),
|
||
goodsVcr: pddMetrics.goodsVcr ?? basicRankMetrics.goodsVcr ?? null,
|
||
trafficLossIndex: pddMetrics.imprUsrCnt ?? basicRankMetrics.searchGuideViewsNum ?? null,
|
||
goodsPtHelpRate30d: pddMetrics.goodsPtHelpRate ?? null,
|
||
promotionScore: promoteMetrics.promotionScore ?? null,
|
||
impression: pddMetrics.impression ?? null,
|
||
ctr: pddMetrics.ctr ?? null,
|
||
gmv: pddMetrics.gmv ?? null,
|
||
netGmv: pddMetrics.netGmv ?? null,
|
||
actualRoi: pddMetrics.actualRoi ?? null,
|
||
netRoi: pddMetrics.netRoi ?? null,
|
||
settlementRoi: pddMetrics.settlementRoi ?? null,
|
||
refundRate,
|
||
primarySkuCode: row.primarySkuCode || '',
|
||
primarySpu: row.primarySpu || '',
|
||
skuCount: toNumber(row.skuCount, 0),
|
||
saleStatus: row.saleStatus || '未配置',
|
||
feeAmount: toNumber(row.feeAmount),
|
||
transactionAmount: toNumber(row.transactionAmount),
|
||
afterSaleAmount: toNumber(row.afterSaleAmount),
|
||
pushFeeAmount: toNumber(row.pushFeeAmount),
|
||
marketingFeeAmount: toNumber(row.marketingFeeAmount),
|
||
grossCostAmount: toNumber(row.grossCostAmount),
|
||
promoteCostCharge: toNumber(promoteMetrics.costCharge),
|
||
promoteSupportCharge: toNumber(promoteMetrics.supCharge),
|
||
strategyIds: toNumber(promoteMetrics.strategyIds),
|
||
strategyKeywords: toNumber(promoteMetrics.strategyKeywords),
|
||
strategyPerson: toNumber(promoteMetrics.strategyPerson),
|
||
strategyScene: toNumber(promoteMetrics.strategyScene),
|
||
goodsCount: toNumber(row.goodsCount),
|
||
refundCount: toNumber(row.refundCount),
|
||
orderCount: toNumber(row.orderCount, 0),
|
||
installOrderCount: toNumber(row.installOrderCount, 0),
|
||
};
|
||
});
|
||
|
||
return {
|
||
page: resolvedPage,
|
||
pageSize: resolvedPageSize,
|
||
total,
|
||
rows,
|
||
dateRange,
|
||
meta: {
|
||
source: 'direct-source-id',
|
||
},
|
||
};
|
||
},
|
||
});
|
||
|
||
return {
|
||
...cached.value,
|
||
meta: {
|
||
...(cached.value?.meta || {}),
|
||
cacheHit: cached.hit !== 'miss',
|
||
cacheLayer: cached.hit,
|
||
},
|
||
};
|
||
}
|
||
|
||
async function getChannelDetail(options) {
|
||
const dateRange = await resolveDateRange(options.startDate, options.endDate);
|
||
const channelName = String(options.channelName || '').trim();
|
||
const shopName = String(options.shopName || '').trim();
|
||
const skuCode = String(options.skuCode || '').trim();
|
||
const shopKey = shopName.replace(/[_-]/g, '');
|
||
|
||
if (!channelName || !shopName || !skuCode) {
|
||
const error = new Error('缺少渠道明细查询参数');
|
||
error.statusCode = 400;
|
||
throw error;
|
||
}
|
||
|
||
const cacheKey = buildChannelDetailCacheKey({
|
||
startDate: dateRange.startDate,
|
||
endDate: dateRange.endDate,
|
||
channelName,
|
||
shopName,
|
||
skuCode,
|
||
});
|
||
|
||
const cached = await dataPageCache.getOrSetCachedPayload({
|
||
key: cacheKey,
|
||
memoryTtlSeconds: CHANNEL_DETAIL_MEMORY_TTL_SECONDS,
|
||
redisTtlSeconds: CHANNEL_DETAIL_CACHE_TTL_SECONDS,
|
||
label: `channel:detail ${dateRange.startDate}~${dateRange.endDate} ${shopName}/${skuCode}`,
|
||
loader: async () => {
|
||
const params = {
|
||
startDate: dateRange.startDate,
|
||
endDate: dateRange.endDate,
|
||
channelName,
|
||
shopName,
|
||
skuCode,
|
||
};
|
||
const excludedSkuSql = buildExcludedSkuInClause(params);
|
||
|
||
const detailSql = `
|
||
SELECT
|
||
ISNULL(NULLIF(TRY_CONVERT(NVARCHAR(255), d.item_id), N''), N'--') AS itemId,
|
||
LTRIM(RTRIM(d.sourceno)) AS orderNo,
|
||
CAST(ROUND(SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.sales_amount), 0)), 2) AS DECIMAL(18, 2)) AS salesAmount,
|
||
CAST(ROUND(SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.after_sale_amount), 0)), 2) AS DECIMAL(18, 2)) AS afterSaleAmount,
|
||
CAST(
|
||
ROUND(
|
||
CASE
|
||
WHEN SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.sales_amount), 0)) > 0
|
||
THEN SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.after_sale_amount), 0)) * 100.0 / SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.sales_amount), 0))
|
||
ELSE 0
|
||
END,
|
||
2
|
||
) AS DECIMAL(18, 2)
|
||
) AS afterSaleAmountRate
|
||
FROM ${CHANNEL_SOURCE_TABLE} d
|
||
WHERE d.stat_date BETWEEN @startDate AND @endDate
|
||
AND COALESCE(NULLIF(LTRIM(RTRIM(d.platform)), N''), N'未归类') = @channelName
|
||
AND LTRIM(RTRIM(d.shop_name)) = @shopName
|
||
AND LTRIM(RTRIM(d.skuoid)) = @skuCode
|
||
AND d.sourceno IS NOT NULL
|
||
AND LTRIM(RTRIM(d.sourceno)) <> N''
|
||
AND (
|
||
ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.after_sale_amount), 0) <> 0
|
||
OR ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.refund_count), 0) <> 0
|
||
)
|
||
AND UPPER(LTRIM(RTRIM(d.skuoid))) NOT IN (${excludedSkuSql})
|
||
${CHANNEL_NON_VIRTUAL_SOURCE_FILTER_SQL}
|
||
GROUP BY TRY_CONVERT(NVARCHAR(255), d.item_id), LTRIM(RTRIM(d.sourceno))
|
||
ORDER BY
|
||
SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.after_sale_amount), 0)) DESC,
|
||
SUM(ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.sales_amount), 0)) DESC,
|
||
LTRIM(RTRIM(d.sourceno)) ASC
|
||
`;
|
||
|
||
const rows = await cloudDb.query(detailSql, params);
|
||
const reasonMap = new Map();
|
||
const orderNumbers = [...new Set((rows || []).map((row) => row.orderNo).filter(Boolean))];
|
||
|
||
for (const chunk of chunkArray(orderNumbers, 200)) {
|
||
const reasonParams = {
|
||
shopKey,
|
||
skuCode,
|
||
};
|
||
const placeholders = chunk.map((orderNo, index) => {
|
||
const key = `orderNo${index}`;
|
||
reasonParams[key] = orderNo;
|
||
return `@${key}`;
|
||
});
|
||
|
||
const reasonRows = await cloudDb.query(
|
||
`SELECT
|
||
LTRIM(RTRIM(a.undefined2)) AS orderNo,
|
||
NULLIF(LTRIM(RTRIM(a.undefined57)), N'') AS refundReason
|
||
FROM dbo.jst_goods_profit_kpi_items_afs a
|
||
WHERE REPLACE(REPLACE(LTRIM(RTRIM(a.undefined8)), N'_', N''), N'-', N'') = @shopKey
|
||
AND LTRIM(RTRIM(a.undefined10)) = @skuCode
|
||
AND LTRIM(RTRIM(a.undefined2)) IN (${placeholders.join(', ')})
|
||
AND a.undefined57 IS NOT NULL
|
||
AND LTRIM(RTRIM(a.undefined57)) <> N''`,
|
||
reasonParams
|
||
);
|
||
|
||
for (const reasonRow of reasonRows || []) {
|
||
const orderNo = reasonRow.orderNo;
|
||
const reason = reasonRow.refundReason;
|
||
if (!orderNo || !reason) continue;
|
||
|
||
if (!reasonMap.has(orderNo)) {
|
||
reasonMap.set(orderNo, new Set());
|
||
}
|
||
reasonMap.get(orderNo).add(reason);
|
||
}
|
||
}
|
||
|
||
const normalizedRows = (rows || []).map((row) => ({
|
||
itemId: row.itemId || '--',
|
||
orderNo: row.orderNo || '',
|
||
refundReason: reasonMap.has(row.orderNo) ? Array.from(reasonMap.get(row.orderNo)).join(';') : '',
|
||
salesAmount: toNumber(row.salesAmount),
|
||
afterSaleAmount: toNumber(row.afterSaleAmount),
|
||
afterSaleAmountRate: toNumber(row.afterSaleAmountRate),
|
||
}));
|
||
|
||
return {
|
||
rows: normalizedRows,
|
||
total: normalizedRows.length,
|
||
dateRange: {
|
||
startDate: dateRange.startDate,
|
||
endDate: dateRange.endDate,
|
||
latestBizDate: dateRange.latestBizDate,
|
||
},
|
||
meta: {
|
||
source: 'direct-source',
|
||
},
|
||
};
|
||
},
|
||
});
|
||
|
||
return {
|
||
...cached.value,
|
||
meta: {
|
||
...(cached.value?.meta || {}),
|
||
cacheHit: cached.hit !== 'miss',
|
||
cacheLayer: cached.hit,
|
||
},
|
||
};
|
||
}
|
||
|
||
module.exports = {
|
||
getChannelList,
|
||
exportChannelListCsv,
|
||
createChannelExportJob,
|
||
getChannelExportJob,
|
||
getChannelExportJobFile,
|
||
getChannelDashboard,
|
||
getChannelIdAnalysisList,
|
||
getChannelIdAnalysisDashboard,
|
||
getChannelSpuList,
|
||
getChannelStockMonitorDashboard,
|
||
getChannelDetail,
|
||
getChannelLargeCustomerList,
|
||
getChannelLargeCustomerDashboard,
|
||
getChannelLargeCustomerDetail,
|
||
refreshChannelLargeCustomerReadStore,
|
||
getLatestChannelBizDate,
|
||
resolveDateRange,
|
||
ensureChannelReadCoverage,
|
||
syncChannelSummaryRange,
|
||
prewarmChannelDefaultSnapshots,
|
||
prewarmChannelIdDefaultSnapshots,
|
||
prewarmChannelIdDashboardDefaultSnapshot,
|
||
prewarmChannelHotData,
|
||
prewarmChannelStockMonitorDashboardDefaultSnapshot,
|
||
};
|