cangku/backend/src/services/logisticsPickupBoard.js

700 lines
25 KiB
JavaScript

const fs = require('fs');
const path = require('path');
const dayjs = require('dayjs');
const cloudDb = require('../config/cloudDatabase');
const PICKUP_EXCLUDED_LABEL_KEYWORDS = [
'配件',
'经管大单',
'不发',
'等通知',
'延期',
'延迟',
'武进安能',
'中铁',
'物流',
'自提',
'外贸',
'采购单',
'备货',
'定制',
'中转集运',
'中转订单',
'新疆西藏单',
'ECLP订单',
];
const PICKUP_EXCLUDED_COURIER_KEYWORDS = [
'武进安能',
'中铁',
'物流',
'自提',
'线下快递',
'专线',
];
const PICKUP_EXCLUDED_REMARK_KEYWORDS = [
'bbff',
'ccdd',
'转X00006',
];
const PICKUP_TIMELY_NO_FETCH_COURIER_KEYWORDS = [
'京东快运',
'京东自营',
'顺丰',
];
const PICKUP_TIMELY_LABEL_KEYWORDS = [
'CLPS订单',
'ECLP订单',
];
const UNKNOWN_WAREHOUSE = '未知仓库';
const UNKNOWN_SUPPLIER = '未知供应商';
const UNKNOWN_PURCHASER = '未配置';
const SNAPSHOT_DIR = path.resolve(__dirname, '../../runtime-cache/logistics-pickup-board');
function q(value) {
return `N'${String(value).replace(/'/g, "''")}'`;
}
function buildSqlLikeAny(column, keywords) {
return keywords
.map((keyword) => `${column} LIKE ${q(`%${keyword}%`)}`)
.join(' OR ');
}
const excludedLabelSql = buildSqlLikeAny(`ISNULL(src.order_labels, N'')`, PICKUP_EXCLUDED_LABEL_KEYWORDS);
const excludedCourierSql = buildSqlLikeAny(`ISNULL(src.courier_name, N'')`, PICKUP_EXCLUDED_COURIER_KEYWORDS);
const excludedRemarkSql = buildSqlLikeAny(
`ISNULL(src.buyer_message, N'') + N' ' + ISNULL(src.remark, N'') + N' ' + ISNULL(src.remark_content, N'')`,
PICKUP_EXCLUDED_REMARK_KEYWORDS
);
const timelyNoFetchCourierSql = buildSqlLikeAny(`ISNULL(src.courier_name, N'')`, PICKUP_TIMELY_NO_FETCH_COURIER_KEYWORDS);
const timelyLabelSql = buildSqlLikeAny(`ISNULL(src.order_labels, N'')`, PICKUP_TIMELY_LABEL_KEYWORDS);
const PICKUP_BOARD_SQL = `
SET NOCOUNT ON;
DECLARE @payStart DATETIME = DATEADD(DAY, -1, CAST(@startDate AS DATETIME));
DECLARE @payEnd DATETIME = DATEADD(DAY, -1, CAST(@endDate AS DATETIME));
SELECT
CAST(DATEADD(DAY, 1, TRY_CONVERT(datetime, REPLACE(undefined5, '/', '-'))) AS date) AS stat_date,
NULLIF(LTRIM(RTRIM(undefined1)), '') AS inner_order_id,
NULLIF(LTRIM(RTRIM(undefined2)), '') AS order_id,
NULLIF(LTRIM(RTRIM(undefined10)), '') AS sku_id,
TRY_CONVERT(datetime, REPLACE(undefined5, '/', '-')) AS pay_time,
TRY_CONVERT(datetime, REPLACE(undefined6, '/', '-')) AS afs_send_time,
NULLIF(LTRIM(RTRIM(undefined8)), '') AS shop_name,
NULLIF(LTRIM(RTRIM(undefined62)), '') AS order_labels,
NULLIF(LTRIM(RTRIM(undefined72)), '') AS express_no,
NULLIF(LTRIM(RTRIM(undefined73)), '') AS afs_courier_name,
NULLIF(LTRIM(RTRIM(undefined74)), '') AS province,
NULLIF(LTRIM(RTRIM(undefined75)), '') AS city,
NULLIF(LTRIM(RTRIM(undefined66)), '') AS afs_warehouse_name,
NULLIF(LTRIM(RTRIM(undefined32)), '') AS order_type,
TRY_CONVERT(datetime, REPLACE(undefined33, '/', '-')) AS aftersale_apply_time
INTO #afs_raw
FROM dbo.jst_goods_profit_kpi_items_afs
WHERE TRY_CONVERT(datetime, REPLACE(undefined5, '/', '-')) >= @payStart
AND TRY_CONVERT(datetime, REPLACE(undefined5, '/', '-')) < DATEADD(DAY, 1, @payEnd);
CREATE NONCLUSTERED INDEX IX_afs_raw_stat_dedup ON #afs_raw(stat_date, inner_order_id, sku_id);
WITH ranked AS (
SELECT
*,
ROW_NUMBER() OVER (
PARTITION BY stat_date, inner_order_id, sku_id
ORDER BY
CASE WHEN express_no IS NULL OR express_no = '' THEN 1 ELSE 0 END,
CASE WHEN afs_send_time IS NULL THEN 1 ELSE 0 END,
afs_send_time DESC,
pay_time DESC
) AS rn
FROM #afs_raw
WHERE stat_date BETWEEN @startDate AND @endDate
AND inner_order_id IS NOT NULL
AND inner_order_id <> ''
AND sku_id IS NOT NULL
AND sku_id <> ''
)
SELECT
stat_date,
inner_order_id,
order_id,
sku_id,
pay_time,
afs_send_time,
shop_name,
order_labels,
express_no,
afs_courier_name,
province,
city,
afs_warehouse_name,
order_type,
aftersale_apply_time
INTO #afs_dedup
FROM ranked
WHERE rn = 1;
CREATE NONCLUSTERED INDEX IX_afs_dedup_order_id ON #afs_dedup(order_id);
CREATE NONCLUSTERED INDEX IX_afs_dedup_sku_id ON #afs_dedup(sku_id);
CREATE NONCLUSTERED INDEX IX_afs_dedup_express_no ON #afs_dedup(express_no);
SELECT DISTINCT express_no
INTO #express_keys
FROM #afs_dedup
WHERE express_no IS NOT NULL
AND express_no <> '';
SELECT DISTINCT order_id
INTO #order_keys
FROM #afs_dedup
WHERE order_id IS NOT NULL
AND order_id <> '';
CREATE UNIQUE CLUSTERED INDEX IX_order_keys ON #order_keys(order_id);
SELECT DISTINCT sku_id
INTO #sku_keys
FROM #afs_dedup
WHERE sku_id IS NOT NULL
AND sku_id <> '';
CREATE UNIQUE CLUSTERED INDEX IX_sku_keys ON #sku_keys(sku_id);
SELECT
NULLIF(LTRIM(RTRIM(e.logistic_id)), '') AS logistic_id,
MAX(NULLIF(LTRIM(RTRIM(e.so_id)), '')) AS so_id,
MAX(NULLIF(LTRIM(RTRIM(e.logistic_company_name)), '')) AS logistic_company_name,
MAX(NULLIF(LTRIM(RTRIM(e.order_status_name)), '')) AS order_status_name,
MAX(NULLIF(LTRIM(RTRIM(e.aftersale_status_name)), '')) AS aftersale_status_name,
MAX(NULLIF(LTRIM(RTRIM(e.remark)), '')) AS remark,
MAX(NULLIF(LTRIM(RTRIM(e.buyer_message)), '')) AS buyer_message,
MAX(NULLIF(LTRIM(RTRIM(e.remark_content)), '')) AS remark_content,
MAX(NULLIF(LTRIM(RTRIM(e.shop_name)), '')) AS shop_name,
MAX(NULLIF(LTRIM(RTRIM(e.warehouse_name)), '')) AS warehouse_name,
MIN(e.send_time) AS send_time,
MIN(e.fetch_time) AS fetch_time,
MAX(NULLIF(LTRIM(RTRIM(e.receiver_address)), '')) AS receiver_address
INTO #express_by_logistic
FROM dbo.jst_order_express_details e
INNER JOIN #express_keys k
ON k.express_no = NULLIF(LTRIM(RTRIM(e.logistic_id)), '')
GROUP BY NULLIF(LTRIM(RTRIM(e.logistic_id)), '');
CREATE UNIQUE CLUSTERED INDEX IX_express_by_logistic ON #express_by_logistic(logistic_id);
SELECT
NULLIF(LTRIM(RTRIM(e.so_id)), '') AS so_id,
MAX(NULLIF(LTRIM(RTRIM(e.logistic_id)), '')) AS logistic_id,
MAX(NULLIF(LTRIM(RTRIM(e.logistic_company_name)), '')) AS logistic_company_name,
MAX(NULLIF(LTRIM(RTRIM(e.order_status_name)), '')) AS order_status_name,
MAX(NULLIF(LTRIM(RTRIM(e.aftersale_status_name)), '')) AS aftersale_status_name,
MAX(NULLIF(LTRIM(RTRIM(e.remark)), '')) AS remark,
MAX(NULLIF(LTRIM(RTRIM(e.buyer_message)), '')) AS buyer_message,
MAX(NULLIF(LTRIM(RTRIM(e.remark_content)), '')) AS remark_content,
MAX(NULLIF(LTRIM(RTRIM(e.shop_name)), '')) AS shop_name,
MAX(NULLIF(LTRIM(RTRIM(e.warehouse_name)), '')) AS warehouse_name,
MIN(e.send_time) AS send_time,
MIN(e.fetch_time) AS fetch_time,
MAX(NULLIF(LTRIM(RTRIM(e.receiver_address)), '')) AS receiver_address
INTO #express_by_order
FROM dbo.jst_order_express_details e
INNER JOIN #order_keys k
ON k.order_id = NULLIF(LTRIM(RTRIM(e.so_id)), '')
GROUP BY NULLIF(LTRIM(RTRIM(e.so_id)), '');
CREATE UNIQUE CLUSTERED INDEX IX_express_by_order ON #express_by_order(so_id);
SELECT
NULLIF(LTRIM(RTRIM(s.sku_id)), '') AS sku_id,
NULLIF(LTRIM(RTRIM(s.supplier_name)), '') AS supplier_name,
NULLIF(LTRIM(RTRIM(s.other_1)), '') AS spu_key,
NULLIF(LTRIM(RTRIM(s.other_3)), '') AS default_warehouse
INTO #sku_meta
FROM dbo.jst_sku s
INNER JOIN #sku_keys k
ON k.sku_id = NULLIF(LTRIM(RTRIM(s.sku_id)), '');
CREATE UNIQUE CLUSTERED INDEX IX_sku_meta ON #sku_meta(sku_id);
SELECT
d.stat_date,
d.inner_order_id,
d.order_id,
d.sku_id,
d.pay_time,
COALESCE(el.send_time, eo.send_time, d.afs_send_time) AS send_time,
COALESCE(el.fetch_time, eo.fetch_time) AS fetch_time,
COALESCE(el.shop_name, eo.shop_name, d.shop_name) AS shop_name,
COALESCE(el.warehouse_name, eo.warehouse_name, d.afs_warehouse_name) AS raw_warehouse_name,
COALESCE(el.logistic_company_name, eo.logistic_company_name, d.afs_courier_name) AS courier_name,
COALESCE(d.express_no, el.logistic_id, eo.logistic_id, N'') AS express_no,
COALESCE(el.order_status_name, eo.order_status_name, N'') AS order_status_name,
COALESCE(el.aftersale_status_name, eo.aftersale_status_name, N'') AS aftersale_status_name,
COALESCE(el.remark, eo.remark, N'') AS remark,
COALESCE(el.buyer_message, eo.buyer_message, N'') AS buyer_message,
COALESCE(el.remark_content, eo.remark_content, N'') AS remark_content,
COALESCE(d.order_labels, N'') AS order_labels,
COALESCE(d.province, N'') AS province,
COALESCE(d.city, N'') AS city,
COALESCE(el.receiver_address, eo.receiver_address, N'') AS receiver_address,
COALESCE(d.order_type, N'') AS order_type,
d.aftersale_apply_time,
COALESCE(s.supplier_name, N'') AS supplier_name,
COALESCE(s.spu_key, N'') AS spu_key,
COALESCE(s.default_warehouse, N'') AS default_warehouse,
COALESCE(cfg.purchaser, N'') AS purchaser_name,
COALESCE(shop_meta.responsible_department, N'') AS shop_responsible_department
INTO #pickup_enriched
FROM #afs_dedup d
LEFT JOIN #express_by_logistic el ON d.express_no = el.logistic_id
LEFT JOIN #express_by_order eo ON d.order_id = eo.so_id
LEFT JOIN #sku_meta s ON d.sku_id = s.sku_id
OUTER APPLY (
SELECT TOP 1 purchaser
FROM dbo.fys_SkuDataLibrary_SpuConfig
WHERE COALESCE(NULLIF(s.spu_key, N''), d.sku_id) LIKE spu + N'%'
AND purchaser IS NOT NULL AND purchaser != N''
ORDER BY LEN(spu) DESC
) cfg
OUTER APPLY (
SELECT TOP 1 NULLIF(LTRIM(RTRIM(responsibleDepartment)), N'') AS responsible_department
FROM dbo.fys_shopInfo
WHERE NULLIF(LTRIM(RTRIM(erpShopName)), N'') = COALESCE(el.shop_name, eo.shop_name, d.shop_name)
) shop_meta;
SELECT
src.stat_date,
ISNULL(NULLIF(src.shop_name, N''), N'未知店铺') AS shop_name,
CASE
WHEN ISNULL(NULLIF(src.raw_warehouse_name, N''), N'') = N'常州简尚家居有限公司'
AND ISNULL(NULLIF(src.default_warehouse, N''), N'') <> N''
THEN src.default_warehouse
WHEN ISNULL(NULLIF(src.raw_warehouse_name, N''), N'') <> N''
THEN src.raw_warehouse_name
WHEN ISNULL(NULLIF(src.default_warehouse, N''), N'') <> N''
THEN src.default_warehouse
ELSE ${q(UNKNOWN_WAREHOUSE)}
END AS warehouse_name,
ISNULL(NULLIF(src.purchaser_name, N''), ${q(UNKNOWN_PURCHASER)}) AS purchaser_name,
ISNULL(NULLIF(src.supplier_name, N''), ${q(UNKNOWN_SUPPLIER)}) AS supplier_name,
src.sku_id,
CASE
WHEN (${excludedLabelSql}) THEN 1
WHEN ISNULL(src.shop_responsible_department, N'') LIKE N'%灞变笢椤圭洰缁?%' THEN 1
WHEN src.supplier_name IN (${q('虚拟')}, ${q('景瓴十方')}) THEN 1
WHEN src.order_status_name IN (${q('被拆分')}, ${q('被合并')}) THEN 1
WHEN (${excludedCourierSql}) THEN 1
WHEN ISNULL(src.express_no, N'') LIKE ${q('%F800%')} THEN 1
WHEN (${excludedRemarkSql}) THEN 1
WHEN ISNULL(src.shop_name, N'') LIKE ${q('%京喜%')}
AND ISNULL(src.aftersale_status_name, ${q('暂无售后')}) <> ${q('暂无售后')} THEN 1
WHEN ISNULL(src.shop_name, N'') LIKE ${q('%样品专用店铺%')} THEN 1
WHEN ISNULL(src.order_status_name, N'') LIKE ${q('%取消%')}
AND src.send_time IS NULL
AND ISNULL(src.aftersale_status_name, ${q('暂无售后')}) IN (N'', ${q('暂无售后')}, ${q('未申请退款')}) THEN 1
WHEN src.aftersale_apply_time IS NOT NULL
AND src.pay_time IS NOT NULL
AND src.aftersale_apply_time >= src.pay_time
AND DATEDIFF(SECOND, src.pay_time, src.aftersale_apply_time) <= 24 * 3600 THEN 1
WHEN ISNULL(src.order_type, N'') LIKE ${q('%预售%')}
OR ISNULL(src.order_status_name, N'') LIKE ${q('%预售%')} THEN 1
ELSE 0
END AS is_excluded,
CASE
WHEN src.pay_time IS NULL THEN 1
WHEN ISNULL(src.shop_name, N'') LIKE ${q('%京喜%')}
AND src.send_time IS NOT NULL
AND src.send_time >= src.pay_time
AND DATEDIFF(SECOND, src.pay_time, src.send_time) <= 24 * 3600 THEN 0
WHEN (${timelyNoFetchCourierSql})
AND src.fetch_time IS NULL
AND src.send_time IS NOT NULL
AND src.send_time >= src.pay_time
AND DATEDIFF(SECOND, src.pay_time, src.send_time) <= 24 * 3600 THEN 0
WHEN (${timelyLabelSql})
AND src.send_time IS NOT NULL
AND src.send_time >= src.pay_time
AND DATEDIFF(SECOND, src.pay_time, src.send_time) <= 24 * 3600 THEN 0
WHEN src.fetch_time IS NOT NULL
AND src.fetch_time >= src.pay_time
AND DATEDIFF(SECOND, src.pay_time, src.fetch_time) <= 24 * 3600 THEN 0
ELSE 1
END AS is_timeout
INTO #pickup_scored
FROM #pickup_enriched src;
SELECT
stat_date,
shop_name,
warehouse_name,
purchaser_name,
supplier_name,
sku_id,
COUNT_BIG(*) AS total_count,
SUM(CASE WHEN is_timeout = 1 THEN 1 ELSE 0 END) AS timeout_count
FROM #pickup_scored
WHERE is_excluded = 0
GROUP BY stat_date, shop_name, warehouse_name, purchaser_name, supplier_name, sku_id
ORDER BY stat_date, total_count DESC;
`;
function parseListParam(value) {
if (!value) {
return [];
}
if (Array.isArray(value)) {
return [...new Set(value.flatMap((item) => String(item).split(',')).map((item) => item.trim()).filter(Boolean))];
}
return [...new Set(String(value).split(',').map((item) => item.trim()).filter(Boolean))];
}
function toNum(value) {
return Number(value) || 0;
}
function calcRate(totalCount, timeoutCount) {
if (!totalCount) {
return null;
}
return Number((((totalCount - timeoutCount) / totalCount) * 100).toFixed(2));
}
function clampEndDateToYesterday(endDate) {
const requestedEnd = dayjs(endDate);
const yesterday = dayjs().subtract(1, 'day').startOf('day');
return requestedEnd.isAfter(yesterday, 'day') ? yesterday.format('YYYY-MM-DD') : requestedEnd.format('YYYY-MM-DD');
}
function buildPeriods(startDate, endDate, mode) {
const start = dayjs(startDate);
const end = dayjs(endDate);
const periods = [];
let cursor = start.startOf(mode === 'monthly' ? 'month' : 'day');
while (cursor.isSame(end, mode === 'monthly' ? 'month' : 'day') || cursor.isBefore(end, mode === 'monthly' ? 'month' : 'day')) {
periods.push(
mode === 'monthly'
? {
key: cursor.format('YYYY-MM'),
label: cursor.format('YYYY年M月'),
}
: {
key: cursor.format('YYYY-MM-DD'),
label: cursor.format('M月D日'),
}
);
cursor = cursor.add(1, mode === 'monthly' ? 'month' : 'day');
}
return periods;
}
function bucketKey(statDate, mode) {
return mode === 'monthly' ? dayjs(statDate).format('YYYY-MM') : dayjs(statDate).format('YYYY-MM-DD');
}
function applyFilters(rows, filters) {
return rows.filter((row) => {
if (filters.shops.length > 0 && !filters.shops.includes(row.shopName)) return false;
if (filters.warehouses.length > 0 && !filters.warehouses.includes(row.warehouseName)) return false;
if (filters.purchasers.length > 0 && !filters.purchasers.includes(row.purchaserName)) return false;
if (filters.suppliers.length > 0 && !filters.suppliers.includes(row.supplierName)) return false;
if (filters.skuIds.length > 0 && !filters.skuIds.includes(row.skuId)) return false;
return true;
});
}
function buildFilterOptions(rows) {
const unique = (selector) => [...new Set(rows.map(selector).filter(Boolean))].sort((a, b) => a.localeCompare(b, 'zh-CN'));
return {
shops: unique((row) => row.shopName),
warehouses: unique((row) => row.warehouseName),
purchasers: unique((row) => row.purchaserName),
suppliers: unique((row) => row.supplierName),
skuIds: unique((row) => row.skuId),
};
}
function aggregateTable(rows, periods, mode, config) {
const map = new Map();
const ensureRow = (key, base) => {
if (!map.has(key)) {
const row = {
key,
...base,
totalCount: 0,
timeoutCount: 0,
periodMap: {},
};
for (const period of periods) {
row.periodMap[period.key] = {
totalCount: 0,
timeoutCount: 0,
rate: null,
};
}
map.set(key, row);
}
return map.get(key);
};
for (const item of rows) {
const period = bucketKey(item.statDate, mode);
const key = config.buildKey(item);
const row = ensureRow(key, config.project(item));
row.totalCount += item.totalCount;
row.timeoutCount += item.timeoutCount;
if (!row.periodMap[period]) {
row.periodMap[period] = { totalCount: 0, timeoutCount: 0, rate: null };
}
row.periodMap[period].totalCount += item.totalCount;
row.periodMap[period].timeoutCount += item.timeoutCount;
}
const finalize = (row) => {
const orderedRates = periods.map((period) => {
const bucket = row.periodMap[period.key] || { totalCount: 0, timeoutCount: 0 };
bucket.rate = calcRate(bucket.totalCount, bucket.timeoutCount);
return bucket.rate;
});
const lastRate = [...orderedRates].reverse().find((value) => value !== null);
const previousRate = [...orderedRates].reverse().find((value, index, arr) => value !== null && arr.indexOf(value) !== arr.lastIndexOf(value));
const firstRate = orderedRates.find((value) => value !== null);
row.totalRate = calcRate(row.totalCount, row.timeoutCount);
row.trendDelta = firstRate !== null && lastRate !== null ? Number((lastRate - firstRate).toFixed(2)) : null;
row.momDelta = orderedRates.length >= 2 && orderedRates[orderedRates.length - 2] !== null && orderedRates[orderedRates.length - 1] !== null
? Number((orderedRates[orderedRates.length - 1] - orderedRates[orderedRates.length - 2]).toFixed(2))
: previousRate !== undefined && previousRate !== null && lastRate !== null
? Number((lastRate - previousRate).toFixed(2))
: null;
return row;
};
const dataRows = Array.from(map.values()).map(finalize).sort((a, b) => {
const rateA = Number.isFinite(a.totalRate) ? a.totalRate : Number.POSITIVE_INFINITY;
const rateB = Number.isFinite(b.totalRate) ? b.totalRate : Number.POSITIVE_INFINITY;
if (rateA !== rateB) {
return rateA - rateB;
}
return b.totalCount - a.totalCount;
});
const totalRow = ensureRow('__total__', config.totalProject());
for (const item of rows) {
totalRow.totalCount += item.totalCount;
totalRow.timeoutCount += item.timeoutCount;
const period = bucketKey(item.statDate, mode);
if (!totalRow.periodMap[period]) {
totalRow.periodMap[period] = { totalCount: 0, timeoutCount: 0, rate: null };
}
totalRow.periodMap[period].totalCount += item.totalCount;
totalRow.periodMap[period].timeoutCount += item.timeoutCount;
}
const finalizedTotalRow = finalize(totalRow);
const limit = config.limit || dataRows.length;
return [finalizedTotalRow, ...dataRows.slice(0, limit)];
}
function buildBoard(rows, startDate, endDate, mode) {
const periods = buildPeriods(startDate, endDate, mode);
return {
periods,
shopRows: aggregateTable(rows, periods, mode, {
buildKey: (row) => `shop:${row.shopName}`,
project: (row) => ({ shopName: row.shopName }),
totalProject: () => ({ shopName: '总计' }),
limit: 120,
}),
warehouseRows: aggregateTable(rows, periods, mode, {
buildKey: (row) => `warehouse:${row.warehouseName}`,
project: (row) => ({ warehouseName: row.warehouseName }),
totalProject: () => ({ warehouseName: '总计' }),
limit: 80,
}),
purchaserRows: aggregateTable(rows, periods, mode, {
buildKey: (row) => `purchaser:${row.purchaserName}`,
project: (row) => ({ purchaserName: row.purchaserName }),
totalProject: () => ({ purchaserName: '总计' }),
limit: 60,
}),
supplierRows: aggregateTable(rows, periods, mode, {
buildKey: (row) => `supplier:${row.purchaserName}:${row.supplierName}`,
project: (row) => ({ purchaserName: row.purchaserName, supplierName: row.supplierName }),
totalProject: () => ({ purchaserName: '', supplierName: '总计' }),
limit: 200,
}),
skuRows: aggregateTable(rows, periods, mode, {
buildKey: (row) => `sku:${row.purchaserName}:${row.supplierName}:${row.skuId}`,
project: (row) => ({
purchaserName: row.purchaserName,
supplierName: row.supplierName,
skuId: row.skuId,
}),
totalProject: () => ({ purchaserName: '', supplierName: '', skuId: '总计' }),
limit: 300,
}),
};
}
function normalizeSnapshotRows(rows = [], periods = []) {
const safePeriods = Array.isArray(periods) ? periods : [];
const recomputeRow = (row) => {
const nextRow = {
...row,
periodMap: { ...(row.periodMap || {}) },
};
let totalCount = 0;
let timeoutCount = 0;
const rates = [];
for (const period of safePeriods) {
const bucket = nextRow.periodMap[period.key] || { totalCount: 0, timeoutCount: 0, rate: null };
const normalizedBucket = {
totalCount: Number(bucket.totalCount || 0),
timeoutCount: Number(bucket.timeoutCount || 0),
rate: calcRate(Number(bucket.totalCount || 0), Number(bucket.timeoutCount || 0)),
};
nextRow.periodMap[period.key] = normalizedBucket;
totalCount += normalizedBucket.totalCount;
timeoutCount += normalizedBucket.timeoutCount;
rates.push(normalizedBucket.rate);
}
nextRow.totalCount = totalCount;
nextRow.timeoutCount = timeoutCount;
nextRow.totalRate = calcRate(totalCount, timeoutCount);
const validRates = rates.filter((value) => value !== null);
const firstRate = validRates[0] ?? null;
const lastRate = validRates.length > 0 ? validRates[validRates.length - 1] : null;
const previousRate = validRates.length > 1 ? validRates[validRates.length - 2] : null;
nextRow.trendDelta = firstRate !== null && lastRate !== null ? Number((lastRate - firstRate).toFixed(2)) : null;
nextRow.momDelta = previousRate !== null && lastRate !== null ? Number((lastRate - previousRate).toFixed(2)) : null;
return nextRow;
};
const normalizedRows = rows.map(recomputeRow);
const [totalRow, ...dataRows] = normalizedRows;
const sortedRows = dataRows.sort((a, b) => {
const rateA = Number.isFinite(a.totalRate) ? a.totalRate : Number.POSITIVE_INFINITY;
const rateB = Number.isFinite(b.totalRate) ? b.totalRate : Number.POSITIVE_INFINITY;
if (rateA !== rateB) {
return rateA - rateB;
}
return b.totalCount - a.totalCount;
});
return totalRow ? [totalRow, ...sortedRows] : sortedRows;
}
function normalizeSnapshotData(data, effectiveEndDate) {
if (!data) {
return null;
}
const trimPeriods = (board) => {
const nextPeriods = (board?.periods || []).filter((period) => period.key <= effectiveEndDate);
return {
periods: nextPeriods,
shopRows: normalizeSnapshotRows(board?.shopRows || [], nextPeriods),
warehouseRows: normalizeSnapshotRows(board?.warehouseRows || [], nextPeriods),
purchaserRows: normalizeSnapshotRows(board?.purchaserRows || [], nextPeriods),
supplierRows: normalizeSnapshotRows(board?.supplierRows || [], nextPeriods),
skuRows: normalizeSnapshotRows(board?.skuRows || [], nextPeriods),
};
};
return {
...data,
daily: trimPeriods(data.daily),
monthly: trimPeriods(data.monthly),
meta: {
...(data.meta || {}),
endDate: effectiveEndDate,
},
};
}
function normalizeRows(rows) {
return rows.map((row) => ({
statDate: dayjs(row.stat_date).format('YYYY-MM-DD'),
shopName: row.shop_name || '未知店铺',
warehouseName: row.warehouse_name || UNKNOWN_WAREHOUSE,
purchaserName: row.purchaser_name || UNKNOWN_PURCHASER,
supplierName: row.supplier_name || UNKNOWN_SUPPLIER,
skuId: row.sku_id || '未知商品编码',
totalCount: toNum(row.total_count),
timeoutCount: toNum(row.timeout_count),
}));
}
async function queryLogisticsPickupBoard({ startDate, endDate, shops, warehouses, purchasers, suppliers, skuIds }) {
const effectiveEndDate = clampEndDateToYesterday(endDate);
const rawRows = await cloudDb.query(PICKUP_BOARD_SQL, { startDate, endDate: effectiveEndDate });
const rows = normalizeRows(rawRows);
const filters = {
shops: parseListParam(shops),
warehouses: parseListParam(warehouses),
purchasers: parseListParam(purchasers),
suppliers: parseListParam(suppliers),
skuIds: parseListParam(skuIds),
};
const filteredRows = applyFilters(rows, filters);
return {
filters: buildFilterOptions(filteredRows.length > 0 ? filteredRows : rows),
daily: buildBoard(filteredRows, startDate, effectiveEndDate, 'daily'),
monthly: buildBoard(filteredRows, startDate, effectiveEndDate, 'monthly'),
meta: {
startDate,
endDate: effectiveEndDate,
requestedEndDate: endDate,
filteredCount: filteredRows.length,
totalCount: rows.length,
},
};
}
function getSnapshotPath(startDate, endDate) {
return path.join(SNAPSHOT_DIR, `${startDate}_${endDate}.json`);
}
function readLogisticsPickupBoardSnapshot(startDate, endDate) {
const filePath = getSnapshotPath(startDate, endDate);
if (!fs.existsSync(filePath)) {
return null;
}
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
function writeLogisticsPickupBoardSnapshot(startDate, endDate, data) {
fs.mkdirSync(SNAPSHOT_DIR, { recursive: true });
const filePath = getSnapshotPath(startDate, endDate);
fs.writeFileSync(filePath, JSON.stringify(data), 'utf8');
return filePath;
}
module.exports = {
getSnapshotPath,
queryLogisticsPickupBoard,
readLogisticsPickupBoardSnapshot,
writeLogisticsPickupBoardSnapshot,
normalizeSnapshotData,
};