190 lines
9.1 KiB
TypeScript
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
|
|
}
|