450 lines
17 KiB
TypeScript
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,
|
|
},
|
|
}
|
|
}
|