gongxiangp2/lib/performance-conversion-source.ts

190 lines
9.1 KiB
TypeScript

import mssql, { type ConnectionPool } from 'mssql'
import type { PerformanceConversionBrand } from '@/components/department-dashboard/performance-conversion'
export const TMALL_STORE_SOURCE_PREFIX = 'tmall-store-v1:'
export const TMALL_STORE_BRANDS = ['顾家', '索菲亚', '朴美'] as const
export type TmallStoreBrand = typeof TMALL_STORE_BRANDS[number]
const STORES: Record<TmallStoreBrand, string> = {
顾家: '顾家家居小件家具旗舰店',
索菲亚: '索菲亚实木风旗舰店',
朴美: '朴美家具旗舰店',
}
const MAX_SOURCE_ROWS = 5000
export type MetricSource = {
kind: 'legacy'
} | {
kind: 'tmall-store'
brand: TmallStoreBrand
store: string
keyPrefix: string
}
export 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
promotionSourceRowId?: string | null
}
export function metricSourceForBrand(brand: PerformanceConversionBrand): MetricSource {
if (!Object.prototype.hasOwnProperty.call(STORES, brand)) return { kind: 'legacy' }
const storeBrand = brand as TmallStoreBrand
return { kind: 'tmall-store', brand: storeBrand, store: STORES[storeBrand], keyPrefix: `${TMALL_STORE_SOURCE_PREFIX}${storeBrand}:` }
}
export function supportsMetricPlatform(source: MetricSource, platform: string | null) {
// A legacy missing platform is still resolved by the exact store/product key.
// An explicitly non-Tmall workflow must never consume a coincidentally equal ID.
return source.kind === 'legacy' || !platform || /天猫|淘宝|tmall|taobao/i.test(platform)
}
export function sourceSnapshotFilter(source: MetricSource) {
return source.kind === 'tmall-store'
? { sourceKey: { startsWith: source.keyPrefix } }
: { NOT: { sourceKey: { startsWith: TMALL_STORE_SOURCE_PREFIX } } }
}
export function sourceMetricDateKey(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 date = value instanceof Date ? value : new Date(value)
return Number.isNaN(date.getTime()) ? null : date.toISOString().slice(0, 10)
}
export type StoreSourceRange = { firstDate: string; lastDate: string; correctionThrough: string | null }
// Monthly views drop older months. Never delete cached dates outside the
// currently retained Overall range, or advance past a delayed ROI source.
export async function readTmallStoreSourceRanges(pool: ConnectionPool, today: string) {
const request = pool.request().input('today', mssql.NVarChar(10), today)
TMALL_STORE_BRANDS.forEach((brand, index) => request.input(`store${index}`, mssql.NVarChar(200), STORES[brand]))
const result = await request.query<{
sourceKind: string; sourceStore: string; firstDate: Date | string; lastDate: Date | string
}>(`
SELECT N'overall' AS sourceKind, [店铺名称] AS sourceStore,
MIN([统计日期]) AS firstDate, MAX([统计日期]) AS lastDate
FROM [rpt].[TmallStoreOverall]
WHERE [店铺名称] IN (@store0, @store1, @store2) AND [统计日期] <= TRY_CONVERT(date, @today)
GROUP BY [店铺名称]
UNION ALL
SELECT N'roi' AS sourceKind, [店铺名称] AS sourceStore,
MIN([统计日期]) AS firstDate, MAX([统计日期]) AS lastDate
FROM [rpt].[TmallStoreBusinessRoi]
WHERE [店铺名称] IN (@store0, @store1, @store2) AND [统计日期] <= TRY_CONVERT(date, @today)
GROUP BY [店铺名称]
`)
const ranges = new Map<TmallStoreBrand, StoreSourceRange>()
for (const brand of TMALL_STORE_BRANDS) {
const overall = result.recordset.find((row) => row.sourceKind === 'overall' && row.sourceStore === STORES[brand])
const roi = result.recordset.find((row) => row.sourceKind === 'roi' && row.sourceStore === STORES[brand])
const firstDate = sourceMetricDateKey(overall?.firstDate || null)
const lastDate = sourceMetricDateKey(overall?.lastDate || null)
const roiLastDate = sourceMetricDateKey(roi?.lastDate || null)
if (firstDate && lastDate && firstDate <= lastDate) {
ranges.set(brand, { firstDate, lastDate, correctionThrough: roiLastDate ? (roiLastDate < lastDate ? roiLastDate : lastDate) : null })
}
}
return ranges
}
export async function fetchPerformanceSourceRows(
pool: ConnectionPool, source: MetricSource, productId: string, startDate: string, endDate: string,
): Promise<SourceMetricRow[]> {
const request = pool.request()
.input('productId', mssql.NVarChar(255), productId)
.input('startDate', mssql.NVarChar(10), startDate)
.input('endDate', mssql.NVarChar(10), endDate)
if (source.kind === 'tmall-store') request.input('store', mssql.NVarChar(200), source.store)
const result = await request.query<SourceMetricRow>(source.kind === 'tmall-store' ? `
SELECT TOP (5001)
CONVERT(nvarchar(255), o.[RowId]) AS sourceRowId,
o.[统计日期] AS metricDate,
CONVERT(nvarchar(255), o.[商品ID]) AS productId,
CONVERT(nvarchar(2000), o.[商品名称]) AS productName,
CONVERT(nvarchar(500), o.[店铺名称]) AS sourceBrand,
CONVERT(nvarchar(500), o.[一级类目名称]) AS category1,
CONVERT(nvarchar(500), o.[二级类目名称]) AS category2,
CONVERT(nvarchar(500), o.[叶子类目名称]) AS category3,
CONVERT(nvarchar(255), o.[支付金额]) AS paymentAmount,
CONVERT(nvarchar(255), o.[支付买家数]) AS paymentUsers,
CONVERT(nvarchar(255), o.[加购人数]) AS cartUsers,
CONVERT(nvarchar(255), o.[商品访客数]) AS visitors,
CONVERT(nvarchar(255), o.[支付转化率]) AS conversionRate,
CONVERT(nvarchar(255), o.[商品收藏人数]) AS favorites,
CONVERT(nvarchar(255), o.[主图整体点击率]) AS clickRate,
CONVERT(nvarchar(255), r.[RowId]) AS promotionSourceRowId,
CONVERT(nvarchar(255), r.[展现量]) AS impressions,
CAST(NULL AS nvarchar(255)) AS clicks,
CONVERT(nvarchar(255), r.[推广消耗金额]) AS promotionSpend,
CONVERT(nvarchar(255), r.[推广引导总成交金额]) AS promotionRevenue,
CONVERT(nvarchar(255), r.[推广ROI]) AS roi
FROM [rpt].[TmallStoreOverall] o
LEFT JOIN [rpt].[TmallStoreBusinessRoi] r
ON r.[店铺名称] = o.[店铺名称] AND r.[统计日期] = o.[统计日期]
AND LTRIM(RTRIM(r.[商品ID])) = LTRIM(RTRIM(o.[商品ID]))
WHERE o.[店铺名称] = @store AND LTRIM(RTRIM(o.[商品ID])) = @productId
AND o.[统计日期] >= TRY_CONVERT(date, @startDate)
AND o.[统计日期] <= TRY_CONVERT(date, @endDate)
ORDER BY o.[统计日期] ASC, o.[RowId] ASC
` : `
SELECT TOP (5001)
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
`)
const rows = result.recordset || []
if (rows.length > MAX_SOURCE_ROWS) throw new Error('经营数据来源行数超出安全上限,本轮不替换快照')
if (source.kind === 'tmall-store') {
const dates = new Set<string>()
for (const row of rows) {
const date = sourceMetricDateKey(row.metricDate)
if (!date || date < startDate || date > endDate || row.productId?.trim() !== productId || row.sourceBrand !== source.store) {
throw new Error('天猫经营数据关联键或日期无效,本轮不替换快照')
}
if (dates.has(date)) throw new Error('天猫视图同一店铺、商品、日期有重复记录,本轮不替换快照')
dates.add(date)
}
}
return rows
}