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