diff --git a/admin-frontend-vue/src/pages/tasks/duplicate-api.ts b/admin-frontend-vue/src/pages/tasks/duplicate-api.ts new file mode 100644 index 00000000..340cb51c --- /dev/null +++ b/admin-frontend-vue/src/pages/tasks/duplicate-api.ts @@ -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 { + const { data } = await http.get(`${DUPLICATE_CHECK_ENDPOINT}/duplicate-check-overview`, { + params: force ? { force: '1' } : undefined, + }) + return parseDuplicateOverview(data) +} diff --git a/admin-frontend-vue/src/pages/tasks/duplicate-model.ts b/admin-frontend-vue/src/pages/tasks/duplicate-model.ts new file mode 100644 index 00000000..3d24d139 --- /dev/null +++ b/admin-frontend-vue/src/pages/tasks/duplicate-model.ts @@ -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 + 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 + 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 + 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(payload) + const record = core && typeof core === 'object' ? (core as Record) : {} + const summary = record.summary && typeof record.summary === 'object' && Object.keys(record.summary as Record).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 + 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), + } +} diff --git a/admin-frontend-vue/tests/task-115.test.ts b/admin-frontend-vue/tests/task-115.test.ts new file mode 100644 index 00000000..00261669 --- /dev/null +++ b/admin-frontend-vue/tests/task-115.test.ts @@ -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, '总览解析保持纯逻辑') +})