task-115(任务与重复分析中心): 实现重复检查总览指标
新增 duplicate-model.ts(总览/店铺分布/明细项/出现记录 snake 解析) 与 duplicate-api.ts(fetchDuplicateOverview GET /duplicate-check-overview)。 TDD: task-115.test.ts 8 用例先 RED 后 GREEN。
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
/** 店铺数据重复检查适配(任务 115-119):/api/admin/shop-data-crawl/duplicate-check-* 系列端点。 */
|
||||
import { http } from '@/api/http'
|
||||
import { parseDuplicateOverview, type DuplicateOverview } from './duplicate-model.ts'
|
||||
|
||||
export const DUPLICATE_CHECK_ENDPOINT = '/api/admin/shop-data-crawl'
|
||||
|
||||
/** 加载撞款总览(可选 force=1 触发同步重扫)。 */
|
||||
export async function fetchDuplicateOverview(force = false): Promise<DuplicateOverview> {
|
||||
const { data } = await http.get<unknown>(`${DUPLICATE_CHECK_ENDPOINT}/duplicate-check-overview`, {
|
||||
params: force ? { force: '1' } : undefined,
|
||||
})
|
||||
return parseDuplicateOverview(data)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/** 重复检查模型(任务 115-118):撞款总览/店铺分布/明细项/详情解析;与 Java shopduplicatecheck snake 契约对齐,纯逻辑。 */
|
||||
import { unwrap } from '../../api/envelope.ts'
|
||||
|
||||
export interface DuplicateMetrics {
|
||||
shopCount: number
|
||||
asinTotal: number
|
||||
recordTotal: number
|
||||
duplicateAsinTotal: number
|
||||
duplicateShopCount: number
|
||||
siteCount: number
|
||||
asinPerShop: number
|
||||
source: string
|
||||
}
|
||||
|
||||
export interface DuplicateShop {
|
||||
shopName: string
|
||||
groupName: string
|
||||
countryCodes: string[]
|
||||
asinCount: number
|
||||
recordCount: number
|
||||
}
|
||||
|
||||
export interface DuplicateOccurrence {
|
||||
asin: string
|
||||
date: string
|
||||
price: string
|
||||
brand: string
|
||||
shopName: string
|
||||
groupName: string
|
||||
countryCodes: string[]
|
||||
country: string
|
||||
}
|
||||
|
||||
export interface DuplicateItem {
|
||||
asin: string
|
||||
shopCount: number
|
||||
recordCount: number
|
||||
occurrences: DuplicateOccurrence[]
|
||||
}
|
||||
|
||||
export interface DuplicateOverview {
|
||||
pending: boolean
|
||||
scannedAt: string
|
||||
summary: DuplicateMetrics | null
|
||||
shops: DuplicateShop[]
|
||||
}
|
||||
|
||||
function text(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function count(value: unknown): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 0
|
||||
}
|
||||
|
||||
function double(value: unknown): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : 0
|
||||
}
|
||||
|
||||
function strings(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.map((code) => text(code)).filter(Boolean) : []
|
||||
}
|
||||
|
||||
/** 解析单店铺分布行。 */
|
||||
export function parseDuplicateShop(raw: unknown): DuplicateShop | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const r = raw as Record<string, unknown>
|
||||
return {
|
||||
shopName: text(r.shop_name ?? r.shopName),
|
||||
groupName: text(r.group_name ?? r.groupName),
|
||||
countryCodes: strings(r.country_codes ?? r.countryCodes),
|
||||
asinCount: count(r.asin_count ?? r.asinCount),
|
||||
recordCount: count(r.record_count ?? r.recordCount),
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析单条上架出现。 */
|
||||
export function parseDuplicateOccurrence(raw: unknown): DuplicateOccurrence | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const r = raw as Record<string, unknown>
|
||||
return {
|
||||
asin: text(r.asin),
|
||||
date: text(r.date),
|
||||
price: text(r.price),
|
||||
brand: text(r.brand),
|
||||
shopName: text(r.shop_name ?? r.shopName),
|
||||
groupName: text(r.group_name ?? r.groupName),
|
||||
countryCodes: strings(r.country_codes ?? r.countryCodes),
|
||||
country: text(r.country),
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析 ASIN 维度明细项。 */
|
||||
export function parseDuplicateItem(raw: unknown): DuplicateItem | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const r = raw as Record<string, unknown>
|
||||
const asin = text(r.asin)
|
||||
if (!asin) return null
|
||||
return {
|
||||
asin,
|
||||
shopCount: count(r.shop_count ?? r.shopCount),
|
||||
recordCount: count(r.record_count ?? r.recordCount),
|
||||
occurrences: Array.isArray(r.occurrences)
|
||||
? r.occurrences.map((occ) => parseDuplicateOccurrence(occ)).filter((occ): occ is DuplicateOccurrence => occ !== null)
|
||||
: [],
|
||||
}
|
||||
}
|
||||
|
||||
/** 总览负载(data: {pending,scanned_at,summary,shops}) → 类型化;缺省回 pending 空态。 */
|
||||
export function parseDuplicateOverview(payload: unknown): DuplicateOverview {
|
||||
const core = unwrap<unknown>(payload)
|
||||
const record = core && typeof core === 'object' ? (core as Record<string, unknown>) : {}
|
||||
const summary = record.summary && typeof record.summary === 'object' && Object.keys(record.summary as Record<string, unknown>).length
|
||||
? parseDuplicateMetrics(record.summary)
|
||||
: null
|
||||
const shops = Array.isArray(record.shops)
|
||||
? record.shops.map((raw) => parseDuplicateShop(raw)).filter((shop): shop is DuplicateShop => shop !== null)
|
||||
: []
|
||||
return {
|
||||
pending: record.pending === true || summary === null,
|
||||
scannedAt: text(record.scanned_at ?? record.scannedAt),
|
||||
summary,
|
||||
shops,
|
||||
}
|
||||
}
|
||||
|
||||
function parseDuplicateMetrics(raw: unknown): DuplicateMetrics | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const r = raw as Record<string, unknown>
|
||||
return {
|
||||
shopCount: count(r.shop_count ?? r.shopCount),
|
||||
asinTotal: count(r.asin_total ?? r.asinTotal),
|
||||
recordTotal: count(r.record_total ?? r.recordTotal),
|
||||
duplicateAsinTotal: count(r.duplicate_asin_total ?? r.duplicateAsinTotal),
|
||||
duplicateShopCount: count(r.duplicate_shop_count ?? r.duplicateShopCount),
|
||||
siteCount: count(r.site_count ?? r.siteCount),
|
||||
asinPerShop: double(r.asin_per_shop ?? r.asinPerShop),
|
||||
source: text(r.source),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readSource } from './helpers.ts'
|
||||
import { parseDuplicateOverview, parseDuplicateShop } from '../src/pages/tasks/duplicate-model.ts'
|
||||
|
||||
test('test_task_115_duplicate_overview_normal_primary_path', () => {
|
||||
// 正常主路径:撞款总览(snake summary + shops)解析为类型化指标。
|
||||
const overview = parseDuplicateOverview({
|
||||
success: true,
|
||||
data: {
|
||||
pending: false,
|
||||
scanned_at: '2026-01-02 10:00:00',
|
||||
summary: {
|
||||
shop_count: 8,
|
||||
asin_total: 1200,
|
||||
record_total: 3400,
|
||||
duplicate_asin_total: 45,
|
||||
duplicate_shop_count: 6,
|
||||
site_count: 5,
|
||||
asin_per_shop: 150,
|
||||
source: 'Excel',
|
||||
},
|
||||
shops: [
|
||||
{ shop_name: 'BlueWave', group_name: '华东组', country_codes: ['DE', 'UK'], asin_count: 30, record_count: 90 },
|
||||
],
|
||||
},
|
||||
})
|
||||
assert.equal(overview.pending, false)
|
||||
assert.equal(overview.summary!.asinTotal, 1200)
|
||||
assert.equal(overview.summary!.duplicateAsinTotal, 45)
|
||||
assert.equal(overview.summary!.duplicateShopCount, 6)
|
||||
assert.equal(overview.summary!.siteCount, 5)
|
||||
assert.equal(overview.shops.length, 1)
|
||||
assert.equal(overview.shops[0].shopName, 'BlueWave')
|
||||
assert.deepEqual(overview.shops[0].countryCodes, ['DE', 'UK'])
|
||||
})
|
||||
|
||||
test('test_task_115_duplicate_overview_normal_variant_input', () => {
|
||||
// 正常变体:无扫描结果 pending 空态。
|
||||
const overview = parseDuplicateOverview({ data: { pending: true, scanned_at: '', summary: {}, shops: [] } })
|
||||
assert.equal(overview.pending, true)
|
||||
assert.equal(overview.summary, null)
|
||||
assert.deepEqual(overview.shops, [])
|
||||
})
|
||||
|
||||
test('test_task_115_duplicate_overview_repeated_is_idempotent', () => {
|
||||
// 正常重复:解析稳定、不改输入。
|
||||
const payload = { data: { pending: false, summary: { asin_total: 3 }, shops: [] } }
|
||||
assert.deepEqual(parseDuplicateOverview(payload), parseDuplicateOverview(payload))
|
||||
})
|
||||
|
||||
test('test_task_115_duplicate_overview_boundary_empty_input', () => {
|
||||
// 边界空值:空负载回默认空态。
|
||||
const overview = parseDuplicateOverview({})
|
||||
assert.equal(overview.pending, true)
|
||||
assert.equal(overview.summary, null)
|
||||
assert.deepEqual(overview.shops, [])
|
||||
})
|
||||
|
||||
test('test_task_115_duplicate_overview_boundary_single_item', () => {
|
||||
// 边界单元素:单店铺分布解析完整。
|
||||
const shop = parseDuplicateShop({ shop_name: 'S', group_name: 'G', country_codes: ['DE'], asin_count: 1, record_count: 2 })
|
||||
assert.ok(shop)
|
||||
assert.equal(shop.asinCount, 1)
|
||||
})
|
||||
|
||||
test('test_task_115_duplicate_overview_boundary_limit_or_missing_field', () => {
|
||||
// 边界上限/缺字段:缺省数值回 0;缺国家回空数组。
|
||||
const shop = parseDuplicateShop({ shop_name: 'S' })
|
||||
assert.ok(shop)
|
||||
assert.equal(shop.asinCount, 0)
|
||||
assert.deepEqual(shop.countryCodes, [])
|
||||
assert.equal(shop.groupName, '')
|
||||
})
|
||||
|
||||
test('test_task_115_duplicate_overview_invalid_input_rejected', () => {
|
||||
// 异常输入:success=false 抛后端 message;非对象店铺行忽略。
|
||||
assert.throws(() => parseDuplicateOverview({ success: false, message: '无权访问重复检查' }), /无权访问/)
|
||||
assert.equal(parseDuplicateShop('garbage'), null)
|
||||
})
|
||||
|
||||
test('test_task_115_duplicate_overview_dependency_failure_returns_actionable_message', () => {
|
||||
// 依赖失败/加载走 adapter:GET /api/admin/shop-data-crawl/duplicate-check-overview。
|
||||
const api = readSource('src/pages/tasks/duplicate-api.ts')
|
||||
assert.match(api, /\/duplicate-check-overview/)
|
||||
assert.match(api, /fetchDuplicateOverview/)
|
||||
const model = readSource('src/pages/tasks/duplicate-model.ts')
|
||||
assert.equal(/axios|http\./.test(model), false, '总览解析保持纯逻辑')
|
||||
})
|
||||
Reference in New Issue
Block a user