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;