514 lines
16 KiB
JavaScript
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,
|
|
};
|