forked from lunanting/cangku
1000 lines
41 KiB
JavaScript
1000 lines
41 KiB
JavaScript
const express = require('express');
|
||
const jianSunDb = require('../config/jianSunDatabase');
|
||
|
||
const router = express.Router();
|
||
const META_CACHE_TTL_MS = 60 * 1000;
|
||
const OVERVIEW_CACHE_TTL_MS = 60 * 1000;
|
||
const HOME_SUMMARY_CACHE_TTL_MS = 10 * 60 * 1000;
|
||
const HOME_SUMMARY_STALE_TTL_MS = 24 * 60 * 60 * 1000;
|
||
const HOME_COMPENSATION_CACHE_TTL_MS = 10 * 60 * 1000;
|
||
const HOME_COMPENSATION_SAMPLE_SIZE = 10000;
|
||
const metaCache = { value: null, expiresAt: 0, promise: null };
|
||
const overviewCache = new Map();
|
||
const overviewInFlight = new Map();
|
||
const homeSummaryCache = new Map();
|
||
const homeSummaryInFlight = new Map();
|
||
const homeReviewCache = new Map();
|
||
const homeReviewInFlight = new Map();
|
||
const homeCompensationCache = new Map();
|
||
const homeCompensationSummaryCache = new Map();
|
||
const homeCompensationSummaryInFlight = new Map();
|
||
|
||
const TAB_KEYS = new Set(['overview', 'business', 'after-sales', 'customer-service', 'service-score', 'stars']);
|
||
|
||
function normalizeDate(value) {
|
||
const text = String(value || '').trim();
|
||
return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : '';
|
||
}
|
||
|
||
function addDays(value, offset) {
|
||
const date = new Date(`${value}T00:00:00Z`);
|
||
date.setUTCDate(date.getUTCDate() + offset);
|
||
return date.toISOString().slice(0, 10);
|
||
}
|
||
|
||
function inclusiveDayCount(startDate, endDate) {
|
||
const start = new Date(`${startDate}T00:00:00Z`);
|
||
const end = new Date(`${endDate}T00:00:00Z`);
|
||
return Math.floor((end.getTime() - start.getTime()) / 86400000) + 1;
|
||
}
|
||
|
||
function toNumber(value) {
|
||
const number = Number(value);
|
||
return Number.isFinite(number) ? number : 0;
|
||
}
|
||
|
||
function normalizeCompensationDate(value) {
|
||
const text = String(value || '').trim();
|
||
return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : '';
|
||
}
|
||
|
||
function compensationDate(value) {
|
||
const text = String(value || '').trim();
|
||
return /^\d{4}-\d{2}-\d{2}/.test(text) ? text.slice(0, 10) : '';
|
||
}
|
||
|
||
async function loadRecentCompensationRows(shopName = '') {
|
||
const cacheKey = shopName || '*';
|
||
const cached = homeCompensationCache.get(cacheKey);
|
||
if (cached?.rows && cached.expiresAt > Date.now()) return cached.rows;
|
||
if (cached?.promise) return cached.promise;
|
||
|
||
const promise = jianSunDb.query(
|
||
`SELECT TOP (${HOME_COMPENSATION_SAMPLE_SIZE})
|
||
id, chargeAmount, chargeReason, chargeTime, couponDetailId,
|
||
goodsId, goodsName, orderSn, shopName
|
||
FROM dbo.pdd_abnormal_compensation WITH (NOLOCK)
|
||
WHERE (@shopName = '' OR shopName = @shopName)
|
||
ORDER BY id DESC`,
|
||
{ shopName }
|
||
).then((rows) => {
|
||
const normalizedRows = rows
|
||
.map((row) => ({
|
||
id: row.id,
|
||
date: compensationDate(row.chargeTime),
|
||
amount: toNumber(row.chargeAmount),
|
||
reason: String(row.chargeReason || '').trim() || '未分类',
|
||
goodsId: String(row.goodsId || '').trim(),
|
||
goodsName: String(row.goodsName || '').trim(),
|
||
orderSn: String(row.orderSn || '').trim(),
|
||
detailId: String(row.couponDetailId || '').trim(),
|
||
shopName: String(row.shopName || '').trim(),
|
||
}))
|
||
.filter((row) => row.date);
|
||
homeCompensationCache.set(cacheKey, {
|
||
rows: normalizedRows,
|
||
expiresAt: Date.now() + HOME_COMPENSATION_CACHE_TTL_MS,
|
||
promise: null,
|
||
});
|
||
if (homeCompensationCache.size > 12) {
|
||
const firstKey = homeCompensationCache.keys().next().value;
|
||
homeCompensationCache.delete(firstKey);
|
||
}
|
||
return normalizedRows;
|
||
}).finally(() => {
|
||
const entry = homeCompensationCache.get(cacheKey);
|
||
if (entry?.promise) homeCompensationCache.delete(cacheKey);
|
||
});
|
||
homeCompensationCache.set(cacheKey, { rows: null, expiresAt: 0, promise });
|
||
return promise;
|
||
}
|
||
|
||
async function resolveCompensationDateRange(req, rows) {
|
||
const latestAvailableDate = rows.reduce((latest, row) => (row.date > latest ? row.date : latest), '');
|
||
const endDate = normalizeCompensationDate(req.query.endDate) || latestAvailableDate;
|
||
const startDate = normalizeCompensationDate(req.query.startDate) || (endDate ? addDays(endDate, -29) : '');
|
||
if (!startDate || !endDate) {
|
||
const error = new Error('赔付数据没有可用日期');
|
||
error.statusCode = 404;
|
||
throw error;
|
||
}
|
||
const dayCount = inclusiveDayCount(startDate, endDate);
|
||
if (dayCount < 1) {
|
||
const error = new Error('赔付开始日期不能晚于结束日期');
|
||
error.statusCode = 400;
|
||
throw error;
|
||
}
|
||
if (dayCount > 90) {
|
||
const error = new Error('赔付查询日期范围不能超过90天');
|
||
error.statusCode = 400;
|
||
throw error;
|
||
}
|
||
return { startDate, endDate, dayCount, latestAvailableDate };
|
||
}
|
||
|
||
function createCompensationGroup(row) {
|
||
return {
|
||
amount: 0,
|
||
recordCount: 0,
|
||
orderKeys: new Set(),
|
||
...row,
|
||
};
|
||
}
|
||
|
||
function addCompensationRecord(group, row) {
|
||
group.amount += row.amount;
|
||
group.recordCount += 1;
|
||
group.orderKeys.add(row.orderSn || row.detailId || String(row.id));
|
||
}
|
||
|
||
function queryHomeCompensationSummary(rows, range) {
|
||
const selectedRows = rows.filter((row) => row.date >= range.startDate && row.date <= range.endDate);
|
||
const orderKeys = new Set();
|
||
const goodsKeys = new Set();
|
||
const reasonGroups = new Map();
|
||
const goodsGroups = new Map();
|
||
let totalAmount = 0;
|
||
|
||
selectedRows.forEach((row) => {
|
||
const orderKey = row.orderSn || row.detailId || String(row.id);
|
||
orderKeys.add(orderKey);
|
||
if (row.goodsId) goodsKeys.add(row.goodsId);
|
||
totalAmount += row.amount;
|
||
|
||
const reasonGroup = reasonGroups.get(row.reason) || createCompensationGroup({ reason: row.reason });
|
||
addCompensationRecord(reasonGroup, row);
|
||
reasonGroups.set(row.reason, reasonGroup);
|
||
|
||
const goodsKey = row.goodsId || `unknown-${row.id}`;
|
||
const goodsGroup = goodsGroups.get(goodsKey) || createCompensationGroup({
|
||
goodsId: row.goodsId || '未识别商品ID',
|
||
goodsName: row.goodsName,
|
||
shopName: row.shopName,
|
||
});
|
||
addCompensationRecord(goodsGroup, row);
|
||
if (!goodsGroup.goodsName && row.goodsName) goodsGroup.goodsName = row.goodsName;
|
||
if (!goodsGroup.shopName && row.shopName) goodsGroup.shopName = row.shopName;
|
||
goodsGroups.set(goodsKey, goodsGroup);
|
||
});
|
||
|
||
const toGroupResult = (group) => ({
|
||
...group,
|
||
orderCount: group.orderKeys.size,
|
||
amount: Number(group.amount.toFixed(2)),
|
||
share: totalAmount > 0 ? Number((group.amount * 100 / totalAmount).toFixed(2)) : 0,
|
||
orderKeys: undefined,
|
||
});
|
||
const reasonDistribution = [...reasonGroups.values()]
|
||
.map(toGroupResult)
|
||
.sort((left, right) => right.amount - left.amount || right.orderCount - left.orderCount)
|
||
.slice(0, 6);
|
||
const highCompensationGoods = [...goodsGroups.values()]
|
||
.map(toGroupResult)
|
||
.sort((left, right) => right.amount - left.amount || right.orderCount - left.orderCount)
|
||
.slice(0, 6);
|
||
|
||
return {
|
||
...range,
|
||
sampledRecordCount: rows.length,
|
||
selectedRecordCount: selectedRows.length,
|
||
compensationCount: orderKeys.size,
|
||
recordCount: selectedRows.length,
|
||
totalAmount: Number(totalAmount.toFixed(2)),
|
||
averageAmount: orderKeys.size ? Number((totalAmount / orderKeys.size).toFixed(2)) : 0,
|
||
affectedGoodsCount: goodsKeys.size,
|
||
reasonDistribution,
|
||
highCompensationGoods,
|
||
};
|
||
}
|
||
|
||
function toNullableNumber(value) {
|
||
if (value === null || value === undefined || value === '') return null;
|
||
const number = Number(value);
|
||
return Number.isFinite(number) ? number : null;
|
||
}
|
||
|
||
function averageNullable(rows, field) {
|
||
const values = rows.map((row) => toNullableNumber(row[field])).filter((value) => value !== null);
|
||
return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : null;
|
||
}
|
||
|
||
function cleanShopName(value) {
|
||
const text = String(value || '').trim();
|
||
return text || '';
|
||
}
|
||
|
||
function shopFilterSql() {
|
||
return '(@shopName IS NULL OR @shopName = \'\' OR shopName = @shopName)';
|
||
}
|
||
|
||
async function loadMeta() {
|
||
if (metaCache.value && metaCache.expiresAt > Date.now()) return metaCache.value;
|
||
if (metaCache.promise) return metaCache.promise;
|
||
|
||
metaCache.promise = Promise.all([
|
||
jianSunDb.query(
|
||
`SELECT
|
||
shopName,
|
||
CONVERT(varchar(10), MIN(TRY_CONVERT(date, statDate)), 23) AS minDate,
|
||
CONVERT(varchar(10), MAX(TRY_CONVERT(date, statDate)), 23) AS latestDate
|
||
FROM dbo.pdd_DataNormal
|
||
WHERE shopName IS NOT NULL AND LTRIM(RTRIM(shopName)) <> ''
|
||
GROUP BY shopName
|
||
ORDER BY shopName`
|
||
),
|
||
jianSunDb.query(
|
||
`SELECT
|
||
CONVERT(varchar(10), MIN(TRY_CONVERT(date, statDate)), 23) AS minDate,
|
||
CONVERT(varchar(10), MAX(TRY_CONVERT(date, statDate)), 23) AS latestDate
|
||
FROM dbo.pdd_DataNormal
|
||
WHERE statDate IS NOT NULL`
|
||
),
|
||
]).then(([shopRows, dateRows]) => {
|
||
const shops = shopRows
|
||
.map((row) => ({
|
||
name: cleanShopName(row.shopName),
|
||
minDate: normalizeDate(row.minDate),
|
||
latestDate: normalizeDate(row.latestDate),
|
||
}))
|
||
.filter((row) => row.name);
|
||
const globalDate = dateRows[0] || {};
|
||
const data = {
|
||
shops,
|
||
minDate: normalizeDate(globalDate.minDate),
|
||
latestDate: normalizeDate(globalDate.latestDate),
|
||
};
|
||
metaCache.value = data;
|
||
metaCache.expiresAt = Date.now() + META_CACHE_TTL_MS;
|
||
return data;
|
||
}).finally(() => {
|
||
metaCache.promise = null;
|
||
});
|
||
|
||
return metaCache.promise;
|
||
}
|
||
|
||
async function resolveContext(req) {
|
||
const meta = await loadMeta();
|
||
const shopName = cleanShopName(req.query.shopName);
|
||
const shop = shopName ? meta.shops.find((item) => item.name === shopName) : null;
|
||
const requestedDate = normalizeDate(req.query.date);
|
||
const date = requestedDate || shop?.latestDate || meta.latestDate;
|
||
return { meta, shopName, date };
|
||
}
|
||
|
||
async function queryLatestRows({ table, dateColumn, fields, targetDate, shopName }) {
|
||
const rows = await jianSunDb.query(
|
||
`WITH ranked AS (
|
||
SELECT ${fields},
|
||
ROW_NUMBER() OVER (PARTITION BY shopName ORDER BY id DESC) AS rowNumber
|
||
FROM dbo.${table}
|
||
WHERE ${shopFilterSql()}
|
||
AND [${dateColumn}] = @targetDate
|
||
)
|
||
SELECT * FROM ranked WHERE rowNumber = 1`,
|
||
{ targetDate, shopName: shopName || null }
|
||
);
|
||
return rows;
|
||
}
|
||
|
||
async function queryBusinessSnapshot(targetDate, shopName) {
|
||
const rows = await queryLatestRows({
|
||
table: 'pdd_DataNormal',
|
||
dateColumn: 'statDate',
|
||
targetDate,
|
||
shopName,
|
||
fields: '[id], shopName, statDate, cfmOrdrAmt, payOrdrAup, payUvRto',
|
||
});
|
||
const sales = rows.reduce((sum, row) => sum + toNumber(row.cfmOrdrAmt), 0);
|
||
const averageConversion = averageNullable(rows, 'payUvRto');
|
||
const count = rows.length;
|
||
return {
|
||
shopCount: count,
|
||
sales,
|
||
averageAov: averageNullable(rows, 'payOrdrAup'),
|
||
averageConversion: averageConversion === null ? null : averageConversion * 100,
|
||
rows,
|
||
};
|
||
}
|
||
|
||
async function queryAfterSalesSnapshot(targetDate, shopName) {
|
||
const rows = await queryLatestRows({
|
||
table: 'pdd_ShopNormalOverflow',
|
||
dateColumn: 'createTime',
|
||
targetDate,
|
||
shopName,
|
||
fields: '[id], shopName, createTime, executedCount, afterSalesCount, waitSellerHandle, platformHandling, waitBuyerHandle, todosCount, todosEndedCount, cfmOrdrCnt',
|
||
});
|
||
const fields = ['executedCount', 'afterSalesCount', 'waitSellerHandle', 'platformHandling', 'waitBuyerHandle', 'todosCount', 'todosEndedCount', 'cfmOrdrCnt'];
|
||
const data = fields.reduce((result, field) => ({ ...result, [field]: rows.reduce((sum, row) => sum + toNumber(row[field]), 0) }), {});
|
||
return { ...data, shopCount: rows.length, rows };
|
||
}
|
||
|
||
async function queryCustomerServiceSnapshot(targetDate, shopName) {
|
||
const [refundRows, receptionRows] = await Promise.all([
|
||
queryLatestRows({
|
||
table: 'pdd_DataCustomerService',
|
||
dateColumn: 'createTime',
|
||
targetDate,
|
||
shopName,
|
||
fields: '[id], shopName, createTime, dsptRfSucOrdrCnt1m, dsptRfSucRto1m, pltInvlOrdrCnt1m, pltInvlOrdrRto1m, qurfOrdRto1m, avgSucRfProcTime1m, ssslAvgSucRfProcTime1mPct, sucRfOrdrCnt1d, sucRfOrdrAmt1d, rfSucRto1m, avgSlfSucRfProcTime1mMgr, avgSlfSucRfProcTime1mMr',
|
||
}),
|
||
jianSunDb.query(
|
||
`WITH ranked AS (
|
||
SELECT *, ROW_NUMBER() OVER (PARTITION BY shopName, cs_name ORDER BY id DESC) AS rowNumber
|
||
FROM dbo.pdd_customer_overflow
|
||
WHERE ${shopFilterSql()}
|
||
AND [createTime] = @targetDate
|
||
)
|
||
SELECT
|
||
SUM(ISNULL(consult_user_cnt, 0)) AS consultUserCount,
|
||
SUM(ISNULL(inquiry_user, 0)) AS inquiryUserCount,
|
||
SUM(ISNULL(final_group_user, 0)) AS finalGroupUserCount,
|
||
SUM(ISNULL(cs_sales_amount, 0)) AS csSalesAmount,
|
||
SUM(ISNULL(need_manu_reply_consult_user_cnt, 0)) AS needManualReplyCount,
|
||
SUM(ISNULL(receive_user_cnt, 0)) AS receiveUserCount,
|
||
SUM(ISNULL(delay_reply, 0)) AS delayReplyCount,
|
||
AVG(TRY_CONVERT(decimal(18, 8), reply_rate_3_min)) AS replyRate3Min,
|
||
AVG(TRY_CONVERT(decimal(18, 8), reply_30s_rate)) AS replyRate30s,
|
||
SUM(ISNULL(unsatisfied_serve_score_cnt, 0)) AS unsatisfiedScoreCount,
|
||
SUM(ISNULL(complain_user_cnt, 0)) AS complainUserCount,
|
||
AVG(TRY_CONVERT(decimal(18, 8), avg_reply_time)) AS averageReplyTime,
|
||
SUM(ISNULL(serve_refund_cnt, 0)) AS serviceRefundCount,
|
||
COUNT(DISTINCT cs_name) AS customerServiceCount
|
||
FROM ranked
|
||
WHERE rowNumber = 1 AND cs_name = N'店铺总计'`,
|
||
{ targetDate, shopName: shopName || null }
|
||
),
|
||
]);
|
||
|
||
const refundFields = [
|
||
'dsptRfSucOrdrCnt1m', 'pltInvlOrdrCnt1m', 'sucRfOrdrCnt1d', 'sucRfOrdrAmt1d',
|
||
];
|
||
const refundTotals = refundFields.reduce((result, field) => ({
|
||
...result,
|
||
[field]: refundRows.reduce((sum, row) => sum + toNumber(row[field]), 0),
|
||
}), {});
|
||
const averageFields = [
|
||
'dsptRfSucRto1m', 'pltInvlOrdrRto1m', 'qurfOrdRto1m', 'avgSucRfProcTime1m',
|
||
'ssslAvgSucRfProcTime1mPct', 'rfSucRto1m', 'avgSlfSucRfProcTime1mMgr', 'avgSlfSucRfProcTime1mMr',
|
||
];
|
||
const averages = averageFields.reduce((result, field) => ({
|
||
...result,
|
||
[field]: averageNullable(refundRows, field),
|
||
}), {});
|
||
return {
|
||
...refundTotals,
|
||
...averages,
|
||
shopCount: refundRows.length,
|
||
reception: receptionRows[0] || {},
|
||
rows: refundRows,
|
||
};
|
||
}
|
||
|
||
async function queryServiceScoreSnapshot(targetDate, shopName) {
|
||
const rows = await queryLatestRows({
|
||
table: 'pdd_MallServiceScore',
|
||
dateColumn: 'createTime',
|
||
targetDate,
|
||
shopName,
|
||
fields: '[id], shopName, createTime, cstmrServScore, attiLmScore, jcfwLmScore, spLmScore, fhLmScore, wlLmScore',
|
||
});
|
||
const fields = ['cstmrServScore', 'attiLmScore', 'jcfwLmScore', 'spLmScore', 'fhLmScore', 'wlLmScore'];
|
||
const values = fields.reduce((result, field) => ({
|
||
...result,
|
||
[field]: averageNullable(rows, field),
|
||
}), {});
|
||
return { ...values, shopCount: rows.length, rows };
|
||
}
|
||
|
||
async function queryStarsSnapshot(targetDate, shopName) {
|
||
const rows = await queryLatestRows({
|
||
table: 'pdd_ShopStars',
|
||
dateColumn: 'createTime',
|
||
targetDate,
|
||
shopName,
|
||
fields: '[id], shopName, createTime, mallStarTomms, mallStarTommsChange, mallStarDesc, ptHelpRate1m, aprsIn3minRplyUsrRto1m, rfSignTime1m, rfProcTime1m, dsr3m, posTypeRto1m, seriousInferiorRto1m, adjNfkAvgCfmSignTime1m, lgstMergeFine1m, gmvRk, ptHelpRate1mNextStarRkDesc, ptHelpRate1mNextPctThreshold, aprsIn3minRplyUsrRto1mNextStarRkDesc, aprsIn3minRplyUsrRto1mNextPctThreshold, rfSignTime1mNextStarRkDesc, rfSignTime1mNextPctThreshold, rfProcTime1mNextStarRkDesc, rfProcTime1mNextPctThreshold, dsr3mNextStarRkDesc, dsr3mNextPctThreshold, posTypeRto1mNextStarRkDesc, posTypeRto1mNextPctThreshold, seriousInferiorRto1mNextStarRkDesc, seriousInferiorRto1mNextPctThreshold, adjNfkAvgCfmSignTime1mNextStarRkDesc, adjNfkAvgCfmSignTime1mNextPctThreshold, lgstMergeFine1mNextStarRkDesc, lgstMergeFine1mNextPctThreshold, gmvRkNextStarRkDesc, gmvRkNextPctThreshold, scoreRk',
|
||
});
|
||
const averageFields = ['mallStarTomms', 'mallStarTommsChange', 'ptHelpRate1m', 'aprsIn3minRplyUsrRto1m', 'rfSignTime1m', 'rfProcTime1m', 'dsr3m', 'posTypeRto1m', 'seriousInferiorRto1m', 'adjNfkAvgCfmSignTime1m', 'lgstMergeFine1m', 'gmvRk'];
|
||
const values = averageFields.reduce((result, field) => ({
|
||
...result,
|
||
[field]: averageNullable(rows, field),
|
||
}), {});
|
||
const first = rows[0] || {};
|
||
const targetFields = [
|
||
['ptHelpRate1m', '平台求助率', 'ptHelpRate1mNextStarRkDesc', 'ptHelpRate1mNextPctThreshold'],
|
||
['aprsIn3minRplyUsrRto1m', '3分钟人工回复率', 'aprsIn3minRplyUsrRto1mNextStarRkDesc', 'aprsIn3minRplyUsrRto1mNextPctThreshold'],
|
||
['rfSignTime1m', '在途订单退款时长', 'rfSignTime1mNextStarRkDesc', 'rfSignTime1mNextPctThreshold'],
|
||
['rfProcTime1m', '签收退货后退款时长', 'rfProcTime1mNextStarRkDesc', 'rfProcTime1mNextPctThreshold'],
|
||
['dsr3m', '90天用户评价得分', 'dsr3mNextStarRkDesc', 'dsr3mNextPctThreshold'],
|
||
['posTypeRto1m', '积极评论率', 'posTypeRto1mNextStarRkDesc', 'posTypeRto1mNextPctThreshold'],
|
||
['seriousInferiorRto1m', '严重劣质率', 'seriousInferiorRto1mNextStarRkDesc', 'seriousInferiorRto1mNextPctThreshold'],
|
||
['adjNfkAvgCfmSignTime1m', '成团到签收时效', 'adjNfkAvgCfmSignTime1mNextStarRkDesc', 'adjNfkAvgCfmSignTime1mNextPctThreshold'],
|
||
['lgstMergeFine1m', '物流综合处罚率', 'lgstMergeFine1mNextStarRkDesc', 'lgstMergeFine1mNextPctThreshold'],
|
||
['gmvRk', '店铺活跃度', 'gmvRkNextStarRkDesc', 'gmvRkNextPctThreshold'],
|
||
];
|
||
const targets = shopName ? targetFields.map(([field, label, statusField, thresholdField]) => ({
|
||
field,
|
||
label,
|
||
value: toNullableNumber(first[field]),
|
||
status: first[statusField] || null,
|
||
threshold: toNullableNumber(first[thresholdField]),
|
||
})) : [];
|
||
const targetFailureCount = rows.reduce((count, row) => count + targetFields.filter(([, , statusField]) => includesFailure(row[statusField])).length, 0);
|
||
return { ...values, shopCount: rows.length, targets, targetFailureCount, rows };
|
||
}
|
||
|
||
function includesFailure(value) {
|
||
const text = String(value || '').trim();
|
||
return text.includes('不达标') || text.includes('未达标') || text.includes('下降');
|
||
}
|
||
|
||
function buildIssues({ afterSales, customerService, stars }) {
|
||
const issues = [];
|
||
const addIssue = (key, tone, title, value, description, source) => {
|
||
if (toNumber(value) > 0 || includesFailure(value)) {
|
||
issues.push({ key, tone, title, value, description, source });
|
||
}
|
||
};
|
||
addIssue('wait-seller-handle', 'error', '待商家处理售后单', afterSales.waitSellerHandle, '需要优先处理,避免售后时效继续积压', '店铺售后');
|
||
addIssue('platform-handling', 'warning', '平台处理中售后单', afterSales.platformHandling, '平台介入中的售后订单需要关注处理结果', '店铺售后');
|
||
addIssue('delay-reply', 'warning', '三分钟未回复咨询', customerService.reception.delayReplyCount, '客服响应存在延迟', '客服接待');
|
||
addIssue('complaint', 'error', '客服投诉数', customerService.reception.complainUserCount, '存在客户投诉,需要回溯客服接待记录', '客服接待');
|
||
addIssue('star-target', 'warning', '星级指标存在未达标项', stars.targetFailureCount, '可在“星级提升”中查看具体指标和下一星级标准', '店铺星级');
|
||
return issues;
|
||
}
|
||
|
||
async function queryOverview(context) {
|
||
const { shopName, date } = context;
|
||
const [business, afterSales, customerService, serviceScore, stars] = await Promise.all([
|
||
queryBusinessSnapshot(date, shopName),
|
||
queryAfterSalesSnapshot(date, shopName),
|
||
queryCustomerServiceSnapshot(date, shopName),
|
||
queryServiceScoreSnapshot(date, shopName),
|
||
queryStarsSnapshot(date, shopName),
|
||
]);
|
||
const reception = customerService.reception || {};
|
||
return {
|
||
date,
|
||
shopName: shopName || '全部店铺',
|
||
shopCount: business.shopCount,
|
||
business: {
|
||
sales: business.sales,
|
||
averageAov: business.averageAov,
|
||
averageConversion: business.averageConversion,
|
||
},
|
||
afterSales: {
|
||
afterSalesCount: afterSales.afterSalesCount,
|
||
executedCount: afterSales.executedCount,
|
||
waitSellerHandle: afterSales.waitSellerHandle,
|
||
platformHandling: afterSales.platformHandling,
|
||
waitBuyerHandle: afterSales.waitBuyerHandle,
|
||
todosCount: afterSales.todosCount,
|
||
todosEndedCount: afterSales.todosEndedCount,
|
||
cfmOrdrCnt: afterSales.cfmOrdrCnt,
|
||
},
|
||
customerService: {
|
||
...customerService,
|
||
reception: {
|
||
consultUserCount: toNumber(reception.consultUserCount),
|
||
inquiryUserCount: toNumber(reception.inquiryUserCount),
|
||
finalGroupUserCount: toNumber(reception.finalGroupUserCount),
|
||
csSalesAmount: toNumber(reception.csSalesAmount),
|
||
needManualReplyCount: toNumber(reception.needManualReplyCount),
|
||
receiveUserCount: toNumber(reception.receiveUserCount),
|
||
delayReplyCount: toNumber(reception.delayReplyCount),
|
||
replyRate3Min: toNullableNumber(reception.replyRate3Min),
|
||
replyRate30s: toNullableNumber(reception.replyRate30s),
|
||
unsatisfiedScoreCount: toNumber(reception.unsatisfiedScoreCount),
|
||
complainUserCount: toNumber(reception.complainUserCount),
|
||
averageReplyTime: toNullableNumber(reception.averageReplyTime),
|
||
serviceRefundCount: toNumber(reception.serviceRefundCount),
|
||
customerServiceCount: toNumber(reception.customerServiceCount),
|
||
},
|
||
},
|
||
serviceScore: {
|
||
cstmrServScore: serviceScore.cstmrServScore,
|
||
attiLmScore: serviceScore.attiLmScore,
|
||
jcfwLmScore: serviceScore.jcfwLmScore,
|
||
spLmScore: serviceScore.spLmScore,
|
||
fhLmScore: serviceScore.fhLmScore,
|
||
wlLmScore: serviceScore.wlLmScore,
|
||
},
|
||
stars: {
|
||
mallStarTomms: stars.mallStarTomms,
|
||
mallStarTommsChange: stars.mallStarTommsChange,
|
||
dsr3m: stars.dsr3m,
|
||
ptHelpRate1m: stars.ptHelpRate1m,
|
||
aprsIn3minRplyUsrRto1m: stars.aprsIn3minRplyUsrRto1m,
|
||
targets: stars.targets,
|
||
},
|
||
issues: buildIssues({ afterSales, customerService, stars }),
|
||
coverage: {
|
||
business: business.shopCount > 0,
|
||
afterSales: afterSales.shopCount > 0,
|
||
customerService: customerService.shopCount > 0 || Boolean(reception.customerServiceCount),
|
||
serviceScore: serviceScore.shopCount > 0,
|
||
stars: stars.shopCount > 0,
|
||
},
|
||
};
|
||
}
|
||
|
||
async function queryHomeSummary(context) {
|
||
const { shopName, date } = context;
|
||
const [afterSales, customerService, serviceScore, stars] = await Promise.all([
|
||
queryAfterSalesSnapshot(date, shopName),
|
||
queryCustomerServiceSnapshot(date, shopName),
|
||
queryServiceScoreSnapshot(date, shopName),
|
||
queryStarsSnapshot(date, shopName),
|
||
]);
|
||
const reception = customerService.reception || {};
|
||
const riskItems = [
|
||
{
|
||
key: 'waitSellerHandle',
|
||
label: '待商家处理',
|
||
value: toNumber(afterSales.waitSellerHandle),
|
||
tone: 'error',
|
||
},
|
||
{
|
||
key: 'delayReplyCount',
|
||
label: '三分钟未回复',
|
||
value: toNumber(reception.delayReplyCount),
|
||
tone: 'warning',
|
||
},
|
||
{
|
||
key: 'complainUserCount',
|
||
label: '客服投诉',
|
||
value: toNumber(reception.complainUserCount),
|
||
tone: 'error',
|
||
},
|
||
{
|
||
key: 'starTargetFailure',
|
||
label: '星级未达标项',
|
||
value: toNumber(stars.targetFailureCount),
|
||
tone: 'warning',
|
||
},
|
||
];
|
||
const riskSignalCount = riskItems.reduce((sum, item) => sum + item.value, 0);
|
||
const riskTypeCount = riskItems.filter((item) => item.value > 0).length;
|
||
const coverageModules = [
|
||
afterSales.shopCount > 0,
|
||
customerService.shopCount > 0 || Boolean(reception.customerServiceCount),
|
||
serviceScore.shopCount > 0,
|
||
stars.shopCount > 0,
|
||
].filter(Boolean).length;
|
||
|
||
return {
|
||
date,
|
||
shopName: shopName || '全部店铺',
|
||
shopCount: Math.max(
|
||
toNumber(afterSales.shopCount),
|
||
toNumber(customerService.shopCount),
|
||
toNumber(serviceScore.shopCount),
|
||
toNumber(stars.shopCount)
|
||
),
|
||
totalShopCount: shopName ? 1 : context.meta.shops.length,
|
||
coverageModules,
|
||
totalModules: 4,
|
||
status: riskSignalCount > 0 ? 'attention' : 'healthy',
|
||
riskSignalCount,
|
||
riskTypeCount,
|
||
averageStar: toNullableNumber(stars.mallStarTomms),
|
||
starChange: toNullableNumber(stars.mallStarTommsChange),
|
||
evaluationScore: toNullableNumber(stars.dsr3m),
|
||
riskItems,
|
||
serviceScores: [
|
||
{ key: 'cstmrServScore', label: '服务体验', value: toNullableNumber(serviceScore.cstmrServScore) },
|
||
{ key: 'spLmScore', label: '商品服务', value: toNullableNumber(serviceScore.spLmScore) },
|
||
{ key: 'wlLmScore', label: '物流服务', value: toNullableNumber(serviceScore.wlLmScore) },
|
||
{ key: 'fhLmScore', label: '发货服务', value: toNullableNumber(serviceScore.fhLmScore) },
|
||
],
|
||
};
|
||
}
|
||
|
||
async function resolveReviewDateRange(req) {
|
||
const shopName = cleanShopName(req.query.shopName);
|
||
let endDate = normalizeDate(req.query.endDate);
|
||
if (!endDate) {
|
||
const rows = await jianSunDb.query(
|
||
`SELECT TOP (1) LEFT(createTime, 10) AS latestDate
|
||
FROM dbo.pdd_Evaluation WITH (NOLOCK)
|
||
WHERE createTime >= '2000-01-01'
|
||
AND createTime < '2100-01-01'
|
||
AND descScore BETWEEN 1 AND 5
|
||
AND (@shopName = '' OR shopName = @shopName)
|
||
ORDER BY createTime DESC`,
|
||
{ shopName }
|
||
);
|
||
endDate = normalizeDate(rows[0]?.latestDate);
|
||
}
|
||
const startDate = normalizeDate(req.query.startDate) || (endDate ? addDays(endDate, -29) : '');
|
||
if (!startDate || !endDate) {
|
||
const error = new Error('评价数据没有可用日期');
|
||
error.statusCode = 404;
|
||
throw error;
|
||
}
|
||
const dayCount = inclusiveDayCount(startDate, endDate);
|
||
if (dayCount < 1) {
|
||
const error = new Error('评价开始日期不能晚于结束日期');
|
||
error.statusCode = 400;
|
||
throw error;
|
||
}
|
||
if (dayCount > 90) {
|
||
const error = new Error('评价查询日期范围不能超过90天');
|
||
error.statusCode = 400;
|
||
throw error;
|
||
}
|
||
return { startDate, endDate, dayCount, shopName };
|
||
}
|
||
|
||
async function queryHomeReviewSummary(range) {
|
||
const params = {
|
||
startDate: range.startDate,
|
||
endDateExclusive: addDays(range.endDate, 1),
|
||
shopName: range.shopName || '',
|
||
};
|
||
const [summaryRows, keywordRows] = await Promise.all([
|
||
jianSunDb.query(
|
||
`SELECT
|
||
COUNT_BIG(1) AS totalReviewCount,
|
||
SUM(CASE WHEN descScore IN (4, 5) THEN 1 ELSE 0 END) AS goodReviewCount,
|
||
SUM(CASE WHEN descScore IN (1, 2, 3) THEN 1 ELSE 0 END) AS badReviewCount,
|
||
SUM(CASE WHEN descScore = 1 THEN 1 ELSE 0 END) AS score1Count,
|
||
SUM(CASE WHEN descScore = 2 THEN 1 ELSE 0 END) AS score2Count,
|
||
SUM(CASE WHEN descScore = 3 THEN 1 ELSE 0 END) AS score3Count,
|
||
SUM(CASE WHEN descScore = 4 THEN 1 ELSE 0 END) AS score4Count,
|
||
SUM(CASE WHEN descScore = 5 THEN 1 ELSE 0 END) AS score5Count,
|
||
AVG(TRY_CONVERT(decimal(18, 6), descScore)) AS averageScore,
|
||
COUNT(DISTINCT goodsId) AS reviewedGoodsCount,
|
||
COUNT(DISTINCT NULLIF(LTRIM(RTRIM(shopName)), '')) AS reviewedShopCount
|
||
FROM dbo.pdd_Evaluation WITH (NOLOCK)
|
||
WHERE createTime >= @startDate
|
||
AND createTime < @endDateExclusive
|
||
AND descScore BETWEEN 1 AND 5
|
||
AND (@shopName = '' OR shopName = @shopName)`,
|
||
params
|
||
),
|
||
jianSunDb.query(
|
||
`SELECT keywords, COUNT_BIG(1) AS reviewCount
|
||
FROM dbo.pdd_Evaluation WITH (NOLOCK)
|
||
WHERE createTime >= @startDate
|
||
AND createTime < @endDateExclusive
|
||
AND descScore BETWEEN 1 AND 3
|
||
AND (@shopName = '' OR shopName = @shopName)
|
||
AND NULLIF(LTRIM(RTRIM(keywords)), '') IS NOT NULL
|
||
GROUP BY keywords`,
|
||
params
|
||
),
|
||
]);
|
||
const row = summaryRows[0] || {};
|
||
const totalReviewCount = toNumber(row.totalReviewCount);
|
||
const goodReviewCount = toNumber(row.goodReviewCount);
|
||
const badReviewCount = toNumber(row.badReviewCount);
|
||
const scoreCounts = [1, 2, 3, 4, 5].map((score) => ({
|
||
score,
|
||
count: toNumber(row[`score${score}Count`]),
|
||
}));
|
||
const negativeKeywordPattern = /差|不符|异味|瑕疵|破损|低|少发|错发|不好|问题|慢|难/;
|
||
const badKeywordCounts = new Map();
|
||
keywordRows.forEach((item) => {
|
||
const weight = toNumber(item.reviewCount);
|
||
String(item.keywords || '').split(/[,,]/).forEach((value) => {
|
||
const keyword = value.trim();
|
||
if (!keyword || !negativeKeywordPattern.test(keyword)) return;
|
||
badKeywordCounts.set(keyword, (badKeywordCounts.get(keyword) || 0) + weight);
|
||
});
|
||
});
|
||
const badKeywords = [...badKeywordCounts.entries()]
|
||
.map(([keyword, count]) => ({ keyword, count }))
|
||
.sort((left, right) => right.count - left.count || left.keyword.localeCompare(right.keyword, 'zh-CN'))
|
||
.slice(0, 5);
|
||
|
||
return {
|
||
...range,
|
||
totalReviewCount,
|
||
goodReviewCount,
|
||
goodReviewRate: totalReviewCount > 0 ? goodReviewCount * 100 / totalReviewCount : 0,
|
||
badReviewCount,
|
||
badReviewRate: totalReviewCount > 0 ? badReviewCount * 100 / totalReviewCount : 0,
|
||
averageScore: toNullableNumber(row.averageScore),
|
||
reviewedGoodsCount: toNumber(row.reviewedGoodsCount),
|
||
reviewedShopCount: toNumber(row.reviewedShopCount),
|
||
distribution: scoreCounts.map((item) => ({
|
||
...item,
|
||
rate: totalReviewCount > 0 ? item.count * 100 / totalReviewCount : 0,
|
||
})),
|
||
badKeywords,
|
||
};
|
||
}
|
||
|
||
async function queryBusinessTab(context) {
|
||
const startDate = addDays(context.date, -29);
|
||
const rows = await jianSunDb.query(
|
||
`WITH ranked AS (
|
||
SELECT
|
||
statDate,
|
||
shopName, cfmOrdrAmt, payOrdrAup, payUvRto,
|
||
ROW_NUMBER() OVER (PARTITION BY shopName, statDate ORDER BY id DESC) AS rowNumber
|
||
FROM dbo.pdd_DataNormal
|
||
WHERE ${shopFilterSql()}
|
||
AND statDate BETWEEN @startDate AND @targetDate
|
||
)
|
||
SELECT statDate,
|
||
SUM(TRY_CONVERT(decimal(38, 6), cfmOrdrAmt)) AS sales,
|
||
AVG(TRY_CONVERT(decimal(38, 6), payOrdrAup)) AS averageAov,
|
||
AVG(TRY_CONVERT(decimal(38, 8), payUvRto)) AS averageConversion,
|
||
COUNT(*) AS shopCount
|
||
FROM ranked
|
||
WHERE rowNumber = 1
|
||
GROUP BY statDate
|
||
ORDER BY statDate`,
|
||
{ startDate, targetDate: context.date, shopName: context.shopName || '' }
|
||
);
|
||
return {
|
||
status: rows.length ? 'ready' : 'empty',
|
||
metrics: [
|
||
{ key: 'sales', label: '成交额', value: rows.reduce((sum, row) => sum + toNumber(row.sales), 0), unit: 'currency' },
|
||
{ key: 'averageAov', label: '平均支付客单价', value: rows.length ? toNumber(rows.at(-1).averageAov) : null, unit: 'currency' },
|
||
{ key: 'averageConversion', label: '最新支付转化率', value: rows.length ? toNumber(rows.at(-1).averageConversion) * 100 : null, unit: 'percent' },
|
||
],
|
||
trend: rows.map((row) => ({ date: row.statDate, sales: toNumber(row.sales), averageAov: toNumber(row.averageAov), averageConversion: toNumber(row.averageConversion) * 100 })),
|
||
};
|
||
}
|
||
|
||
async function queryAfterSalesTab(context) {
|
||
const rows = await queryLatestRows({
|
||
table: 'pdd_ShopNormalOverflow',
|
||
dateColumn: 'createTime',
|
||
targetDate: context.date,
|
||
shopName: context.shopName,
|
||
fields: '[id], shopName, createTime, executedCount, afterSalesCount, waitSellerHandle, platformHandling, waitBuyerHandle, todosCount, todosEndedCount, cfmOrdrCnt',
|
||
});
|
||
return {
|
||
status: rows.length ? 'ready' : 'empty',
|
||
rows,
|
||
columns: [
|
||
{ key: 'shopName', title: '店铺' }, { key: 'afterSalesCount', title: '售后总量' }, { key: 'executedCount', title: '已处理' },
|
||
{ key: 'waitSellerHandle', title: '待商家处理' }, { key: 'platformHandling', title: '平台处理中' }, { key: 'waitBuyerHandle', title: '待买家处理' },
|
||
{ key: 'todosCount', title: '工单总量' }, { key: 'todosEndedCount', title: '已完结工单' },
|
||
],
|
||
};
|
||
}
|
||
|
||
async function queryCustomerServiceTab(context) {
|
||
const rows = await jianSunDb.query(
|
||
`WITH ranked AS (
|
||
SELECT *, ROW_NUMBER() OVER (PARTITION BY shopName, cs_name ORDER BY id DESC) AS rowNumber
|
||
FROM dbo.pdd_customer_overflow
|
||
WHERE ${shopFilterSql()}
|
||
AND [createTime] = @targetDate
|
||
)
|
||
SELECT
|
||
cs_name AS csName,
|
||
SUM(ISNULL(consult_user_cnt, 0)) AS consultUserCount,
|
||
SUM(ISNULL(inquiry_user, 0)) AS inquiryUserCount,
|
||
SUM(ISNULL(final_group_user, 0)) AS finalGroupUserCount,
|
||
SUM(ISNULL(cs_sales_amount, 0)) AS csSalesAmount,
|
||
SUM(ISNULL(receive_user_cnt, 0)) AS receiveUserCount,
|
||
SUM(ISNULL(delay_reply, 0)) AS delayReplyCount,
|
||
AVG(TRY_CONVERT(decimal(18, 8), reply_rate_3_min)) AS replyRate3Min,
|
||
AVG(TRY_CONVERT(decimal(18, 8), reply_30s_rate)) AS replyRate30s,
|
||
SUM(ISNULL(complain_user_cnt, 0)) AS complainUserCount
|
||
FROM ranked
|
||
WHERE rowNumber = 1 AND ISNULL(cs_name, '') <> N'店铺总计'
|
||
GROUP BY cs_name
|
||
ORDER BY SUM(ISNULL(cs_sales_amount, 0)) DESC`,
|
||
{ targetDate: context.date, shopName: context.shopName || '' }
|
||
);
|
||
return {
|
||
status: rows.length ? 'ready' : 'empty',
|
||
rows,
|
||
columns: [
|
||
{ key: 'csName', title: '客服账号' }, { key: 'consultUserCount', title: '咨询人数' }, { key: 'inquiryUserCount', title: '询单人数' },
|
||
{ key: 'finalGroupUserCount', title: '成团人数' }, { key: 'csSalesAmount', title: '客服销售额' }, { key: 'receiveUserCount', title: '人工接待' },
|
||
{ key: 'replyRate30s', title: '30秒应答率' }, { key: 'replyRate3Min', title: '3分钟回复率' }, { key: 'complainUserCount', title: '投诉数' },
|
||
],
|
||
};
|
||
}
|
||
|
||
async function queryServiceScoreTab(context) {
|
||
const snapshot = await queryServiceScoreSnapshot(context.date, context.shopName);
|
||
return {
|
||
status: snapshot.shopCount ? 'ready' : 'empty',
|
||
metrics: [
|
||
{ key: 'cstmrServScore', label: '消费者服务体验分', value: snapshot.cstmrServScore, unit: 'score' },
|
||
{ key: 'attiLmScore', label: '服务态度', value: snapshot.attiLmScore, unit: 'score' },
|
||
{ key: 'jcfwLmScore', label: '基础服务', value: snapshot.jcfwLmScore, unit: 'score' },
|
||
{ key: 'spLmScore', label: '商品服务', value: snapshot.spLmScore, unit: 'score' },
|
||
{ key: 'fhLmScore', label: '发货服务', value: snapshot.fhLmScore, unit: 'score' },
|
||
{ key: 'wlLmScore', label: '物流服务', value: snapshot.wlLmScore, unit: 'score' },
|
||
],
|
||
rows: snapshot.rows,
|
||
columns: [
|
||
{ key: 'shopName', title: '店铺' }, { key: 'cstmrServScore', title: '消费者服务体验分' }, { key: 'attiLmScore', title: '服务态度' },
|
||
{ key: 'jcfwLmScore', title: '基础服务' }, { key: 'spLmScore', title: '商品服务' }, { key: 'fhLmScore', title: '发货服务' }, { key: 'wlLmScore', title: '物流服务' },
|
||
],
|
||
};
|
||
}
|
||
|
||
async function queryStarsTab(context) {
|
||
const snapshot = await queryStarsSnapshot(context.date, context.shopName);
|
||
return {
|
||
status: snapshot.shopCount ? 'ready' : 'empty',
|
||
metrics: [
|
||
{ key: 'mallStarTomms', label: '综合体验星级', value: snapshot.mallStarTomms, unit: 'star' },
|
||
{ key: 'mallStarTommsChange', label: '星级变化', value: snapshot.mallStarTommsChange, unit: 'starChange' },
|
||
{ key: 'dsr3m', label: '90天用户评价得分', value: snapshot.dsr3m, unit: 'score' },
|
||
{ key: 'ptHelpRate1m', label: '30天平台求助率', value: snapshot.ptHelpRate1m, unit: 'ratio' },
|
||
{ key: 'aprsIn3minRplyUsrRto1m', label: '30天3分钟人工回复率', value: snapshot.aprsIn3minRplyUsrRto1m, unit: 'ratio' },
|
||
],
|
||
targets: snapshot.targets,
|
||
rows: snapshot.rows,
|
||
columns: [
|
||
{ key: 'shopName', title: '店铺' }, { key: 'mallStarTomms', title: '综合体验星级' }, { key: 'mallStarTommsChange', title: '星级变化' },
|
||
{ key: 'dsr3m', title: '90天评价得分' }, { key: 'ptHelpRate1m', title: '平台求助率' }, { key: 'aprsIn3minRplyUsrRto1m', title: '3分钟回复率' },
|
||
],
|
||
};
|
||
}
|
||
|
||
router.get('/meta', async (_req, res) => {
|
||
try {
|
||
res.json({ success: true, data: await loadMeta() });
|
||
} catch (error) {
|
||
console.error('[StoreCheckup] 查询元数据失败:', error.message);
|
||
res.status(500).json({ success: false, error: error.message });
|
||
}
|
||
});
|
||
|
||
router.get('/overview', async (req, res) => {
|
||
try {
|
||
const context = await resolveContext(req);
|
||
const cacheKey = `${context.shopName || '*'}:${context.date}`;
|
||
const cached = overviewCache.get(cacheKey);
|
||
if (cached && cached.expiresAt > Date.now()) {
|
||
return res.json({ success: true, data: cached.data });
|
||
}
|
||
let pending = overviewInFlight.get(cacheKey);
|
||
if (!pending) {
|
||
pending = queryOverview(context)
|
||
.then((data) => {
|
||
overviewCache.set(cacheKey, { data, expiresAt: Date.now() + OVERVIEW_CACHE_TTL_MS });
|
||
return data;
|
||
})
|
||
.finally(() => overviewInFlight.delete(cacheKey));
|
||
overviewInFlight.set(cacheKey, pending);
|
||
}
|
||
const data = await pending;
|
||
res.json({ success: true, data });
|
||
} catch (error) {
|
||
console.error('[StoreCheckup] 查询体检概览失败:', error.message);
|
||
res.status(500).json({ success: false, error: error.message });
|
||
}
|
||
});
|
||
|
||
router.get('/home-summary', async (req, res) => {
|
||
try {
|
||
const context = await resolveContext(req);
|
||
const cacheKey = `${context.shopName || '*'}:${context.date}`;
|
||
const cached = homeSummaryCache.get(cacheKey);
|
||
if (cached && cached.expiresAt > Date.now()) {
|
||
return res.json({ success: true, data: cached.data });
|
||
}
|
||
const refreshSummary = () => {
|
||
let pending = homeSummaryInFlight.get(cacheKey);
|
||
if (pending) return pending;
|
||
pending = queryHomeSummary(context)
|
||
.then((data) => {
|
||
const now = Date.now();
|
||
homeSummaryCache.set(cacheKey, {
|
||
data,
|
||
expiresAt: now + HOME_SUMMARY_CACHE_TTL_MS,
|
||
staleUntil: now + HOME_SUMMARY_STALE_TTL_MS,
|
||
});
|
||
return data;
|
||
})
|
||
.finally(() => homeSummaryInFlight.delete(cacheKey));
|
||
homeSummaryInFlight.set(cacheKey, pending);
|
||
return pending;
|
||
};
|
||
if (cached && cached.staleUntil > Date.now()) {
|
||
refreshSummary().catch((error) => {
|
||
console.error('[StoreCheckup] 后台刷新首页摘要失败:', error.message);
|
||
});
|
||
return res.json({ success: true, data: { ...cached.data, refreshing: true } });
|
||
}
|
||
return res.json({ success: true, data: await refreshSummary() });
|
||
} catch (error) {
|
||
console.error('[StoreCheckup] 查询首页摘要失败:', error.message);
|
||
return res.status(500).json({ success: false, error: error.message });
|
||
}
|
||
});
|
||
|
||
router.get('/home-reviews', async (req, res) => {
|
||
try {
|
||
const range = await resolveReviewDateRange(req);
|
||
const cacheKey = `${range.shopName || '*'}:${range.startDate}:${range.endDate}`;
|
||
const cached = homeReviewCache.get(cacheKey);
|
||
if (cached && cached.expiresAt > Date.now()) {
|
||
return res.json({ success: true, data: cached.data });
|
||
}
|
||
let pending = homeReviewInFlight.get(cacheKey);
|
||
if (!pending) {
|
||
pending = queryHomeReviewSummary(range)
|
||
.then((data) => {
|
||
homeReviewCache.set(cacheKey, { data, expiresAt: Date.now() + HOME_SUMMARY_CACHE_TTL_MS });
|
||
return data;
|
||
})
|
||
.finally(() => homeReviewInFlight.delete(cacheKey));
|
||
homeReviewInFlight.set(cacheKey, pending);
|
||
}
|
||
return res.json({ success: true, data: await pending });
|
||
} catch (error) {
|
||
console.error('[StoreCheckup] 查询首页评价摘要失败:', error.message);
|
||
return res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
||
}
|
||
});
|
||
|
||
router.get('/home-compensation', async (req, res) => {
|
||
try {
|
||
const shopName = cleanShopName(req.query.shopName);
|
||
const rows = await loadRecentCompensationRows(shopName);
|
||
const range = { ...(await resolveCompensationDateRange(req, rows)), shopName };
|
||
const cacheKey = `${shopName || '*'}:${range.startDate}:${range.endDate}`;
|
||
const cached = homeCompensationSummaryCache.get(cacheKey);
|
||
if (cached && cached.expiresAt > Date.now()) {
|
||
return res.json({ success: true, data: cached.data });
|
||
}
|
||
let pending = homeCompensationSummaryInFlight.get(cacheKey);
|
||
if (!pending) {
|
||
pending = Promise.resolve(queryHomeCompensationSummary(rows, range))
|
||
.then((data) => {
|
||
homeCompensationSummaryCache.set(cacheKey, {
|
||
data,
|
||
expiresAt: Date.now() + HOME_COMPENSATION_CACHE_TTL_MS,
|
||
});
|
||
return data;
|
||
})
|
||
.finally(() => homeCompensationSummaryInFlight.delete(cacheKey));
|
||
homeCompensationSummaryInFlight.set(cacheKey, pending);
|
||
}
|
||
return res.json({ success: true, data: await pending });
|
||
} catch (error) {
|
||
console.error('[StoreCheckup] 查询首页赔付摘要失败:', error.message);
|
||
return res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
||
}
|
||
});
|
||
|
||
router.get('/tabs/:tabKey', async (req, res) => {
|
||
try {
|
||
const tabKey = String(req.params.tabKey || '').trim();
|
||
if (!TAB_KEYS.has(tabKey) || tabKey === 'overview') {
|
||
return res.status(400).json({ success: false, error: '不支持的店铺体检模块' });
|
||
}
|
||
const context = await resolveContext(req);
|
||
const handlers = {
|
||
business: queryBusinessTab,
|
||
'after-sales': queryAfterSalesTab,
|
||
'customer-service': queryCustomerServiceTab,
|
||
'service-score': queryServiceScoreTab,
|
||
stars: queryStarsTab,
|
||
};
|
||
const data = await handlers[tabKey](context);
|
||
return res.json({ success: true, data: { ...data, date: context.date, shopName: context.shopName || '全部店铺' } });
|
||
} catch (error) {
|
||
console.error('[StoreCheckup] 查询体检模块失败:', error.message);
|
||
return res.status(500).json({ success: false, error: error.message });
|
||
}
|
||
});
|
||
|
||
module.exports = router;
|