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 = { 顾家: '顾家家居小件家具旗舰店', 索菲亚: '索菲亚实木风旗舰店', 朴美: '朴美家具旗舰店', } 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() 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 { 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(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() 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 }