import { randomUUID } from 'node:crypto' import { hostname } from 'node:os' import type { Session } from 'next-auth' import mssql from 'mssql' import { fetchPerformanceSourceRows, metricSourceForBrand, readTmallStoreSourceRanges, sourceMetricDateKey as metricDateKey, sourceSnapshotFilter, supportsMetricPlatform, TMALL_STORE_BRANDS, type MetricSource, type SourceMetricRow, type StoreSourceRange, type TmallStoreBrand, } from '@/lib/performance-conversion-source' 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_DAY_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 CachedSourceRows = { expiresAt: number; rows: SourceMetricRow[] } const sourceCache = new Map() type PerformanceConversionSourceCursor = { version: 2 lastSourceDate: string | null tmallStoreDates: Partial> } 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(brand as PerformanceConversionBrand), ]) 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 }, } } 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 { const empty: PerformanceConversionSourceCursor = { version: 2, lastSourceDate: null, tmallStoreDates: {} } if (!value) return empty try { const parsed = JSON.parse(value) as Partial const tmallStoreDates: PerformanceConversionSourceCursor['tmallStoreDates'] = {} if (parsed.version === 2 && parsed.tmallStoreDates && typeof parsed.tmallStoreDates === 'object') { for (const brand of TMALL_STORE_BRANDS) { const date = parsed.tmallStoreDates[brand] if (date === null || isValidDateKey(date)) tmallStoreDates[brand] = date } } return { version: 2, lastSourceDate: typeof parsed.lastSourceDate === 'string' && isValidDateKey(parsed.lastSourceDate) ? parsed.lastSourceDate : null, tmallStoreDates, } } catch { return empty } } 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(brand?: PerformanceConversionBrand): 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) const source = brand ? metricSourceForBrand(brand) : { kind: 'legacy' as const } if (source.kind === 'tmall-store' && !(source.brand in cursor.tmallStoreDates)) { return { status: state?.status === 'error' ? 'error' : state?.status === 'syncing' ? 'syncing' : 'pending', lastCompletedAt: null, lastSourceDate: null } } return { status: normalizePerformanceConversionSyncStatus(state), lastCompletedAt: state?.lastCompletedAt?.toISOString() || null, lastSourceDate: source.kind === 'tmall-store' ? cursor.tmallStoreDates[source.brand] || null : cursor.lastSourceDate, } } catch (error) { console.warn('[performance-conversion-sync] 同步状态读取失败,暂按首次同步处理:', error) return { status: 'pending', lastCompletedAt: null, lastSourceDate: null } } } async function readSnapshotRows(source: MetricSource, productId: string, startDate: string, endDate: string) { const rows = await prisma.performanceConversionMetricSnapshot.findMany({ where: { productId, ...sourceSnapshotFilter(source), 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(source: MetricSource, productId: string, startDate: string, endDate: string) { const cacheKey = `upstream:${source.kind === 'tmall-store' ? source.keyPrefix : 'legacy'}:${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 fetchPerformanceSourceRows(pool, source, 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(brand: PerformanceConversionBrand, productId: string, startDate: string, endDate: string) { const source = metricSourceForBrand(brand) const sync = await getPerformanceConversionSyncMetadata(brand) if (sync.lastCompletedAt) { const localRows = await readSnapshotRows(source, 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(source, productId, startDate, endDate) } type MetricProductWindow = { source: MetricSource productId: string startDate: string endDate: string } function getMetricProductWindows(snapshots: SnapshotRow[], today: string): MetricProductWindow[] { const windows = new Map() snapshots.flatMap(workflowItems).forEach((item) => { const source = metricSourceForBrand(item.brand) if (!supportsMetricPlatform(source, item.platform)) return const key = `${source.kind === 'tmall-store' ? source.keyPrefix : 'legacy'}:${item.productId}` const endDate = compareDateKeys(today, item.monitorEndDate) <= 0 ? today : item.monitorEndDate const existing = windows.get(key) if (!existing) { windows.set(key, { source, 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, source: MetricSource, productId: string, syncedAt: Date, index: number) { const metricDate = metricDateKey(row.metricDate) if (!metricDate) return null const sourceRowId = clean(row.sourceRowId) return { id: randomUUID(), sourceKey: source.kind === 'tmall-store' ? `${source.keyPrefix}${productId}:${metricDate}` : `${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 storeRanges = windows.some((window) => window.source.kind === 'tmall-store') ? await readTmallStoreSourceRanges(pool, today) : new Map() const tmallStoreDates = { ...cursor.tmallStoreDates } const syncStartedAt = new Date() for (const window of windows) { await renewPerformanceConversionSyncLease(leaseOwner) const source = window.source const scopeFilter = sourceSnapshotFilter(source) const existing = await prisma.performanceConversionMetricSnapshot.findFirst({ where: { productId: window.productId, ...scopeFilter }, select: { id: true }, }) // The legacy cursor cannot stand in for the first sync of a new source. const lastSourceDate = source.kind === 'tmall-store' ? cursor.tmallStoreDates[source.brand] : cursor.lastSourceDate const incrementalStart = lastSourceDate ? shiftDateKey(lastSourceDate, -SYNC_LOOKBACK_DAYS) : window.startDate let startDate = existing ? maxDateKey(window.startDate, incrementalStart) : window.startDate let endDate = window.endDate if (source.kind === 'tmall-store') { const retainedRange = storeRanges.get(source.brand) // An empty/missing store must not purge its previously retained history. if (!retainedRange) continue startDate = maxDateKey(startDate, retainedRange.firstDate) endDate = compareDateKeys(endDate, retainedRange.lastDate) < 0 ? endDate : retainedRange.lastDate tmallStoreDates[source.brand] = retainedRange.correctionThrough } if (compareDateKeys(startDate, endDate) > 0) continue const sourceRows = await fetchPerformanceSourceRows(pool, source, window.productId, startDate, endDate) const snapshotsToWrite = sourceRows .map((row, index) => toMetricSnapshotData(row, source, window.productId, syncStartedAt, index)) .filter((row): row is NonNullable> => row !== null) await prisma.$transaction(async (tx) => { // The supplemental ROI view can lag the Overall view or roll away a // month. Preserve only prior supplemental values when its row is absent; // a present row with an explicit null/zero remains authoritative. if (source.kind === 'tmall-store' && sourceRows.some((row) => !row.promotionSourceRowId)) { const previous = await tx.performanceConversionMetricSnapshot.findMany({ where: { productId: window.productId, ...scopeFilter, metricDate: { gte: dateKeyToUtcDate(startDate), lte: dateKeyToUtcDate(endDate) }, }, select: { sourceKey: true, impressions: true, promotionSpend: true, promotionRevenue: true, roi: true }, }) const byKey = new Map(previous.map((row) => [row.sourceKey, row])) snapshotsToWrite.forEach((snapshot, index) => { const prior = byKey.get(snapshot.sourceKey) if (prior && !sourceRows[index].promotionSourceRowId) { snapshot.impressions = prior.impressions snapshot.promotionSpend = prior.promotionSpend snapshot.promotionRevenue = prior.promotionRevenue snapshot.roi = prior.roi } }) } // Fence the replacement in the same transaction as its writes. const lease = await tx.performanceConversionSyncState.updateMany({ where: { id: PERFORMANCE_CONVERSION_SYNC_STATE_ID, leaseOwner, leaseExpiresAt: { gt: new Date() } }, data: { leaseExpiresAt: new Date(Date.now() + SYNC_LEASE_DURATION_MS) }, }) if (lease.count !== 1) throw new Error('经营数据同步租约已失效,本轮停止写入') await tx.performanceConversionMetricSnapshot.deleteMany({ where: { productId: window.productId, ...scopeFilter, metricDate: { gte: dateKeyToUtcDate(startDate), lte: dateKeyToUtcDate(endDate) }, // Monthly views are not a deletion feed: missing source dates must // not remove already ingested daily history. ...(source.kind === 'tmall-store' ? { AND: { sourceKey: { in: snapshotsToWrite.map((row) => row.sourceKey) } } } : {}), }, }) if (snapshotsToWrite.length > 0) { await tx.performanceConversionMetricSnapshot.createMany({ data: snapshotsToWrite }) } }) rowsUpserted += snapshotsToWrite.length productsSynced += 1 } sourceCache.clear() const sourceCursorRaw = serializePerformanceConversionSourceCursor({ version: 2, lastSourceDate: today, tmallStoreDates }) 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 scheduleNext = () => { const timer = setTimeout(() => { runOnce() scheduleNext() }, nextPerformanceConversionSyncDelay(new Date())) timer.unref?.() } scheduleNext() console.info('[performance-conversion-sync] 已调度经营数据每日增量同步:上海时间 15:00(上游视图 14:30 刷新),按来源日期回看两天校准') } export function nextPerformanceConversionSyncDelay(now: Date) { // 15:00 Asia/Shanghai is 07:00 UTC, independent of the host timezone. const next = new Date(now) next.setUTCHours(7, 0, 0, 0) if (next.getTime() <= now.getTime()) next.setTime(next.getTime() + SYNC_DAY_MS) return next.getTime() - now.getTime() } 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 aggregateRows(rows: SourceMetricRow[], source: MetricSource) { 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: source.kind === 'tmall-store' ? average(dayRows.map((row) => numberValue(row.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: source.kind === 'tmall-store' ? average(dayRows.map((row) => numberValue(row.conversionRate))) : ratio(paymentUsers, visitors, average(dayRows.map((row) => numberValue(row.conversionRate)))), paymentAmount: sum(dayRows.map((row) => numberValue(row.paymentAmount))), roi: source.kind === 'tmall-store' ? average(dayRows.map((row) => numberValue(row.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 source = metricSourceForBrand(workflow.brand) const rows = compareDateKeys(queryEndDate, workflow.monitorStartDate) >= 0 && supportsMetricPlatform(source, workflow.platform) ? await readSourceRows(workflow.brand, productId, workflow.monitorStartDate, queryEndDate) : [] const sourceDaily = aggregateRows(rows, source) 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, }, } }