cangku/backend/src/services/channelAnalysis.js

10376 lines
392 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

const crypto = require('crypto');
const 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,
};