cangku/backend/src/services/logisticsDeliveryDurationBoard.js

547 lines
18 KiB
JavaScript

const dayjs = require('dayjs');
const cloudDb = require('../config/cloudDatabase');
const DELIVERY_DURATION_EXCLUDED_WAREHOUSES = [
'常州简尚家居有限公司',
'三方-京东京造',
];
const DELIVERY_DURATION_EXCLUDED_LOGISTICS_KEYWORDS = [
'中铁',
'自提',
'物流',
'专线',
'线下快递',
'武进安能',
'京喜',
'京东自营',
'京造',
];
const UNKNOWN_WAREHOUSE = '未知仓库';
const UNKNOWN_COURIER = '未知物流公司';
const UNKNOWN_PROVINCE = '未知省份';
const REQUEST_TIMEOUT = 180000;
const DELIVERY_DETAIL_COLUMNS = [
{ key: 'innerOrderId', title: '内部订单号' },
{ key: 'onlineOrderNo', title: '线上订单号' },
{ key: 'waybillNo', title: '物流单号' },
{ key: 'courierName', title: '物流公司' },
{ key: 'platformName', title: '平台' },
{ key: 'shopName', title: '店铺' },
{ key: 'warehouseName', title: '发货仓' },
{ key: 'receiverAddress', title: '收货省市区' },
{ key: 'payTime', title: '支付时间' },
{ key: 'sendTime', title: '发货时间' },
{ key: 'fetchTime', title: '揽收时间' },
{ key: 'signTime', title: '签收时间' },
{ key: 'payToSignHours', title: '付款至签收时长(h)' },
{ key: 'sendToSignHours', title: '发货至签收时长(h)' },
];
function q(value) {
return `N'${String(value).replace(/'/g, "''")}'`;
}
function parseListParam(value) {
if (Array.isArray(value)) {
return [...new Set(value.flatMap((item) => parseListParam(item)))];
}
return [...new Set(String(value || '')
.split(',')
.map((item) => item.trim())
.filter(Boolean))];
}
function toNumber(value, fallback = 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
function round2(value) {
const parsed = toNumber(value);
return Number(parsed.toFixed(2));
}
function normalizeDateRange({ startDate, endDate } = {}) {
const startCandidate = startDate && dayjs(startDate).isValid()
? dayjs(startDate)
: dayjs().startOf('month');
const endCandidate = endDate && dayjs(endDate).isValid()
? dayjs(endDate)
: dayjs();
const start = startCandidate.isAfter(endCandidate, 'day') ? endCandidate : startCandidate;
const end = endCandidate.isBefore(startCandidate, 'day') ? startCandidate : endCandidate;
return {
startDate: start.format('YYYY-MM-DD'),
endDate: end.format('YYYY-MM-DD'),
startDateTime: `${start.format('YYYY-MM-DD')} 00:00:00`,
endDateExclusive: `${end.add(1, 'day').format('YYYY-MM-DD')} 00:00:00`,
};
}
function sortTextValues(values) {
return values
.filter(Boolean)
.sort((a, b) => String(a).localeCompare(String(b), 'zh-Hans-CN'));
}
const excludedWarehousesSql = DELIVERY_DURATION_EXCLUDED_WAREHOUSES.map(q).join(', ');
const excludedLogisticsSql = DELIVERY_DURATION_EXCLUDED_LOGISTICS_KEYWORDS
.map((keyword) => `ISNULL(logistic_company_name, N'') NOT LIKE ${q(`%${keyword}%`)}`)
.join('\n AND ');
const provinceExpr = `
CASE
WHEN receiver_address IS NULL OR LTRIM(RTRIM(receiver_address)) = N'' THEN N'${UNKNOWN_PROVINCE}'
WHEN CHARINDEX(N'-', receiver_address) > 0 THEN LEFT(receiver_address, CHARINDEX(N'-', receiver_address) - 1)
WHEN CHARINDEX(N'自治区', receiver_address) > 0 THEN LEFT(receiver_address, CHARINDEX(N'自治区', receiver_address) + 2)
WHEN CHARINDEX(N'省', receiver_address) > 0 THEN LEFT(receiver_address, CHARINDEX(N'省', receiver_address))
WHEN CHARINDEX(N'市', receiver_address) > 0 THEN LEFT(receiver_address, CHARINDEX(N'市', receiver_address))
ELSE receiver_address
END`;
const deliveryBaseWhereSql = `
sign_time >= @startDateTime
AND sign_time < @endDateExclusive
AND pay_time IS NOT NULL
AND send_time IS NOT NULL
AND sign_time IS NOT NULL
AND sign_time >= pay_time
AND sign_time >= send_time
AND ISNULL(warehouse_name, N'') NOT IN (${excludedWarehousesSql})
AND (
logistic_company_name IS NULL
OR (
${excludedLogisticsSql}
)
)`;
const DELIVERY_AGGREGATE_SQL = `
SET NOCOUNT ON;
WITH delivery_base AS (
SELECT
CONVERT(varchar(10), sign_time, 120) AS stat_date,
COALESCE(NULLIF(LTRIM(RTRIM(warehouse_name)), N''), N'${UNKNOWN_WAREHOUSE}') AS warehouse_name,
COALESCE(NULLIF(LTRIM(RTRIM(logistic_company_name)), N''), N'${UNKNOWN_COURIER}') AS courier_name,
COALESCE(NULLIF(LTRIM(RTRIM(${provinceExpr})), N''), N'${UNKNOWN_PROVINCE}') AS province,
CAST(DATEDIFF(SECOND, pay_time, sign_time) AS DECIMAL(20, 2)) / 3600.0 AS pay_to_sign_hours,
CAST(DATEDIFF(SECOND, send_time, sign_time) AS DECIMAL(20, 2)) / 3600.0 AS send_to_sign_hours,
CASE WHEN DATEDIFF(SECOND, pay_time, sign_time) <= 48 * 3600 THEN 1 ELSE 0 END AS timely_48
FROM dbo.jst_order_express_details
WHERE ${deliveryBaseWhereSql}
)
SELECT
stat_date AS statDate,
warehouse_name AS warehouseName,
courier_name AS courierName,
province,
COUNT_BIG(*) AS orderCount,
CAST(ROUND(SUM(pay_to_sign_hours), 2) AS DECIMAL(20, 2)) AS payTotalHours,
CAST(ROUND(SUM(send_to_sign_hours), 2) AS DECIMAL(20, 2)) AS sendTotalHours,
SUM(timely_48) AS timely48Count
FROM delivery_base
GROUP BY stat_date, warehouse_name, courier_name, province
ORDER BY stat_date, warehouse_name, province, courier_name;
`;
function normalizeAggregateRow(row) {
const orderCount = toNumber(row.orderCount);
const payTotalHours = toNumber(row.payTotalHours);
const sendTotalHours = toNumber(row.sendTotalHours);
const timely48Count = toNumber(row.timely48Count);
return {
statDate: row.statDate || '',
warehouseName: row.warehouseName || UNKNOWN_WAREHOUSE,
courierName: row.courierName || UNKNOWN_COURIER,
province: row.province || UNKNOWN_PROVINCE,
orderCount,
payTotalHours,
sendTotalHours,
timely48Count,
};
}
function createBucket() {
return {
orderCount: 0,
payTotalHours: 0,
sendTotalHours: 0,
timely48Count: 0,
};
}
function addToBucket(bucket, row) {
bucket.orderCount += toNumber(row.orderCount);
bucket.payTotalHours += toNumber(row.payTotalHours);
bucket.sendTotalHours += toNumber(row.sendTotalHours);
bucket.timely48Count += toNumber(row.timely48Count);
}
function finalizeBucket(bucket) {
const orderCount = toNumber(bucket.orderCount);
const timely48Count = toNumber(bucket.timely48Count);
return {
orderCount,
payTotalHours: round2(bucket.payTotalHours),
sendTotalHours: round2(bucket.sendTotalHours),
avgPayToSignHours: orderCount > 0 ? round2(bucket.payTotalHours / orderCount) : null,
avgSendToSignHours: orderCount > 0 ? round2(bucket.sendTotalHours / orderCount) : null,
timely48Count,
timely48Rate: orderCount > 0 ? round2((timely48Count / orderCount) * 100) : null,
};
}
function buildFilterOptions(rows) {
return {
warehouses: sortTextValues([...new Set(rows.map((row) => row.warehouseName))]),
couriers: sortTextValues([...new Set(rows.map((row) => row.courierName))]),
provinces: sortTextValues([...new Set(rows.map((row) => row.province))]),
};
}
function filterRows(rows, filters) {
const warehouseSet = new Set(parseListParam(filters.warehouses));
const courierSet = new Set(parseListParam(filters.couriers));
const provinceSet = new Set(parseListParam(filters.provinces));
return rows.filter((row) => (
(warehouseSet.size === 0 || warehouseSet.has(row.warehouseName)) &&
(courierSet.size === 0 || courierSet.has(row.courierName)) &&
(provinceSet.size === 0 || provinceSet.has(row.province))
));
}
function buildSummary(rows) {
const bucket = createBucket();
rows.forEach((row) => addToBucket(bucket, row));
const summary = finalizeBucket(bucket);
return {
orderCount: summary.orderCount,
avgPayToSignHours: summary.avgPayToSignHours,
avgSendToSignHours: summary.avgSendToSignHours,
timely48Count: summary.timely48Count,
timely48Rate: summary.timely48Rate,
timeout48Count: Math.max(0, summary.orderCount - summary.timely48Count),
};
}
function buildCourierSummaries(rows) {
const grouped = new Map();
rows.forEach((row) => {
if (!grouped.has(row.courierName)) grouped.set(row.courierName, createBucket());
addToBucket(grouped.get(row.courierName), row);
});
return Array.from(grouped.entries())
.map(([name, bucket]) => ({
name,
...finalizeBucket(bucket),
}))
.sort((a, b) => (
toNumber(a.avgPayToSignHours, Number.MAX_SAFE_INTEGER) - toNumber(b.avgPayToSignHours, Number.MAX_SAFE_INTEGER) ||
String(a.name).localeCompare(String(b.name), 'zh-Hans-CN')
));
}
function buildTotalRow(rows, dimensions, key) {
const totalBucket = createBucket();
const courierMap = {};
rows.forEach((row) => {
addToBucket(totalBucket, row);
if (!courierMap[row.courierName]) courierMap[row.courierName] = createBucket();
addToBucket(courierMap[row.courierName], row);
});
const totalRow = {
key,
statDate: '总计',
warehouseName: dimensions.includes('warehouseName') ? '总计' : undefined,
province: dimensions.includes('province') ? '总计' : undefined,
total: finalizeBucket(totalBucket),
courierMap: {},
};
Object.entries(courierMap).forEach(([courierName, bucket]) => {
totalRow.courierMap[courierName] = finalizeBucket(bucket);
});
return totalRow;
}
function buildPivotRows(rows, dimensions, totalKey) {
const grouped = new Map();
rows.forEach((row) => {
const keyParts = dimensions.map((dimension) => row[dimension] || '');
const key = keyParts.join('\u0001');
if (!grouped.has(key)) {
const next = {
key: `${totalKey}:${keyParts.join('|')}`,
total: createBucket(),
courierMap: {},
};
dimensions.forEach((dimension) => {
next[dimension] = row[dimension] || '';
});
grouped.set(key, next);
}
const target = grouped.get(key);
addToBucket(target.total, row);
if (!target.courierMap[row.courierName]) {
target.courierMap[row.courierName] = createBucket();
}
addToBucket(target.courierMap[row.courierName], row);
});
const dataRows = Array.from(grouped.values())
.map((row) => {
const courierMap = {};
Object.entries(row.courierMap).forEach(([courierName, bucket]) => {
courierMap[courierName] = finalizeBucket(bucket);
});
return {
...row,
total: finalizeBucket(row.total),
courierMap,
};
})
.sort((a, b) => (
String(a.statDate || '').localeCompare(String(b.statDate || '')) ||
String(a.warehouseName || '').localeCompare(String(b.warehouseName || ''), 'zh-Hans-CN') ||
String(a.province || '').localeCompare(String(b.province || ''), 'zh-Hans-CN')
));
if (rows.length === 0) return [];
return [
buildTotalRow(rows, dimensions, `${totalKey}:total`),
...dataRows,
];
}
function buildDeliveryBoard(rows) {
return {
summary: buildSummary(rows),
couriers: buildCourierSummaries(rows),
courierDateRows: buildPivotRows(rows, ['statDate'], 'courier-date'),
courierProvinceRows: buildPivotRows(rows, ['statDate', 'province'], 'courier-province'),
warehouseCourierProvinceRows: buildPivotRows(rows, ['statDate', 'warehouseName', 'province'], 'warehouse-courier-province'),
};
}
async function queryLogisticsDeliveryDurationBoard(params = {}) {
const range = normalizeDateRange(params);
const aggregateRows = await cloudDb.query(
DELIVERY_AGGREGATE_SQL,
{
startDateTime: range.startDateTime,
endDateExclusive: range.endDateExclusive,
},
{ requestTimeout: REQUEST_TIMEOUT }
);
const normalizedRows = aggregateRows.map(normalizeAggregateRow);
const filteredRows = filterRows(normalizedRows, params);
const board = buildDeliveryBoard(filteredRows);
return {
...board,
filters: buildFilterOptions(normalizedRows),
detailColumns: DELIVERY_DETAIL_COLUMNS,
meta: {
startDate: range.startDate,
endDate: range.endDate,
statisticDateField: 'sign_time',
sourceTable: 'dbo.jst_order_express_details',
excludedWarehouses: DELIVERY_DURATION_EXCLUDED_WAREHOUSES,
excludedLogisticsKeywords: DELIVERY_DURATION_EXCLUDED_LOGISTICS_KEYWORDS,
calculatedAt: dayjs().format('YYYY-MM-DD HH:mm:ss'),
},
};
}
function appendInFilter(whereParts, params, column, values, prefix) {
const list = parseListParam(values);
if (list.length === 0) return;
const placeholders = list.map((value, index) => {
const key = `${prefix}${index}`;
params[key] = value;
return `@${key}`;
});
whereParts.push(`${column} IN (${placeholders.join(', ')})`);
}
function buildDetailSql(whereClause) {
return `
SET NOCOUNT ON;
WITH delivery_base AS (
SELECT
CAST(e.id AS nvarchar(64)) AS express_detail_id,
NULLIF(LTRIM(RTRIM(e.so_id)), N'') AS online_order_no,
NULLIF(LTRIM(RTRIM(e.logistic_id)), N'') AS waybill_no,
COALESCE(NULLIF(LTRIM(RTRIM(e.logistic_company_name)), N''), N'${UNKNOWN_COURIER}') AS courier_name,
NULLIF(LTRIM(RTRIM(e.platform_name)), N'') AS platform_name,
NULLIF(LTRIM(RTRIM(e.shop_name)), N'') AS shop_name,
COALESCE(NULLIF(LTRIM(RTRIM(e.warehouse_name)), N''), N'${UNKNOWN_WAREHOUSE}') AS warehouse_name,
NULLIF(LTRIM(RTRIM(e.receiver_address)), N'') AS receiver_address,
COALESCE(NULLIF(LTRIM(RTRIM(${provinceExpr.replace(/receiver_address/g, 'e.receiver_address')})), N''), N'${UNKNOWN_PROVINCE}') AS province,
e.pay_time,
e.send_time,
e.fetch_time,
e.sign_time,
CAST(DATEDIFF(SECOND, e.pay_time, e.sign_time) AS DECIMAL(20, 2)) / 3600.0 AS pay_to_sign_hours,
CAST(DATEDIFF(SECOND, e.send_time, e.sign_time) AS DECIMAL(20, 2)) / 3600.0 AS send_to_sign_hours,
CASE WHEN DATEDIFF(SECOND, e.pay_time, e.sign_time) <= 48 * 3600 THEN 1 ELSE 0 END AS timely_48
FROM dbo.jst_order_express_details e
WHERE
e.sign_time >= @startDateTime
AND e.sign_time < @endDateExclusive
AND e.pay_time IS NOT NULL
AND e.send_time IS NOT NULL
AND e.sign_time IS NOT NULL
AND e.sign_time >= e.pay_time
AND e.sign_time >= e.send_time
AND ISNULL(e.warehouse_name, N'') NOT IN (${excludedWarehousesSql})
AND (
e.logistic_company_name IS NULL
OR (
${DELIVERY_DURATION_EXCLUDED_LOGISTICS_KEYWORDS
.map((keyword) => `ISNULL(e.logistic_company_name, N'') NOT LIKE ${q(`%${keyword}%`)}`)
.join('\n AND ')}
)
)
),
numbered AS (
SELECT
ROW_NUMBER() OVER (ORDER BY sign_time ASC, express_detail_id ASC) AS __rowNumber,
COUNT_BIG(*) OVER() AS __total,
express_detail_id,
online_order_no AS onlineOrderNo,
waybill_no AS waybillNo,
courier_name AS courierName,
platform_name AS platformName,
shop_name AS shopName,
warehouse_name AS warehouseName,
province,
receiver_address AS receiverAddress,
CONVERT(varchar(19), pay_time, 120) AS payTime,
CONVERT(varchar(19), send_time, 120) AS sendTime,
CONVERT(varchar(19), fetch_time, 120) AS fetchTime,
CONVERT(varchar(19), sign_time, 120) AS signTime,
CAST(ROUND(pay_to_sign_hours, 2) AS DECIMAL(10, 2)) AS payToSignHours,
CAST(ROUND(send_to_sign_hours, 2) AS DECIMAL(10, 2)) AS sendToSignHours,
timely_48 AS timely48
FROM delivery_base
WHERE ${whereClause}
)
SELECT
page_data.__rowNumber,
page_data.__total,
COALESCE(NULLIF(LTRIM(RTRIM(afs.inner_order_id)), N''), page_data.express_detail_id) AS innerOrderId,
page_data.onlineOrderNo,
page_data.waybillNo,
page_data.courierName,
page_data.platformName,
page_data.shopName,
page_data.warehouseName,
page_data.province,
page_data.receiverAddress,
page_data.payTime,
page_data.sendTime,
page_data.fetchTime,
page_data.signTime,
page_data.payToSignHours,
page_data.sendToSignHours,
page_data.timely48
FROM numbered page_data
OUTER APPLY (
SELECT TOP 1
NULLIF(LTRIM(RTRIM(a.undefined1)), N'') AS inner_order_id
FROM dbo.jst_goods_profit_kpi_items_afs a
WHERE NULLIF(LTRIM(RTRIM(a.undefined2)), N'') = page_data.onlineOrderNo
ORDER BY TRY_CONVERT(datetime, REPLACE(a.undefined5, '/', '-')) DESC
) afs
WHERE page_data.__rowNumber BETWEEN @startRow AND @endRow
ORDER BY __rowNumber;
`;
}
async function queryLogisticsDeliveryDurationDetails(params = {}) {
const range = normalizeDateRange(params);
const page = Math.max(1, parseInt(params.page, 10) || 1);
const pageSize = Math.min(5000, Math.max(1, parseInt(params.pageSize, 10) || 20));
const startRow = ((page - 1) * pageSize) + 1;
const endRow = page * pageSize;
const queryParams = {
startDateTime: range.startDateTime,
endDateExclusive: range.endDateExclusive,
startRow,
endRow,
};
const whereParts = ['1 = 1'];
appendInFilter(whereParts, queryParams, 'warehouse_name', params.warehouses, 'warehouse');
appendInFilter(whereParts, queryParams, 'courier_name', params.couriers, 'courier');
appendInFilter(whereParts, queryParams, 'province', params.provinces, 'province');
const rows = await cloudDb.query(
buildDetailSql(whereParts.join(' AND ')),
queryParams,
{ requestTimeout: REQUEST_TIMEOUT }
);
const total = rows.length > 0 ? toNumber(rows[0].__total) : 0;
return {
columns: DELIVERY_DETAIL_COLUMNS,
rows: rows.map((row) => ({
innerOrderId: row.innerOrderId || '',
onlineOrderNo: row.onlineOrderNo || '',
waybillNo: row.waybillNo || '',
courierName: row.courierName || UNKNOWN_COURIER,
platformName: row.platformName || '',
shopName: row.shopName || '',
warehouseName: row.warehouseName || UNKNOWN_WAREHOUSE,
province: row.province || UNKNOWN_PROVINCE,
receiverAddress: row.receiverAddress || '',
payTime: row.payTime || '',
sendTime: row.sendTime || '',
fetchTime: row.fetchTime || '',
signTime: row.signTime || '',
payToSignHours: row.payToSignHours === null || row.payToSignHours === undefined ? '' : round2(row.payToSignHours),
sendToSignHours: row.sendToSignHours === null || row.sendToSignHours === undefined ? '' : round2(row.sendToSignHours),
timely48: toNumber(row.timely48) === 1 ? '是' : '否',
})),
total,
page,
pageSize,
meta: {
startDate: range.startDate,
endDate: range.endDate,
statisticDateField: 'sign_time',
sourceTable: 'dbo.jst_order_express_details',
},
};
}
module.exports = {
queryLogisticsDeliveryDurationBoard,
queryLogisticsDeliveryDurationDetails,
DELIVERY_DETAIL_COLUMNS,
};