import { randomUUID } from 'node:crypto' import { hostname } from 'node:os' import type { Session } from 'next-auth' import mssql, { type ConnectionPool } 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, getDingWorkflowSyncMetadata, 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 const PERFORMANCE_CONVERSION_SYNC_STATE_ID = 'performance-conversion-daily-v1' const SYNC_LEASE_DURATION_MS = 20 * 60 * 1000 const SYNC_INITIAL_DELAY_MS = 30 * 1000 const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000 const SYNC_LOOKBACK_DAYS = 2 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 } type SourceMetricRow = { sourceRowId: string | null 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() type PerformanceConversionSourceCursor = { version: 1 lastSourceDate: string | null } export type PerformanceConversionSyncStatus = 'pending' | 'syncing' | 'ready' | 'error' export type PerformanceConversionSyncMetadata = { status: PerformanceConversionSyncStatus lastCompletedAt: string | null lastSourceDate: string | null } 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), platform: clean(fields.platform), 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, }, 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, workflowSync, performanceSync] = await Promise.all([ readEligibleSnapshots(), getDingWorkflowSyncMetadata(), getPerformanceConversionSyncMetadata(), ]) 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 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, workflowSync, performanceSync, } } 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: 4, min: 0, idleTimeoutMillis: 10_000 }, } } async function fetchSourceRows(pool: ConnectionPool, productId: string, startDate: string, endDate: string) { 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) CONVERT(nvarchar(255), [RowId]) AS sourceRowId, [统计日期] 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 `) return result.recordset || [] } function dateKeyToUtcDate(value: string) { return new Date(`${value}T00:00:00.000Z`) } function shiftDateKey(value: string, days: number) { const date = dateKeyToUtcDate(value) date.setUTCDate(date.getUTCDate() + days) return date.toISOString().slice(0, 10) } function maxDateKey(left: string, right: string) { return compareDateKeys(left, right) >= 0 ? left : right } function parsePerformanceConversionSourceCursor(value: string | null): PerformanceConversionSourceCursor { if (!value) return { version: 1, lastSourceDate: null } try { const parsed = JSON.parse(value) as Partial return { version: 1, lastSourceDate: typeof parsed.lastSourceDate === 'string' && isValidDateKey(parsed.lastSourceDate) ? parsed.lastSourceDate : null, } } catch { return { version: 1, lastSourceDate: null } } } function serializePerformanceConversionSourceCursor(cursor: PerformanceConversionSourceCursor) { return JSON.stringify(cursor) } function normalizePerformanceConversionSyncStatus(state: { status: string; lastCompletedAt: Date | null } | null): PerformanceConversionSyncStatus { if (!state?.lastCompletedAt) { return state?.status === 'syncing' ? 'syncing' : state?.status === 'error' ? 'error' : 'pending' } if (state.status === 'syncing') return 'syncing' if (state.status === 'error') return 'error' return 'ready' } export async function getPerformanceConversionSyncMetadata(): Promise { // Keep isolated unit tests and pre-migration local shells readable while the // additive table is being introduced. Production Prisma clients always have // this delegate after the migration has been applied. const syncState = (prisma as typeof prisma & { performanceConversionSyncState?: typeof prisma.performanceConversionSyncState }).performanceConversionSyncState if (!syncState) { return { status: 'pending', lastCompletedAt: null, lastSourceDate: null } } try { const state = await syncState.findUnique({ where: { id: PERFORMANCE_CONVERSION_SYNC_STATE_ID }, select: { status: true, lastCompletedAt: true, sourceCursorRaw: true }, }) const cursor = parsePerformanceConversionSourceCursor(state?.sourceCursorRaw || null) return { status: normalizePerformanceConversionSyncStatus(state), lastCompletedAt: state?.lastCompletedAt?.toISOString() || null, lastSourceDate: cursor.lastSourceDate, } } catch (error) { console.warn('[performance-conversion-sync] 同步状态读取失败,暂按首次同步处理:', error) return { status: 'pending', lastCompletedAt: null, lastSourceDate: null } } } async function readSnapshotRows(productId: string, startDate: string, endDate: string) { const rows = await prisma.performanceConversionMetricSnapshot.findMany({ where: { productId, metricDate: { gte: dateKeyToUtcDate(startDate), lte: dateKeyToUtcDate(endDate), }, }, orderBy: [{ metricDate: 'asc' }, { sourceRowId: 'asc' }], }) return rows.map((row) => ({ sourceRowId: row.sourceRowId, metricDate: row.metricDate, productId: row.productId, productName: row.productName, sourceBrand: row.sourceBrand, category1: row.category1, category2: row.category2, category3: row.category3, paymentAmount: row.paymentAmount, paymentUsers: row.paymentUsers, cartUsers: row.cartUsers, visitors: row.visitors, conversionRate: row.conversionRate, favorites: row.favorites, impressions: row.impressions, clicks: row.clicks, clickRate: row.clickRate, promotionSpend: row.promotionSpend, promotionRevenue: row.promotionRevenue, roi: row.roi, })) satisfies SourceMetricRow[] } async function readUpstreamSourceRows(productId: string, startDate: string, endDate: string) { const cacheKey = `upstream:${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 rows = await fetchSourceRows(pool, productId, startDate, endDate) sourceCache.set(cacheKey, { rows, expiresAt: Date.now() + SOURCE_CACHE_TTL_MS }) return rows } finally { await pool.close().catch(() => undefined) } } async function readSourceRows(productId: string, startDate: string, endDate: string) { const sync = await getPerformanceConversionSyncMetadata() if (sync.lastCompletedAt) { const localRows = await readSnapshotRows(productId, startDate, endDate) if (localRows.length > 0) { return localRows } // A newly synced workflow may not be included in the previous daily run. // Keep the old direct read only for that bootstrap gap; the next scheduled // run will persist the same product into the local snapshot. } return readUpstreamSourceRows(productId, startDate, endDate) } type MetricProductWindow = { productId: string startDate: string endDate: string } function getMetricProductWindows(snapshots: SnapshotRow[], today: string): MetricProductWindow[] { const windows = new Map() snapshots.flatMap(workflowItems).forEach((item) => { const endDate = compareDateKeys(today, item.monitorEndDate) <= 0 ? today : item.monitorEndDate const existing = windows.get(item.productId) if (!existing) { windows.set(item.productId, { productId: item.productId, startDate: item.monitorStartDate, endDate }) return } existing.startDate = compareDateKeys(item.monitorStartDate, existing.startDate) < 0 ? item.monitorStartDate : existing.startDate existing.endDate = maxDateKey(existing.endDate, endDate) }) return Array.from(windows.values()).filter((item) => compareDateKeys(item.startDate, item.endDate) <= 0) } async function acquirePerformanceConversionSyncLease(leaseOwner: string, startedAt: Date) { await prisma.performanceConversionSyncState.upsert({ where: { id: PERFORMANCE_CONVERSION_SYNC_STATE_ID }, create: { id: PERFORMANCE_CONVERSION_SYNC_STATE_ID }, update: {}, }) const claimed = await prisma.performanceConversionSyncState.updateMany({ where: { id: PERFORMANCE_CONVERSION_SYNC_STATE_ID, OR: [ { leaseExpiresAt: null }, { leaseExpiresAt: { lt: startedAt } }, { leaseOwner }, ], }, data: { status: 'syncing', lastStartedAt: startedAt, lastError: null, leaseOwner, leaseExpiresAt: new Date(startedAt.getTime() + SYNC_LEASE_DURATION_MS), }, }) if (claimed.count !== 1) return null return prisma.performanceConversionSyncState.findUniqueOrThrow({ where: { id: PERFORMANCE_CONVERSION_SYNC_STATE_ID }, }) } async function renewPerformanceConversionSyncLease(leaseOwner: string) { const renewed = await prisma.performanceConversionSyncState.updateMany({ where: { id: PERFORMANCE_CONVERSION_SYNC_STATE_ID, leaseOwner }, data: { leaseExpiresAt: new Date(Date.now() + SYNC_LEASE_DURATION_MS) }, }) if (renewed.count !== 1) throw new Error('经营数据同步租约已失效,本轮停止写入') } function toMetricSnapshotData(row: SourceMetricRow, productId: string, syncedAt: Date, index: number) { const metricDate = metricDateKey(row.metricDate) if (!metricDate) return null const sourceRowId = clean(row.sourceRowId) return { id: randomUUID(), sourceKey: `${productId}:${metricDate}:${sourceRowId || `row-${index}`}`, sourceRowId, productId, metricDate: dateKeyToUtcDate(metricDate), productName: clean(row.productName), sourceBrand: clean(row.sourceBrand), category1: clean(row.category1), category2: clean(row.category2), category3: clean(row.category3), paymentAmount: clean(row.paymentAmount), paymentUsers: clean(row.paymentUsers), cartUsers: clean(row.cartUsers), visitors: clean(row.visitors), conversionRate: clean(row.conversionRate), favorites: clean(row.favorites), impressions: clean(row.impressions), clicks: clean(row.clicks), clickRate: clean(row.clickRate), promotionSpend: clean(row.promotionSpend), promotionRevenue: clean(row.promotionRevenue), roi: clean(row.roi), syncedAt, createdAt: syncedAt, updatedAt: syncedAt, } } export async function syncPerformanceConversionMetrics() { if (!sourceDbConfigured()) throw new Error('未配置上游报表库(REPORT_DB_*),无法同步经营数据') const startedAt = new Date() const leaseOwner = `${hostname()}:${process.pid}:${randomUUID()}` const state = await acquirePerformanceConversionSyncLease(leaseOwner, startedAt) if (!state) return { skipped: true, rowsUpserted: 0, productsSynced: 0 } const pool = new mssql.ConnectionPool(sourceConfig()) let rowsUpserted = 0 let productsSynced = 0 try { await pool.connect() const cursor = parsePerformanceConversionSourceCursor(state.sourceCursorRaw) const today = toShanghaiDateKey(new Date()) if (!today) throw new Error('无法确定上海时区的当前日期') const snapshots = await readEligibleSnapshots() const windows = getMetricProductWindows(snapshots, today) const syncStartedAt = new Date() for (const window of windows) { await renewPerformanceConversionSyncLease(leaseOwner) const existing = await prisma.performanceConversionMetricSnapshot.findFirst({ where: { productId: window.productId }, select: { id: true }, }) const incrementalStart = cursor.lastSourceDate ? shiftDateKey(cursor.lastSourceDate, -SYNC_LOOKBACK_DAYS) : window.startDate const startDate = existing ? maxDateKey(window.startDate, incrementalStart) : window.startDate const endDate = window.endDate if (compareDateKeys(startDate, endDate) > 0) continue const sourceRows = await fetchSourceRows(pool, window.productId, startDate, endDate) const snapshotsToWrite = sourceRows .map((row, index) => toMetricSnapshotData(row, window.productId, syncStartedAt, index)) .filter((row): row is NonNullable> => row !== null) await prisma.$transaction(async (tx) => { await tx.performanceConversionMetricSnapshot.deleteMany({ where: { productId: window.productId, metricDate: { gte: dateKeyToUtcDate(startDate), lte: dateKeyToUtcDate(endDate) }, }, }) if (snapshotsToWrite.length > 0) { await tx.performanceConversionMetricSnapshot.createMany({ data: snapshotsToWrite }) } }) rowsUpserted += snapshotsToWrite.length productsSynced += 1 } sourceCache.clear() const sourceCursorRaw = serializePerformanceConversionSourceCursor({ version: 1, lastSourceDate: today }) const completed = await prisma.performanceConversionSyncState.updateMany({ where: { id: PERFORMANCE_CONVERSION_SYNC_STATE_ID, leaseOwner }, data: { status: 'ready', sourceCursorRaw, lastCompletedAt: syncStartedAt, lastError: null, leaseOwner: null, leaseExpiresAt: null, rowsUpserted, productsSynced, }, }) if (completed.count !== 1) throw new Error('经营数据同步租约已失效,本轮不能标记为成功') return { skipped: false, rowsUpserted, productsSynced } } catch (error) { const message = error instanceof Error ? error.message : String(error) await prisma.performanceConversionSyncState.updateMany({ where: { id: PERFORMANCE_CONVERSION_SYNC_STATE_ID, leaseOwner }, data: { status: 'error', lastError: message.slice(0, 2_000), leaseOwner: null, leaseExpiresAt: null, }, }).catch(() => undefined) throw error } finally { await pool.close().catch(() => undefined) } } type PerformanceConversionSyncGlobal = typeof globalThis & { __performanceConversionDailySyncStarted?: boolean } export function ensureDailyPerformanceConversionSyncStarted() { const schedulerGlobal = globalThis as PerformanceConversionSyncGlobal if (schedulerGlobal.__performanceConversionDailySyncStarted) return schedulerGlobal.__performanceConversionDailySyncStarted = true if (!sourceDbConfigured()) { console.info('[performance-conversion-sync] 未配置上游报表库,跳过每日经营数据同步') return } const runOnce = () => { void syncPerformanceConversionMetrics() .then((result) => { if (result.skipped) { console.info('[performance-conversion-sync] 另一实例正在同步,本轮跳过') return } console.info(`[performance-conversion-sync] 同步完成:更新 ${result.rowsUpserted} 条,商品 ${result.productsSynced} 个`) }) .catch((error) => console.warn('[performance-conversion-sync] 同步失败:', error)) } const initialTimer = setTimeout(runOnce, SYNC_INITIAL_DELAY_MS) initialTimer.unref?.() const interval = setInterval(runOnce, SYNC_INTERVAL_MS) interval.unref?.() console.info('[performance-conversion-sync] 已调度经营数据每日增量同步,周期 24 小时,按最近两天增量校准') } 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, }, } }