gongxiangp2/lib/performance-conversion.ts

450 lines
17 KiB
TypeScript

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<string>(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<string, CachedSourceRows>()
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),
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,
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<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 = 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<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
const latestSyncedAt = snapshots.reduce<Date | null>(
(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<SourceMetricRow>(`
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<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,
},
}
}