'use strict'; const dayjs = require('dayjs'); const cloudDb = require('../config/cloudDatabase'); const database = require('../config/database'); const SYNC_VERSION = 'logistics-damage-rate-v2'; const INSERT_BATCH_SIZE = 500; const QUERY_BATCH_SIZE = 400; const UNKNOWN_COURIER = '\u672a\u77e5\u7269\u6d41\u516c\u53f8'; const DAMAGE_PROCESS_RESULT = '\u5feb\u9012-\u7834\u635f'; const SHIPPING_COLUMNS = [ 'stat_date', 'waybill_no', 'so_id', 'inner_order_id', 'courier_name', 'shop_name', 'warehouse_name', 'platform_name', 'receiver_address', 'send_time', 'handover_flag', 'deduct_flag', 'change_flag', 'source_table', ]; const DAMAGE_COLUMNS = [ 'stat_date', 'waybill_no', 'source_key', 'source_table', 'as_id', 'outer_as_id', 'o_id', 'so_id', 'type', 'shop_name', 'question_type', 'process_result', 'status', 'created', 'modified', 'l_id', 'original_courier_name', 'original_send_time', 'original_shop_name', 'original_warehouse_name', ]; function normalizeDate(value, fallback = dayjs().subtract(1, 'day').format('YYYY-MM-DD')) { const parsed = dayjs(value || fallback); if (!parsed.isValid()) { throw new Error(`Invalid date: ${value}`); } return parsed.format('YYYY-MM-DD'); } function listDatesInRange(startDate, endDate) { const start = dayjs(normalizeDate(startDate)); const end = dayjs(normalizeDate(endDate || startDate)); if (end.isBefore(start, 'day')) { throw new Error(`Invalid date range: ${startDate} -> ${endDate}`); } const dates = []; let cursor = start; while (!cursor.isAfter(end, 'day')) { dates.push(cursor.format('YYYY-MM-DD')); cursor = cursor.add(1, 'day'); } return dates; } function textOrEmpty(value) { return value === null || value === undefined ? '' : String(value); } function normalizeText(value) { return textOrEmpty(value).trim(); } function toDateTimeOrNull(value) { return value || null; } function chunk(items, size) { const chunks = []; for (let index = 0; index < items.length; index += size) { chunks.push(items.slice(index, index + size)); } return chunks; } function uniqueNonEmpty(items) { return Array.from(new Set(items.map(normalizeText).filter(Boolean))); } function addToMapList(map, key, value) { if (!key) return; const list = map.get(key) || []; list.push(value); map.set(key, list); } function buildParamList(values, prefix, params) { return values.map((value, index) => { const name = `${prefix}${index}`; params[name] = value; return `@${name}`; }).join(', '); } async function insertBatches(tableName, columns, rows) { if (rows.length === 0) return; const columnSql = columns.map((column) => `\`${column}\``).join(', '); for (let index = 0; index < rows.length; index += INSERT_BATCH_SIZE) { const batch = rows.slice(index, index + INSERT_BATCH_SIZE); await database.local.query( `INSERT INTO ${tableName} (${columnSql}) VALUES ?`, [batch] ); } } async function writeSyncLog({ statDate, shippingCount, damageCount, status, errorMessage = '' }) { await database.local.query( `INSERT INTO logistics_damage_rate_sync (stat_date, sync_version, shipping_count, damage_count, status, error_message, synced_at) VALUES (?, ?, ?, ?, ?, ?, NOW()) ON DUPLICATE KEY UPDATE sync_version = VALUES(sync_version), shipping_count = VALUES(shipping_count), damage_count = VALUES(damage_count), status = VALUES(status), error_message = VALUES(error_message), synced_at = NOW()`, [statDate, SYNC_VERSION, shippingCount, damageCount, status, errorMessage.slice(0, 1000)] ); } async function fetchShippingRows(statDate) { const sql = ` WITH shipping_base AS ( SELECT CONVERT(varchar(10), CONVERT(date, e.send_time), 120) AS stat_date, NULLIF(LTRIM(RTRIM(e.logistic_id)), '') AS waybill_no, CAST(e.so_id AS nvarchar(120)) AS so_id, CAST(e.inner_order_id AS nvarchar(80)) AS inner_order_id, CAST(e.logistic_company_name AS nvarchar(120)) AS courier_name, CAST(e.shop_name AS nvarchar(255)) AS shop_name, CAST(e.warehouse_name AS nvarchar(120)) AS warehouse_name, CAST(e.platform_name AS nvarchar(80)) AS platform_name, CAST(e.receiver_address AS nvarchar(500)) AS receiver_address, CONVERT(varchar(19), e.send_time, 120) AS send_time, ROW_NUMBER() OVER ( PARTITION BY NULLIF(LTRIM(RTRIM(e.logistic_id)), '') ORDER BY e.send_time ASC, e.id ASC ) AS rn FROM dbo.jst_order_express_details e WITH (NOLOCK) WHERE e.send_time >= @statDate AND e.send_time < DATEADD(day, 1, @statDate) AND NULLIF(LTRIM(RTRIM(e.logistic_id)), '') IS NOT NULL ) SELECT stat_date, waybill_no, so_id, inner_order_id, ISNULL(NULLIF(courier_name, ''), N'${UNKNOWN_COURIER}') AS courier_name, ISNULL(shop_name, N'') AS shop_name, ISNULL(warehouse_name, N'') AS warehouse_name, ISNULL(platform_name, N'') AS platform_name, ISNULL(receiver_address, N'') AS receiver_address, send_time FROM shipping_base WHERE rn = 1 ORDER BY send_time ASC, waybill_no ASC `; return cloudDb.query(sql, { statDate }, { requestTimeout: 180000 }); } async function fetchDamageAftersalesRows(statDate) { return database.local.query( `SELECT stat_date, source_key, source_table, as_id, outer_as_id, o_id, so_id, type, shop_name, question_type, process_result, status, created, modified, l_id, original_order_no, original_courier_name, original_waybill_no FROM aftersales_refund_detail_read WHERE stat_date = ? AND process_result = ?`, [statDate, DAMAGE_PROCESS_RESULT] ); } async function fetchExpressRowsForAftersales(aftersalesRows) { const soIds = uniqueNonEmpty(aftersalesRows.map((row) => row.so_id)); const lIds = uniqueNonEmpty(aftersalesRows.map((row) => row.l_id)); const rows = []; for (const soIdBatch of chunk(soIds, QUERY_BATCH_SIZE)) { const params = {}; const inList = buildParamList(soIdBatch, 'so', params); rows.push(...await cloudDb.query( `SELECT CAST(so_id AS nvarchar(120)) AS so_id, NULLIF(LTRIM(RTRIM(logistic_id)), '') AS waybill_no, CAST(logistic_company_name AS nvarchar(120)) AS courier_name, CAST(shop_name AS nvarchar(255)) AS shop_name, CAST(warehouse_name AS nvarchar(120)) AS warehouse_name, CONVERT(varchar(19), send_time, 120) AS send_time FROM dbo.jst_order_express_details WITH (NOLOCK) WHERE so_id IN (${inList}) AND NULLIF(LTRIM(RTRIM(logistic_id)), '') IS NOT NULL`, params, { requestTimeout: 180000 } )); } for (const lIdBatch of chunk(lIds, QUERY_BATCH_SIZE)) { const params = {}; const inList = buildParamList(lIdBatch, 'lid', params); rows.push(...await cloudDb.query( `SELECT CAST(so_id AS nvarchar(120)) AS so_id, NULLIF(LTRIM(RTRIM(logistic_id)), '') AS waybill_no, CAST(logistic_company_name AS nvarchar(120)) AS courier_name, CAST(shop_name AS nvarchar(255)) AS shop_name, CAST(warehouse_name AS nvarchar(120)) AS warehouse_name, CONVERT(varchar(19), send_time, 120) AS send_time FROM dbo.jst_order_express_details WITH (NOLOCK) WHERE logistic_id IN (${inList}) AND NULLIF(LTRIM(RTRIM(logistic_id)), '') IS NOT NULL`, params, { requestTimeout: 180000 } )); } return rows; } function buildShippingInsertRows(rows) { return rows.map((row) => [ normalizeDate(row.stat_date), normalizeText(row.waybill_no), normalizeText(row.so_id), normalizeText(row.inner_order_id), normalizeText(row.courier_name) || UNKNOWN_COURIER, normalizeText(row.shop_name), normalizeText(row.warehouse_name), normalizeText(row.platform_name), normalizeText(row.receiver_address), toDateTimeOrNull(row.send_time), 1, 0, 0, 'dbo.jst_order_express_details', ]); } function buildDamageInsertRows(aftersalesRows, expressRows) { const bySoId = new Map(); const byWaybill = new Map(); for (const row of expressRows) { const item = { soId: normalizeText(row.so_id), waybillNo: normalizeText(row.waybill_no), courierName: normalizeText(row.courier_name) || UNKNOWN_COURIER, shopName: normalizeText(row.shop_name), warehouseName: normalizeText(row.warehouse_name), sendTime: row.send_time || null, }; addToMapList(bySoId, item.soId, item); addToMapList(byWaybill, item.waybillNo, item); } const insertRows = []; const seen = new Set(); for (const row of aftersalesRows) { const originalWaybillNo = normalizeText(row.original_waybill_no); const originalCourierName = normalizeText(row.original_courier_name); const lId = normalizeText(row.l_id); const soId = normalizeText(row.so_id); let matches = []; if (originalWaybillNo) { matches = (byWaybill.get(originalWaybillNo) || []).map((match) => ({ ...match, waybillNo: originalWaybillNo, courierName: originalCourierName || match.courierName || UNKNOWN_COURIER, })); } if (matches.length === 0) { matches = lId ? (byWaybill.get(lId) || []) : []; } if (matches.length === 0) { matches = bySoId.get(soId) || []; } if (matches.length === 0) { matches = [{ waybillNo: originalWaybillNo || lId, courierName: originalCourierName || UNKNOWN_COURIER, shopName: '', warehouseName: '', sendTime: null, }]; } for (const match of matches) { const waybillNo = normalizeText(match.waybillNo); const uniqueKey = [ normalizeDate(row.stat_date), normalizeText(row.source_key), normalizeText(row.as_id), soId, waybillNo, ].join('|'); if (seen.has(uniqueKey)) continue; seen.add(uniqueKey); insertRows.push([ normalizeDate(row.stat_date), waybillNo, normalizeText(row.source_key), normalizeText(row.source_table), normalizeText(row.as_id), normalizeText(row.outer_as_id), normalizeText(row.o_id), soId, normalizeText(row.type), normalizeText(row.shop_name), normalizeText(row.question_type), normalizeText(row.process_result), normalizeText(row.status), toDateTimeOrNull(row.created), toDateTimeOrNull(row.modified), lId, normalizeText(match.courierName) || UNKNOWN_COURIER, toDateTimeOrNull(match.sendTime), normalizeText(match.shopName), normalizeText(match.warehouseName), ]); } } return insertRows; } async function refreshCourierDamageSummary(statDate) { const [shippingRows, damageRows] = await Promise.all([ database.local.query( `SELECT COALESCE(NULLIF(courier_name, ''), ?) AS courier_name, COUNT(DISTINCT waybill_no) AS damage_base_count FROM logistics_damage_shipping_detail_read WHERE stat_date = ? AND waybill_no <> '' GROUP BY COALESCE(NULLIF(courier_name, ''), ?)`, [UNKNOWN_COURIER, statDate, UNKNOWN_COURIER] ), database.local.query( `SELECT COALESCE(NULLIF(original_courier_name, ''), ?) AS courier_name, COUNT(DISTINCT waybill_no) AS damage_count FROM logistics_damage_aftersales_detail_read WHERE stat_date = ? AND waybill_no <> '' GROUP BY COALESCE(NULLIF(original_courier_name, ''), ?)`, [UNKNOWN_COURIER, statDate, UNKNOWN_COURIER] ), ]); const summary = new Map(); for (const row of shippingRows) { summary.set(row.courier_name, { courierName: row.courier_name, damageBaseCount: Number(row.damage_base_count || 0), damageCount: 0, }); } for (const row of damageRows) { const courierName = row.courier_name || UNKNOWN_COURIER; const item = summary.get(courierName) || { courierName, damageBaseCount: 0, damageCount: 0, }; item.damageCount = Number(row.damage_count || 0); summary.set(courierName, item); } await database.local.query( `UPDATE summary_daily_courier SET damage_count = 0, damage_base_count = 0, damage_rate = 0 WHERE stat_date = ?`, [statDate] ); for (const item of summary.values()) { const damageRate = item.damageBaseCount > 0 ? Number(((item.damageCount / item.damageBaseCount) * 100).toFixed(2)) : 0; await database.local.query( `INSERT INTO summary_daily_courier ( stat_date, courier_name, ship_count, refund_count, refund_rate, avg_delivery_hours, pickup_timely_rate, damage_count, damage_base_count, damage_rate, bad_review_rate ) VALUES (?, ?, ?, 0, 0, 0, 0, ?, ?, ?, 0) ON DUPLICATE KEY UPDATE ship_count = IF(ship_count = 0, VALUES(ship_count), ship_count), damage_count = VALUES(damage_count), damage_base_count = VALUES(damage_base_count), damage_rate = VALUES(damage_rate)`, [ statDate, item.courierName, item.damageBaseCount, item.damageCount, item.damageBaseCount, damageRate, ] ); } return { shippingCount: shippingRows.reduce((sum, row) => sum + Number(row.damage_base_count || 0), 0), damageCount: damageRows.reduce((sum, row) => sum + Number(row.damage_count || 0), 0), }; } async function refreshLogisticsDamageRateDate(inputDate) { const statDate = normalizeDate(inputDate); console.log(`[logistics-damage-rate] sync start ${statDate}`); try { const [shippingRows, aftersalesRows] = await Promise.all([ fetchShippingRows(statDate), fetchDamageAftersalesRows(statDate), ]); const expressRows = await fetchExpressRowsForAftersales(aftersalesRows); const shippingInsertRows = buildShippingInsertRows(shippingRows); const damageInsertRows = buildDamageInsertRows(aftersalesRows, expressRows); await database.local.query('DELETE FROM logistics_damage_shipping_detail_read WHERE stat_date = ?', [statDate]); await database.local.query('DELETE FROM logistics_damage_aftersales_detail_read WHERE stat_date = ?', [statDate]); await insertBatches('logistics_damage_shipping_detail_read', SHIPPING_COLUMNS, shippingInsertRows); await insertBatches('logistics_damage_aftersales_detail_read', DAMAGE_COLUMNS, damageInsertRows); const summary = await refreshCourierDamageSummary(statDate); await writeSyncLog({ statDate, shippingCount: summary.shippingCount, damageCount: summary.damageCount, status: 'success', }); console.log( `[logistics-damage-rate] sync done ${statDate}, shipping=${summary.shippingCount}, damage=${summary.damageCount}` ); return { statDate, ...summary }; } catch (error) { await writeSyncLog({ statDate, shippingCount: 0, damageCount: 0, status: 'failed', errorMessage: error.message || String(error), }).catch((logError) => { console.warn('[logistics-damage-rate] sync log failed:', logError.message || logError); }); throw error; } } async function refreshLogisticsDamageRateRange(startDate, endDate = startDate) { const results = []; for (const statDate of listDatesInRange(startDate, endDate)) { results.push(await refreshLogisticsDamageRateDate(statDate)); } return results; } module.exports = { SYNC_VERSION, DAMAGE_PROCESS_RESULT, refreshLogisticsDamageRateDate, refreshLogisticsDamageRateRange, };