import type { Session } from 'next-auth' import mssql from 'mssql' import { assertBrandCenterDashboardAccess } from '@/lib/brand-center-dashboard-access' import { prisma } from '@/lib/prisma' import { DING_WORKFLOW_ALLOWED_PROCESS_CODES, DING_WORKFLOW_STARTED_AT_CUTOFF_ISO, isDingWorkflowApprovedCompletion, } from '@/lib/ding-workflow-sync' import { BUSINESS_TREND_BRANDS, addCalendarMonths, compareDateKeys, daysBetweenInclusive, extractValidProductIds, isValidDateKey, toShanghaiDateKey, type PerformanceConversionBrand, type PerformanceConversionDailyMetric, type PerformanceConversionTimeseries, type PerformanceConversionWorkflowItem, type PerformanceConversionWorkflowList, } from '@/components/department-dashboard/performance-conversion' const ART_PROCESS_CODE = DING_WORKFLOW_ALLOWED_PROCESS_CODES[0] const VALID_BRANDS = new Set(BUSINESS_TREND_BRANDS) const DEFAULT_PAGE_SIZE = 20 const MAX_PAGE_SIZE = 50 const SOURCE_CACHE_TTL_MS = 5 * 60 * 1000 export class PerformanceConversionError extends Error { status: number constructor(message: string, status = 400) { super(message) this.name = 'PerformanceConversionError' this.status = status } } type SnapshotRow = { processInstanceId: string businessId: string | null title: string | null result: string | null bizAction: string | null status: string processCode: string | null brand: string | null sourceFinishedAtRaw: string | null formFields: unknown syncedAt: Date } type SourceMetricRow = { metricDate: Date | string | null productId: string | null productName: string | null sourceBrand: string | null category1: string | null category2: string | null category3: string | null paymentAmount: string | null paymentUsers: string | null cartUsers: string | null visitors: string | null conversionRate: string | null favorites: string | null impressions: string | null clicks: string | null clickRate: string | null promotionSpend: string | null promotionRevenue: string | null roi: string | null } type CachedSourceRows = { expiresAt: number; rows: SourceMetricRow[] } const sourceCache = new Map() function clean(value: unknown) { const normalized = String(value ?? '').trim() return normalized && normalized.toUpperCase() !== 'NULL' && normalized !== '--' ? normalized : null } function formRecord(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {} } function workflowItems(row: SnapshotRow): PerformanceConversionWorkflowItem[] { const brand = clean(row.brand) const completedAt = clean(row.sourceFinishedAtRaw) if (!brand || !VALID_BRANDS.has(brand) || !completedAt) return [] const monitorStartDate = toShanghaiDateKey(completedAt) if (!monitorStartDate) return [] const fields = formRecord(row.formFields) const ids = extractValidProductIds(fields.productId, fields.currentProductId) const monitorEndDate = addCalendarMonths(monitorStartDate, 6) const today = toShanghaiDateKey(new Date()) || monitorStartDate return ids.map((productId) => ({ key: `${row.processInstanceId}:${productId}`, workflowId: row.processInstanceId, approvalNumber: clean(row.businessId), processCode: clean(row.processCode) || ART_PROCESS_CODE, title: clean(row.title) || '未命名流程', brand: brand as PerformanceConversionBrand, productId, spu: clean(fields.spu), completedAt, monitorStartDate, monitorEndDate, monitoringStatus: compareDateKeys(today, monitorEndDate) <= 0 ? 'monitoring' : 'complete', })) } function isEligibleSnapshot(row: SnapshotRow) { return row.processCode === ART_PROCESS_CODE && Boolean(row.sourceFinishedAtRaw) && isDingWorkflowApprovedCompletion(row.status, row.result, row.bizAction) } async function readEligibleSnapshots(): Promise { const rows = await prisma.dingWorkflowSnapshot.findMany({ where: { processCode: ART_PROCESS_CODE, sourceCreatedAtRaw: { gte: DING_WORKFLOW_STARTED_AT_CUTOFF_ISO }, sourceFinishedAtRaw: { not: null }, }, select: { processInstanceId: true, businessId: true, title: true, result: true, bizAction: true, status: true, processCode: true, brand: true, sourceFinishedAtRaw: true, formFields: true, syncedAt: true, }, orderBy: [ { sourceFinishedAtRaw: 'desc' }, { processInstanceId: 'desc' }, ], }) return rows.filter(isEligibleSnapshot) } export async function getPerformanceConversionWorkflows( session: Session | null, input: { brand?: string | null page?: number pageSize?: number query?: string | null completedFrom?: string | null completedTo?: string | null } = {}, ): Promise { await assertBrandCenterDashboardAccess(session) const brand = clean(input.brand) || BUSINESS_TREND_BRANDS[0] if (!VALID_BRANDS.has(brand)) throw new PerformanceConversionError('品牌参数无效') const page = Math.max(1, Math.floor(Number(input.page) || 1)) const pageSize = Math.max(1, Math.min(MAX_PAGE_SIZE, Math.floor(Number(input.pageSize) || DEFAULT_PAGE_SIZE))) const query = clean(input.query)?.toLocaleLowerCase('zh-CN') || '' const completedFrom = clean(input.completedFrom) const completedTo = clean(input.completedTo) if (completedFrom && !isValidDateKey(completedFrom)) throw new PerformanceConversionError('完结开始日期格式无效') if (completedTo && !isValidDateKey(completedTo)) throw new PerformanceConversionError('完结结束日期格式无效') if (completedFrom && completedTo && compareDateKeys(completedFrom, completedTo) > 0) { throw new PerformanceConversionError('完结开始日期不能晚于结束日期') } const snapshots = await readEligibleSnapshots() const rangeSnapshots = snapshots.filter((row) => { const completedDate = row.sourceFinishedAtRaw ? toShanghaiDateKey(row.sourceFinishedAtRaw) : null if (!completedDate) return false return (!completedFrom || compareDateKeys(completedDate, completedFrom) >= 0) && (!completedTo || compareDateKeys(completedDate, completedTo) <= 0) }) const allItems = rangeSnapshots.flatMap(workflowItems) const brandCounts = new Map( BUSINESS_TREND_BRANDS.map((item) => [item, 0]), ) allItems.forEach((item) => brandCounts.set(item.brand, (brandCounts.get(item.brand) || 0) + 1)) const brandSnapshots = rangeSnapshots.filter((row) => row.brand === brand) const invalidProductIdCount = brandSnapshots.filter((row) => workflowItems(row).length === 0).length const filtered = allItems.filter((item) => item.brand === brand && (!query || [ item.productId, item.spu, item.approvalNumber, item.workflowId, item.title, ].some((value) => String(value || '').toLocaleLowerCase('zh-CN').includes(query)))) const offset = (page - 1) * pageSize const latestSyncedAt = snapshots.reduce( (latest, row) => !latest || row.syncedAt > latest ? row.syncedAt : latest, null, ) return { brands: BUSINESS_TREND_BRANDS.map((item) => ({ brand: item, count: brandCounts.get(item) || 0 })), items: filtered.slice(offset, offset + pageSize), total: filtered.length, invalidProductIdCount, page, pageSize, hasMore: offset + pageSize < filtered.length, lastWorkflowSyncedAt: latestSyncedAt?.toISOString() || null, } } function sourceDbConfigured() { return Boolean( process.env.REPORT_DB_SERVER && process.env.REPORT_DB_DATABASE && process.env.REPORT_DB_USER && process.env.REPORT_DB_PASSWORD, ) } function sourceConfig() { if (!sourceDbConfigured()) throw new PerformanceConversionError('经营数据源尚未配置', 503) return { server: process.env.REPORT_DB_SERVER as string, database: process.env.REPORT_DB_DATABASE as string, user: process.env.REPORT_DB_USER as string, password: process.env.REPORT_DB_PASSWORD as string, port: Number(process.env.REPORT_DB_PORT || 1433), options: { encrypt: process.env.REPORT_DB_ENCRYPT === 'true', trustServerCertificate: process.env.REPORT_DB_TRUST_SERVER_CERTIFICATE !== 'false', enableArithAbort: true, }, connectionTimeout: 15_000, requestTimeout: 60_000, pool: { max: 1, min: 0, idleTimeoutMillis: 10_000 }, } } async function readSourceRows(productId: string, startDate: string, endDate: string) { const cacheKey = `${productId}:${startDate}:${endDate}` const cached = sourceCache.get(cacheKey) if (cached && cached.expiresAt > Date.now()) return cached.rows const pool = new mssql.ConnectionPool(sourceConfig()) try { await pool.connect() const result = await pool.request() .input('productId', mssql.NVarChar(255), productId) .input('startDate', mssql.NVarChar(10), startDate) .input('endDate', mssql.NVarChar(10), endDate) .query(` SELECT TOP (5000) [统计日期] AS metricDate, CONVERT(nvarchar(255), [商品ID]) AS productId, CONVERT(nvarchar(2000), [商品名称]) AS productName, CONVERT(nvarchar(500), [品牌名称]) AS sourceBrand, CONVERT(nvarchar(500), [一级类目名称]) AS category1, CONVERT(nvarchar(500), [二级类目名称]) AS category2, CONVERT(nvarchar(500), [叶子类目名称]) AS category3, CONVERT(nvarchar(255), [支付金额]) AS paymentAmount, CONVERT(nvarchar(255), [支付人数]) AS paymentUsers, CONVERT(nvarchar(255), [加购人数]) AS cartUsers, CONVERT(nvarchar(255), [商品访客数]) AS visitors, CONVERT(nvarchar(255), [支付转化率]) AS conversionRate, CONVERT(nvarchar(255), [商品收藏人数]) AS favorites, CONVERT(nvarchar(255), [展现量]) AS impressions, CONVERT(nvarchar(255), [点击量]) AS clicks, CONVERT(nvarchar(255), [点击率]) AS clickRate, CONVERT(nvarchar(255), [推广消耗金额]) AS promotionSpend, CONVERT(nvarchar(255), [推广引导总成交金额]) AS promotionRevenue, CONVERT(nvarchar(255), [推广ROI]) AS roi FROM [rpt].[TmallSrcBusinessRoi] WHERE LTRIM(RTRIM(CONVERT(nvarchar(255), [商品ID]))) = @productId AND [统计日期] >= TRY_CONVERT(date, @startDate) AND [统计日期] <= TRY_CONVERT(date, @endDate) ORDER BY [统计日期] ASC, [RowId] ASC `) const rows = result.recordset || [] sourceCache.set(cacheKey, { rows, expiresAt: Date.now() + SOURCE_CACHE_TTL_MS }) return rows } finally { await pool.close().catch(() => undefined) } } function numberValue(value: unknown) { const normalized = clean(value)?.replace(/[,%¥¥\s]/g, '') if (!normalized) return null const parsed = Number(normalized) if (!Number.isFinite(parsed)) return null return String(value).includes('%') ? parsed / 100 : parsed } function sum(values: Array) { const present = values.filter((value): value is number => value !== null) return present.length ? present.reduce((total, value) => total + value, 0) : null } function average(values: Array) { const present = values.filter((value): value is number => value !== null) return present.length ? present.reduce((total, value) => total + value, 0) / present.length : null } function ratio(numerator: number | null, denominator: number | null, fallback: number | null) { return denominator !== null && denominator > 0 && numerator !== null ? numerator / denominator : fallback } function metricDateKey(value: Date | string | null) { if (!value) return null if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(value)) return value.slice(0, 10) const parsed = value instanceof Date ? value : new Date(value) return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString().slice(0, 10) } function aggregateRows(rows: SourceMetricRow[]) { const grouped = new Map() rows.forEach((row) => { const key = metricDateKey(row.metricDate) if (key) grouped.set(key, [...(grouped.get(key) || []), row]) }) return Array.from(grouped.entries()).sort(([left], [right]) => left.localeCompare(right)).map(([date, dayRows]): PerformanceConversionDailyMetric => { const impressions = sum(dayRows.map((row) => numberValue(row.impressions))) const clicks = sum(dayRows.map((row) => numberValue(row.clicks))) const visitors = sum(dayRows.map((row) => numberValue(row.visitors))) const paymentUsers = sum(dayRows.map((row) => numberValue(row.paymentUsers))) const promotionSpend = sum(dayRows.map((row) => numberValue(row.promotionSpend))) const promotionRevenue = sum(dayRows.map((row) => numberValue(row.promotionRevenue))) return { date, impressions, visitors, clickRate: ratio(clicks, impressions, average(dayRows.map((row) => numberValue(row.clickRate)))), favorites: sum(dayRows.map((row) => numberValue(row.favorites))), cartUsers: sum(dayRows.map((row) => numberValue(row.cartUsers))), paymentConversionRate: ratio(paymentUsers, visitors, average(dayRows.map((row) => numberValue(row.conversionRate)))), paymentAmount: sum(dayRows.map((row) => numberValue(row.paymentAmount))), roi: ratio(promotionRevenue, promotionSpend, average(dayRows.map((row) => numberValue(row.roi)))), } }) } function expandDailyRange( source: PerformanceConversionDailyMetric[], startDate: string, endDate: string, ) { const byDate = new Map(source.map((item) => [item.date, item])) const expanded: PerformanceConversionDailyMetric[] = [] const cursor = new Date(`${startDate}T00:00:00Z`) const end = new Date(`${endDate}T00:00:00Z`) while (cursor <= end) { const date = cursor.toISOString().slice(0, 10) expanded.push(byDate.get(date) || { date, impressions: null, visitors: null, clickRate: null, favorites: null, cartUsers: null, paymentConversionRate: null, paymentAmount: null, roi: null, }) cursor.setUTCDate(cursor.getUTCDate() + 1) } return expanded } function normalizeSourceBrand(value: unknown): PerformanceConversionBrand | null { const normalized = clean(value)?.toLowerCase() if (!normalized) return null return BUSINESS_TREND_BRANDS.find((brand) => normalized.includes(brand.toLowerCase())) || null } function latestClean(rows: SourceMetricRow[], field: keyof SourceMetricRow) { for (let index = rows.length - 1; index >= 0; index -= 1) { const value = clean(rows[index][field]) if (value) return value } return null } export async function getPerformanceConversionTimeseries( session: Session | null, input: { workflowId?: string | null; productId?: string | null }, ): Promise { await assertBrandCenterDashboardAccess(session) const workflowId = clean(input.workflowId) const productId = extractValidProductIds(input.productId)[0] if (!workflowId || !productId) throw new PerformanceConversionError('流程或商品 ID 参数无效') const row = await prisma.dingWorkflowSnapshot.findUnique({ where: { processInstanceId: workflowId }, select: { processInstanceId: true, businessId: true, title: true, result: true, bizAction: true, status: true, processCode: true, brand: true, sourceFinishedAtRaw: true, formFields: true, syncedAt: true, }, }) if (!row || !isEligibleSnapshot(row)) throw new PerformanceConversionError('未找到可监控的已完结美术流程', 404) const workflow = workflowItems(row).find((item) => item.productId === productId) if (!workflow) throw new PerformanceConversionError('商品 ID 不属于该流程', 403) const today = toShanghaiDateKey(new Date()) || workflow.monitorStartDate const queryEndDate = compareDateKeys(today, workflow.monitorEndDate) < 0 ? today : workflow.monitorEndDate const rows = compareDateKeys(queryEndDate, workflow.monitorStartDate) >= 0 ? await readSourceRows(productId, workflow.monitorStartDate, queryEndDate) : [] const sourceDaily = aggregateRows(rows) const daily = compareDateKeys(queryEndDate, workflow.monitorStartDate) >= 0 ? expandDailyRange(sourceDaily, workflow.monitorStartDate, queryEndDate) : [] const sourceBrand = normalizeSourceBrand(latestClean(rows, 'sourceBrand')) const category = [latestClean(rows, 'category1'), latestClean(rows, 'category2'), latestClean(rows, 'category3')] .filter(Boolean).join(' / ') || null return { workflow, product: { id: productId, name: latestClean(rows, 'productName'), sourceBrand: latestClean(rows, 'sourceBrand'), category, brandMatchesWorkflow: sourceBrand ? sourceBrand === workflow.brand : null, }, latest: sourceDaily[sourceDaily.length - 1] || null, daily, coverage: { sourceFirstDate: sourceDaily[0]?.date || null, sourceLatestDate: sourceDaily[sourceDaily.length - 1]?.date || null, coveredDays: sourceDaily.length, expectedDaysToDate: daysBetweenInclusive(workflow.monitorStartDate, queryEndDate), monitorStartDate: workflow.monitorStartDate, monitorEndDate: workflow.monitorEndDate, }, } }