182 lines
6.9 KiB
TypeScript
182 lines
6.9 KiB
TypeScript
import { BUSINESS_TREND_BRANDS, type BusinessTrendBrandName } from './business-trends'
|
||
export { BUSINESS_TREND_BRANDS }
|
||
export type PerformanceConversionBrand = BusinessTrendBrandName
|
||
|
||
export type PerformanceConversionWorkflowSync = {
|
||
status: 'pending' | 'syncing' | 'ready' | 'error'
|
||
lastCompletedAt: string | null
|
||
lastFullReconciledAt: string | null
|
||
}
|
||
|
||
export type PerformanceConversionSourceSync = {
|
||
status: 'pending' | 'syncing' | 'ready' | 'error'
|
||
lastCompletedAt: string | null
|
||
lastSourceDate: string | null
|
||
}
|
||
|
||
export const PERFORMANCE_CONVERSION_METRICS = [
|
||
{ key: 'impressions', label: '展现量', group: '流量', format: 'integer' },
|
||
{ key: 'visitors', label: '商品访客数', group: '流量', format: 'integer' },
|
||
{ key: 'clickRate', label: '点击率', group: '流量', format: 'percent' },
|
||
{ key: 'favorites', label: '收藏人数', group: '兴趣', format: 'integer' },
|
||
{ key: 'cartUsers', label: '加购人数', group: '兴趣', format: 'integer' },
|
||
{ key: 'paymentConversionRate', label: '支付转化率', group: '成交', format: 'percent' },
|
||
{ key: 'paymentAmount', label: '支付金额', group: '成交', format: 'currency' },
|
||
{ key: 'roi', label: '推广 ROI', group: '成交', format: 'decimal' },
|
||
] as const
|
||
|
||
export type PerformanceConversionMetricKey = typeof PERFORMANCE_CONVERSION_METRICS[number]['key']
|
||
|
||
export type PerformanceConversionWorkflowItem = {
|
||
key: string
|
||
workflowId: string
|
||
approvalNumber: string | null
|
||
processCode: string
|
||
title: string
|
||
brand: PerformanceConversionBrand
|
||
productId: string
|
||
spu: string | null
|
||
platform: string | null
|
||
completedAt: string
|
||
monitorStartDate: string
|
||
monitorEndDate: string
|
||
monitoringStatus: 'monitoring' | 'complete'
|
||
}
|
||
export type PerformanceConversionWorkflowList = {
|
||
brands: Array<{ brand: PerformanceConversionBrand; count: number }>
|
||
items: PerformanceConversionWorkflowItem[]
|
||
total: number
|
||
invalidProductIdCount: number
|
||
page: number
|
||
pageSize: number
|
||
hasMore: boolean
|
||
workflowSync: PerformanceConversionWorkflowSync
|
||
performanceSync?: PerformanceConversionSourceSync
|
||
}
|
||
|
||
export type PerformanceConversionDailyMetric = {
|
||
date: string
|
||
impressions: number | null
|
||
visitors: number | null
|
||
clickRate: number | null
|
||
favorites: number | null
|
||
cartUsers: number | null
|
||
paymentConversionRate: number | null
|
||
paymentAmount: number | null
|
||
roi: number | null
|
||
}
|
||
|
||
export type PerformanceConversionWindowSummary = {
|
||
sevenDayIntentRate: number | null
|
||
fifteenDayIntentRate: number | null
|
||
intentRateChange: number | null
|
||
}
|
||
|
||
export type PerformanceConversionTimeseries = {
|
||
workflow: PerformanceConversionWorkflowItem
|
||
product: {
|
||
id: string
|
||
name: string | null
|
||
sourceBrand: string | null
|
||
category: string | null
|
||
brandMatchesWorkflow: boolean | null
|
||
}
|
||
latest: PerformanceConversionDailyMetric | null
|
||
daily: PerformanceConversionDailyMetric[]
|
||
coverage: {
|
||
sourceFirstDate: string | null
|
||
sourceLatestDate: string | null
|
||
coveredDays: number
|
||
expectedDaysToDate: number
|
||
monitorStartDate: string
|
||
monitorEndDate: string
|
||
}
|
||
}
|
||
|
||
export function calculateIntentRate(day: Pick<PerformanceConversionDailyMetric, 'favorites' | 'cartUsers' | 'visitors' | 'paymentConversionRate'>): number | null {
|
||
const { favorites, cartUsers, visitors, paymentConversionRate } = day
|
||
if (favorites === null || cartUsers === null || visitors === null || paymentConversionRate === null
|
||
|| ![favorites, cartUsers, visitors, paymentConversionRate].every((value) => Number.isFinite(value))) {
|
||
return null
|
||
}
|
||
if (visitors <= 0) return null
|
||
return (favorites + cartUsers) / visitors + paymentConversionRate
|
||
}
|
||
|
||
function averageIntentRate(daily: PerformanceConversionDailyMetric[], windowSize: number) {
|
||
const window = daily.slice(0, windowSize)
|
||
if (window.length !== windowSize) return null
|
||
const values = window.map(calculateIntentRate)
|
||
if (values.some((value) => value === null)) return null
|
||
return values.reduce<number>((total, value) => total + (value ?? 0), 0) / windowSize
|
||
}
|
||
|
||
export function buildIntentRateWindowSummary(daily: PerformanceConversionDailyMetric[]): PerformanceConversionWindowSummary {
|
||
const sevenDayIntentRate = averageIntentRate(daily, 7)
|
||
const fifteenDayIntentRate = averageIntentRate(daily, 15)
|
||
const intentRateChange = sevenDayIntentRate !== null
|
||
&& sevenDayIntentRate !== 0
|
||
&& fifteenDayIntentRate !== null
|
||
? (fifteenDayIntentRate - sevenDayIntentRate) / sevenDayIntentRate
|
||
: null
|
||
|
||
return { sevenDayIntentRate, fifteenDayIntentRate, intentRateChange }
|
||
}
|
||
|
||
export function extractValidProductIds(...values: unknown[]): string[] {
|
||
const candidates = values.flatMap((value) => String(value || '')
|
||
.split(/[\s,,;;/、|]+/)
|
||
.map((part) => part.trim())
|
||
.filter(Boolean))
|
||
return Array.from(new Set(candidates.filter((value) => /^\d{6,20}$/.test(value) && !/^0+$/.test(value))))
|
||
}
|
||
|
||
function shanghaiDateParts(value: Date) {
|
||
const parts = new Intl.DateTimeFormat('en-US', {
|
||
timeZone: 'Asia/Shanghai', year: 'numeric', month: 'numeric', day: 'numeric',
|
||
}).formatToParts(value)
|
||
return {
|
||
year: Number(parts.find((part) => part.type === 'year')?.value),
|
||
month: Number(parts.find((part) => part.type === 'month')?.value),
|
||
day: Number(parts.find((part) => part.type === 'day')?.value),
|
||
}
|
||
}
|
||
|
||
function dateKey(parts: { year: number; month: number; day: number }) {
|
||
return `${parts.year}-${String(parts.month).padStart(2, '0')}-${String(parts.day).padStart(2, '0')}`
|
||
}
|
||
|
||
export function toShanghaiDateKey(value: string | Date): string | null {
|
||
const date = value instanceof Date ? value : new Date(value)
|
||
return Number.isNaN(date.getTime()) ? null : dateKey(shanghaiDateParts(date))
|
||
}
|
||
|
||
export function addCalendarMonths(date: string, months: number): string {
|
||
const [year, month, day] = date.split('-').map(Number)
|
||
const monthIndex = month - 1 + months
|
||
const targetYear = year + Math.floor(monthIndex / 12)
|
||
const targetMonthIndex = ((monthIndex % 12) + 12) % 12
|
||
const lastDay = new Date(Date.UTC(targetYear, targetMonthIndex + 1, 0)).getUTCDate()
|
||
return dateKey({ year: targetYear, month: targetMonthIndex + 1, day: Math.min(day, lastDay) })
|
||
}
|
||
|
||
export function compareDateKeys(left: string, right: string) {
|
||
return left.localeCompare(right)
|
||
}
|
||
|
||
export function isValidDateKey(value: unknown): value is string {
|
||
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false
|
||
const [year, month, day] = value.split('-').map(Number)
|
||
const date = new Date(Date.UTC(year, month - 1, day))
|
||
return date.getUTCFullYear() === year
|
||
&& date.getUTCMonth() === month - 1
|
||
&& date.getUTCDate() === day
|
||
}
|
||
|
||
export function daysBetweenInclusive(start: string, end: string) {
|
||
const startMs = Date.parse(`${start}T00:00:00Z`)
|
||
const endMs = Date.parse(`${end}T00:00:00Z`)
|
||
if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs < startMs) return 0
|
||
return Math.floor((endMs - startMs) / 86_400_000) + 1
|
||
}
|