gongxiangp2/components/department-dashboard/performance-conversion.ts
2026-09-29 11:35:41 +08:00

182 lines
6.9 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 { 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
}