gongxiangp2/lib/performance-conversion.ts

807 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

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<string>(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<string, CachedSourceRows>()
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<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {}
}
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<SnapshotRow[]> {
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<PerformanceConversionWorkflowList> {
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<PerformanceConversionBrand, number>(
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<SourceMetricRow>(`
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<PerformanceConversionSourceCursor>
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<PerformanceConversionSyncMetadata> {
// 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<string, MetricProductWindow>()
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<ReturnType<typeof toMetricSnapshotData>> => 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<number | null>) {
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<number | null>) {
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<string, SourceMetricRow[]>()
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<PerformanceConversionTimeseries> {
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,
},
}
}