cangku/backend/src/api/storeCheckup.js

1000 lines
41 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;