cangku/backend/src/services/logisticsDamageRateDetail.js

514 lines
16 KiB
JavaScript

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