From ab787ceff7f3697ae74a234c0423f1e42b55fbd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Sun, 6 Sep 2026 02:41:44 +0800 Subject: [PATCH] =?UTF-8?q?task-280(=E9=AA=8C=E6=94=B6=E5=8F=8D=E9=A6=88):?= =?UTF-8?q?=20=E9=87=8D=E5=A4=8D=E6=A3=80=E6=B5=8B=E5=AF=B9=E9=BD=90refere?= =?UTF-8?q?nce=20P0(=E5=88=86=E7=BB=84=E7=AD=9B=E9=80=89=E7=94=9F=E6=95=88?= =?UTF-8?q?/=E6=8A=BD=E5=B1=89=E8=81=9A=E5=90=88+=E6=B1=87=E6=80=BB?= =?UTF-8?q?=E5=BE=BD=E7=AB=A0/=E5=8F=B0=E8=B4=A6=E8=AE=A1=E6=95=B0?= =?UTF-8?q?=E9=A2=84=E8=BD=BD/total=5Fdup=E8=AE=A1=E6=95=B0/=E7=9F=A9?= =?UTF-8?q?=E9=98=B5=E8=A1=A8=E5=A4=B4=E5=90=B8=E9=A1=B6;=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E5=90=AB=E5=B7=A5=E4=BD=9C=E5=8C=BA=E5=9C=A8=E9=80=94?= =?UTF-8?q?console=E5=8C=96=E4=BB=A3=E7=A0=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/tasks/DuplicateCheckPage.vue | 694 ++++++++++++++++++ .../src/pages/tasks/duplicate-console-api.ts | 65 ++ .../pages/tasks/duplicate-console-logic.ts | 144 ++++ .../src/pages/tasks/duplicate-filter.ts | 2 + .../tests/align-dup-console.test.ts | 58 ++ ...AdminShopDataDuplicateCheckController.java | 52 ++ .../ShopDataDuplicateCheckQueryService.java | 216 ++++++ ...hopDataDuplicateCheckQueryServiceTest.java | 119 +++ 8 files changed, 1350 insertions(+) create mode 100644 admin-frontend-vue/src/pages/tasks/DuplicateCheckPage.vue create mode 100644 admin-frontend-vue/src/pages/tasks/duplicate-console-api.ts create mode 100644 admin-frontend-vue/src/pages/tasks/duplicate-console-logic.ts create mode 100644 admin-frontend-vue/tests/align-dup-console.test.ts diff --git a/admin-frontend-vue/src/pages/tasks/DuplicateCheckPage.vue b/admin-frontend-vue/src/pages/tasks/DuplicateCheckPage.vue new file mode 100644 index 00000000..26733af3 --- /dev/null +++ b/admin-frontend-vue/src/pages/tasks/DuplicateCheckPage.vue @@ -0,0 +1,694 @@ + + + + + diff --git a/admin-frontend-vue/src/pages/tasks/duplicate-console-api.ts b/admin-frontend-vue/src/pages/tasks/duplicate-console-api.ts new file mode 100644 index 00000000..0ea73361 --- /dev/null +++ b/admin-frontend-vue/src/pages/tasks/duplicate-console-api.ts @@ -0,0 +1,65 @@ +/** 撞款控制台/台账适配:/api/admin/shop-data-crawl/duplicate-check-console、-ledger、-export。 */ +import { http } from '@/api/http' +import { toDuplicateItemsQuery, type DuplicateFilter } from './duplicate-filter.ts' +import { + parseDuplicateConsole, + parseLedgerPage, + type DuplicateConsole, + type LedgerPage, +} from './duplicate-console-model.ts' + +export const DUPLICATE_CHECK_ENDPOINT = '/api/admin/shop-data-crawl' + +/** console 端点吃 asin/shop_name/group/country/site/date_from/date_to;多余分页参数后端忽略。 */ +function toConsoleQuery(filter: DuplicateFilter): Record { + const query: Record = {} + const asin = (filter.asin || '').trim() + if (asin) query.asin = asin + const shopName = (filter.shopName || '').trim() + if (shopName) query.shop_name = shopName + const group = (filter.group || '').trim() + if (group) query.group = group + const country = (filter.country || '').trim() + if (country) query.country = country + const site = (filter.site || '').trim() + if (site) query.site = site + const dateFrom = (filter.dateFrom || '').trim() + if (dateFrom) query.date_from = dateFrom + const dateTo = (filter.dateTo || '').trim() + if (dateTo) query.date_to = dateTo + return query +} + +export type LedgerSortKey = 'asin' | 'brand' | 'store_count' | 'record_count' | 'earliest' | 'latest' + +/** 加载撞款控制台快照:GET /duplicate-check-console。 */ +export async function fetchDuplicateConsole(filter: DuplicateFilter): Promise { + const { data } = await http.get(`${DUPLICATE_CHECK_ENDPOINT}/duplicate-check-console`, { + params: toConsoleQuery(filter), + }) + return parseDuplicateConsole(data) +} + +/** 加载全部 ASIN 台账分页(含未撞款):GET /duplicate-check-ledger。 */ +export async function fetchDuplicateLedger( + filter: DuplicateFilter, + page: number, + pageSize: number, + sortKey: LedgerSortKey | '', + sortDir: 'asc' | 'desc', +): Promise { + const params: Record = { ...toConsoleQuery(filter), page, page_size: pageSize } + if (sortKey) params.sort_key = sortKey + params.sort_dir = sortDir + const { data } = await http.get(`${DUPLICATE_CHECK_ENDPOINT}/duplicate-check-ledger`, { params }) + return parseLedgerPage(data) +} + +/** 导出 CSV(撞款 view=monitor / 台账 view=all):GET /duplicate-check-export → Blob。 */ +export async function requestDuplicateCsvBlob(filter: DuplicateFilter, view: string): Promise { + const { data } = await http.get(`${DUPLICATE_CHECK_ENDPOINT}/duplicate-check-export`, { + params: toDuplicateItemsQuery({ ...filter, view }, 1, 100), + responseType: 'blob', + }) + return data +} diff --git a/admin-frontend-vue/src/pages/tasks/duplicate-console-logic.ts b/admin-frontend-vue/src/pages/tasks/duplicate-console-logic.ts new file mode 100644 index 00000000..cc4705dd --- /dev/null +++ b/admin-frontend-vue/src/pages/tasks/duplicate-console-logic.ts @@ -0,0 +1,144 @@ +/** 撞款控制台视图派生(分组索引 / 组内撞款子集 / 店铺列并集):纯逻辑。 */ +import type { DuplicateItem, DuplicateOccurrence, DuplicateShop } from './duplicate-model.ts' + +export interface ShopGroups { + /** 分组名 → 成员店铺(展示名,保持首次出现顺序)。 */ + members: Record + /** 店铺展示名 → 所属分组名(含多分组拆分的每一组)。 */ + shopGroupNames: Record +} + +const GROUP_SEPARATOR = /[、,,/;;]/ + +function shopKey(name: string): string { + return (name || '').trim().toLowerCase() +} + +function splitTokens(value: string): string[] { + const out: string[] = [] + for (const token of (value || '').split(GROUP_SEPARATOR)) { + const group = token.trim() + if (group && !out.includes(group)) out.push(group) + } + return out +} + +/** 由店铺分布构建 分组→成员 与 店铺→分组 双向索引。 */ +export function indexShopGroups(shops: DuplicateShop[]): ShopGroups { + const members: Record = {} + const shopGroupNames: Record = {} + for (const shop of shops || []) { + const display = (shop.shopName || '').trim() + if (!display) continue + const groups = splitTokens(shop.groupName || '') + for (const group of groups) { + const list = members[group] || (members[group] = []) + if (!list.includes(display)) list.push(display) + const owned = shopGroupNames[display] || (shopGroupNames[display] = []) + if (!owned.includes(group)) owned.push(group) + } + } + return { members, shopGroupNames } +} + +/** 仅在指定成员店铺(展示名)内保留 occurrences;重算 shop_count,返回 ≥2 店的撞款子集。 */ +export function dupWithinGroup(items: DuplicateItem[], memberShops: string[]): DuplicateItem[] { + const memberKeys = new Set() + for (const shop of memberShops || []) memberKeys.add(shopKey(shop)) + const result: DuplicateItem[] = [] + for (const item of items || []) { + const kept: DuplicateOccurrence[] = [] + const shops = new Set() + for (const occ of item.occurrences || []) { + if (memberKeys.has(shopKey(occ.shopName))) { + kept.push(occ) + shops.add(occ.shopName || '') + } + } + if (kept.length > 0 && shops.size >= 2) { + result.push({ asin: item.asin, shopCount: shops.size, recordCount: kept.length, occurrences: kept }) + } + } + return result +} + +/** 撞款行涉及店铺的并集列(出现顺序去重);memberShops 非空时仅取交集成员的子集顺序。 */ +export function storeColumnsOf(items: DuplicateItem[], memberShops: string[] = []): string[] { + const restricted = new Set(memberShops.map((shop) => shopKey(shop))) + const useRestricted = restricted.size > 0 + const order: string[] = [] + const seen = new Set() + const push = (name: string): void => { + const key = shopKey(name) + if (!name || seen.has(key) || (useRestricted && !restricted.has(key))) return + seen.add(key) + order.push(name) + } + for (const item of items || []) { + for (const occ of item.occurrences || []) push(occ.shopName || '') + } + if (order.length === 0 && memberShops.length > 0) { + // 无撞款行但该组有成员店铺:矩阵列仍需展示组员店。 + for (const shop of memberShops) { + const key = shopKey(shop) + if (!seen.has(key)) { + seen.add(key) + order.push(shop) + } + } + } + return order +} + +/** 明细抽屉聚合行:店铺×站点聚合(对齐 reference 抽屉的 店铺/分组/站点/上架时间(升序堆叠)/次数)。 */ +export interface DrawerAggRow { + shopName: string + groupName: string + country: string + times: string[] + count: number +} + +export function aggregateDrawerRows(occurrences: DuplicateOccurrence[]): DrawerAggRow[] { + const rows = new Map() + for (const occ of occurrences || []) { + const key = `${shopKey(occ.shopName)}|${(occ.country || '').trim().toUpperCase()}` + let row = rows.get(key) + if (!row) { + row = { shopName: occ.shopName || '', groupName: occ.groupName || '', country: (occ.country || '').trim(), times: [], count: 0 } + rows.set(key, row) + } + row.times.push(occ.date || '') + row.count += 1 + } + const list = [...rows.values()] + for (const row of list) row.times.sort() + return list +} + +/** 明细抽屉汇总徽章:N店在售/N家店铺/国家chips/N次上架/最早上架。 */ +export interface DrawerSummary { + shopCount: number + countries: string[] + totalRecords: number + earliest: string +} + +export function drawerSummaryOf(occurrences: DuplicateOccurrence[]): DrawerSummary { + const shops = new Set() + const countries: string[] = [] + const seenCountry = new Set() + let earliest = '' + let totalRecords = 0 + for (const occ of occurrences || []) { + if (occ.shopName) shops.add(shopKey(occ.shopName)) + const country = (occ.country || '').trim() + if (country && !seenCountry.has(country)) { + seenCountry.add(country) + countries.push(country) + } + if (occ.date && (!earliest || occ.date < earliest)) earliest = occ.date + totalRecords += 1 + } + return { shopCount: shops.size, countries, totalRecords, earliest } +} diff --git a/admin-frontend-vue/src/pages/tasks/duplicate-filter.ts b/admin-frontend-vue/src/pages/tasks/duplicate-filter.ts index 4206c04a..4158e762 100644 --- a/admin-frontend-vue/src/pages/tasks/duplicate-filter.ts +++ b/admin-frontend-vue/src/pages/tasks/duplicate-filter.ts @@ -9,6 +9,8 @@ export interface DuplicateFilter { view: string asin: string shopName: string + /** 分组筛选(撞款分组名,后端按组员店铺裁剪可见集)。 */ + group?: string country: string site: string dateFrom: string diff --git a/admin-frontend-vue/tests/align-dup-console.test.ts b/admin-frontend-vue/tests/align-dup-console.test.ts new file mode 100644 index 00000000..11cd2fc8 --- /dev/null +++ b/admin-frontend-vue/tests/align-dup-console.test.ts @@ -0,0 +1,58 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readSource } from './helpers.ts' +import { aggregateDrawerRows, drawerSummaryOf } from '../src/pages/tasks/duplicate-console-logic.ts' + +// 验收反馈:店铺重复检测对齐 reference —— 本轮补齐 P0 缺口: +// 分组筛选生效、抽屉聚合+汇总徽章、台账计数打开即显示、撞款计数走后端 total_dup、矩阵表头吸顶。 + +test('align_dup_console_group_filter_wired', () => { + const api = readSource('src/pages/tasks/duplicate-console-api.ts') + assert.match(api, /query\.group = group/, 'console 查询携带 group 参数') + const page = readSource('src/pages/tasks/DuplicateCheckPage.vue') + assert.match(page, /group: filter\.group\.trim\(\)/, '页面筛选把 group 传给查询') +}) + +test('align_dup_console_group_filter_backend', () => { + const ctrl = readSource('../backend-java/src/main/java/com/nanri/aiimage/modules/shopduplicatecheck/controller/AdminShopDataDuplicateCheckController.java') + assert.match(ctrl, /@RequestParam\(name = "group"/, '后端 console/ledger 接收 group 参数') + assert.match(ctrl, /withGroupFilter\(visibleKeys, scan, group\)/, '端点按分组裁剪可见店铺') + const service = readSource('../backend-java/src/main/java/com/nanri/aiimage/modules/shopduplicatecheck/service/ShopDataDuplicateCheckQueryService.java') + assert.match(service, /withGroupFilter/, 'service 提供分组裁剪方法') + assert.match(service, /分组筛选/, '分组筛选留中文排查日志') +}) + +test('align_dup_console_drawer_aggregation', () => { + const rows = aggregateDrawerRows([ + { asin: 'A', date: '2026-09-02 10:00', price: '1', brand: 'B', shopName: '店1', groupName: '组1', countryCodes: [], country: 'DE' }, + { asin: 'A', date: '2026-09-01 10:00', price: '1', brand: 'B', shopName: '店1', groupName: '组1', countryCodes: [], country: 'DE' }, + { asin: 'A', date: '2026-09-03 10:00', price: '1', brand: 'B', shopName: '店2', groupName: '组1', countryCodes: [], country: 'UK' }, + ]) + assert.equal(rows.length, 2, '按店铺×站点聚合') + assert.equal(rows[0].count, 2, '同店同站次数累加') + assert.deepEqual(rows[0].times, ['2026-09-01 10:00', '2026-09-02 10:00'], '时间升序') + const summary = drawerSummaryOf(rows.flatMap((row) => row.times.map((time) => ({ asin: 'A', date: time, price: '1', brand: 'B', shopName: row.shopName, groupName: '组1', countryCodes: [], country: row.country })))) + assert.equal(summary.shopCount, 2) + assert.equal(summary.totalRecords, 3) + assert.equal(summary.earliest, '2026-09-01 10:00') +}) + +test('align_dup_console_drawer_template', () => { + const page = readSource('src/pages/tasks/DuplicateCheckPage.vue') + assert.match(page, /drawerAggRows/, '抽屉渲染聚合行') + assert.match(page, /drawerSummary/, '抽屉渲染汇总徽章') + assert.match(page, /次上架/, '汇总含 N 次上架') + assert.match(page, /label="次数"/, '聚合表含次数列') +}) + +test('align_dup_console_ledger_total_preload', () => { + const page = readSource('src/pages/tasks/DuplicateCheckPage.vue') + assert.match(page, /onMounted\(\(\) => \{\s*loadConsole\(\).*loadLedger\(1\)/s, '挂载即预取台账计数') + assert.match(page, /totalDup/, '撞款计数走后端 total_dup 字段') +}) + +test('align_dup_console_matrix_sticky', () => { + const page = readSource('src/pages/tasks/DuplicateCheckPage.vue') + assert.match(page, /table\.matrix th\s*\{[^}]*position:\s*sticky/, '矩阵表头吸顶') + assert.match(page, /\.matrix-wrap\s*\{[^}]*max-height/, '矩阵容器限高滚动') +}) diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopduplicatecheck/controller/AdminShopDataDuplicateCheckController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopduplicatecheck/controller/AdminShopDataDuplicateCheckController.java index 537e56c3..8121998e 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopduplicatecheck/controller/AdminShopDataDuplicateCheckController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopduplicatecheck/controller/AdminShopDataDuplicateCheckController.java @@ -128,6 +128,58 @@ public class AdminShopDataDuplicateCheckController { return ApiResponse.success(queryService.detailData(scan, visibleKeys, safePage, safeSize, filters)); } + @GetMapping("/duplicate-check-console") + @Operation(summary = "撞款控制台快照:指标/店铺/命中筛选的撞款全集/分组统计") + public ApiResponse console( + HttpServletRequest request, + @RequestParam(name = "asin", required = false) String asin, + @RequestParam(name = "shop_name", required = false) String shopName, + @RequestParam(name = "group", required = false) String group, + @RequestParam(name = "country", required = false) String country, + @RequestParam(name = "site", required = false) String site, + @RequestParam(name = "date_from", required = false) String dateFrom, + @RequestParam(name = "date_to", required = false) String dateTo) { + RequestOperator operator = requireDuplicateCheckAccess(request); + ShopDataDuplicateCheckQueryService.Filters filters = + ShopDataDuplicateCheckQueryService.Filters.clean(asin, shopName, country, site, dateFrom, dateTo); + DuplicateScanView scan = scanService.loadLatest(); + if (scan == null) { + return ApiResponse.success(queryService.pendingConsole()); + } + Set visibleKeys = queryService.resolveVisibleShopKeys(operator.id(), operator.superAdmin()); + Set effectiveKeys = queryService.withGroupFilter(visibleKeys, scan, group); + return ApiResponse.success(queryService.consoleData(scan, effectiveKeys, filters)); + } + + @GetMapping("/duplicate-check-ledger") + @Operation(summary = "全部 ASIN 台账(含未撞款):筛选 + 列排序 + 分页") + public ApiResponse ledger( + HttpServletRequest request, + @RequestParam(name = "page", required = false) String page, + @RequestParam(name = "page_size", required = false) String pageSize, + @RequestParam(name = "sort_key", required = false) String sortKey, + @RequestParam(name = "sort_dir", required = false) String sortDir, + @RequestParam(name = "asin", required = false) String asin, + @RequestParam(name = "shop_name", required = false) String shopName, + @RequestParam(name = "group", required = false) String group, + @RequestParam(name = "country", required = false) String country, + @RequestParam(name = "site", required = false) String site, + @RequestParam(name = "date_from", required = false) String dateFrom, + @RequestParam(name = "date_to", required = false) String dateTo) { + RequestOperator operator = requireDuplicateCheckAccess(request); + int safePage = parsePage(page); + int safeSize = clamp(parseSize(pageSize, 100), 1, 200); + ShopDataDuplicateCheckQueryService.Filters filters = + ShopDataDuplicateCheckQueryService.Filters.clean(asin, shopName, country, site, dateFrom, dateTo); + DuplicateScanView scan = scanService.loadLatest(); + if (scan == null) { + return ApiResponse.success(queryService.pendingLedger(safePage, safeSize)); + } + Set visibleKeys = queryService.resolveVisibleShopKeys(operator.id(), operator.superAdmin()); + Set effectiveKeys = queryService.withGroupFilter(visibleKeys, scan, group); + return ApiResponse.success(queryService.ledgerData(scan, effectiveKeys, safePage, safeSize, sortKey, sortDir, filters)); + } + @GetMapping("/duplicate-check-export") @Operation(summary = "撞款导出 CSV(UTF-8 BOM,逐行=一条上架记录)") public void export( diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopduplicatecheck/service/ShopDataDuplicateCheckQueryService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopduplicatecheck/service/ShopDataDuplicateCheckQueryService.java index f8bceede..e106cd24 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopduplicatecheck/service/ShopDataDuplicateCheckQueryService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopduplicatecheck/service/ShopDataDuplicateCheckQueryService.java @@ -11,6 +11,7 @@ import com.nanri.aiimage.modules.shopduplicatecheck.service.ShopDataDuplicateChe import com.nanri.aiimage.modules.shopduplicatecheck.service.support.DuplicateCheckCsvWriter; import com.nanri.aiimage.modules.shopduplicatecheck.service.support.DuplicateCheckTimeNormalizer; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import java.io.IOException; @@ -37,6 +38,7 @@ import java.util.Set; * 内存模型/排序语义与 Python 后台逐字对齐;返回结构为可直接序列化的 Map(键 snake_case, * 空态与 Python 响应完全一致:pending 时 summary 为 {} 空对象)。 */ +@Slf4j @Service @RequiredArgsConstructor public class ShopDataDuplicateCheckQueryService { @@ -86,6 +88,29 @@ public class ShopDataDuplicateCheckQueryService { return keys; } + /** 分组筛选:group 非空时取该分组全部成员店铺键;与 visibleKeys 求交集(null=全量时直接取组键)。 */ + public Set withGroupFilter(Set visibleKeys, DuplicateScanView view, String groupName) { + String group = trim(groupName); + if (group.isEmpty()) { + return visibleKeys; + } + Set memberKeys = new LinkedHashSet<>(); + for (DuplicateShop shop : view.payload().shops()) { + for (String g : splitGroups(safe(shop.groupName()))) { + if (g.equals(group)) { + memberKeys.add(shopKey(shop.shopName())); + } + } + } + if (visibleKeys == null) { + log.info("[duplicate-check] 分组筛选 group={} 命中成员店铺 {} 家", group, memberKeys.size()); + return memberKeys; + } + memberKeys.retainAll(visibleKeys); + log.info("[duplicate-check] 分组筛选 group={} 与数据范围交集后成员店铺 {} 家", group, memberKeys.size()); + return memberKeys; + } + /** 超管/null → 全量;否则保留可见店铺(shops 保持缓存序)。 */ public List cropShops(List shops, Set visibleKeys) { if (visibleKeys == null || shops == null) { @@ -177,6 +202,197 @@ public class ShopDataDuplicateCheckQueryService { "page", page, "page_size", pageSize, "scanned_at", ""); } + /* ================= 控制台 & 台账(reference console 对齐的只读聚合) ================= */ + + private static final Set LEDGER_SORT_KEYS = + Set.of("asin", "brand", "store_count", "record_count", "earliest", "latest"); + /** 店铺 group_name 可能以分隔符连接多分组,统一按分隔符拆分。 */ + private static final String GROUP_SPLIT = "[、,,/;;]"; + + /** 台账单行聚合:{asin, brand, stores[], groups[], countries[], record_count, earliest, latest, occurrences}。 */ + private record LedgerRow(String asin, String brand, List stores, List groups, + List countries, int recordCount, String earliest, String latest, + List occurrences) { + } + + /** + * 全部 ASIN 台账(含单店未撞款):筛选 + 可选列排序 + 分页。 + * sortKey 白名单外回退 earliest,sortDir 非 desc 视为 asc。 + */ + public Map ledgerData(DuplicateScanView view, Set visibleKeys, + int page, int pageSize, String sortKey, String sortDir, + Filters filters) { + List cropped = cropItems(view.payload().items(), visibleKeys); + List matched = filterItems(cropped, false, filters); + String key = LEDGER_SORT_KEYS.contains(safe(sortKey).toLowerCase(Locale.ROOT)) + ? safe(sortKey).toLowerCase(Locale.ROOT) : "earliest"; + boolean asc = !"desc".equals(safe(sortDir).trim().toLowerCase(Locale.ROOT)); + List rows = new ArrayList<>(matched.size()); + for (DuplicateItem item : matched) { + rows.add(ledgerRow(item)); + } + rows.sort((a, b) -> ledgerCompare(a, b, key, asc)); + int total = rows.size(); + int from = Math.min((page - 1) * pageSize, total); + int to = Math.min(from + pageSize, total); + List> slice = new ArrayList<>(); + for (int i = from; i < to; i++) { + slice.add(ledgerMap(rows.get(i))); + } + return mapOf("pending", false, "items", slice, "total", total, + "page", page, "page_size", pageSize, "scanned_at", safe(view.scannedAt())); + } + + public Map pendingLedger(int page, int pageSize) { + return mapOf("pending", true, "items", Collections.emptyList(), "total", 0, + "page", page, "page_size", pageSize, "scanned_at", ""); + } + + /** 撞款控制台一次快照:summary/shops(全局可见口径) + 命中筛选的撞款全集 + 分组统计。 */ + public Map consoleData(DuplicateScanView view, Set visibleKeys, Filters filters) { + List shops = cropShops(view.payload().shops(), visibleKeys); + List cropped = cropItems(view.payload().items(), visibleKeys); + String source = view.summary() == null ? "" : safe(view.summary().source()); + DuplicateScanSummary summary = recomputeSummary(cropped, shops, source); + List dup = filterItems(cropped, true, filters); + List> groups = groupStats(shops, filterItems(cropped, false, filters)); + return mapOf("pending", false, "scanned_at", safe(view.scannedAt()), "summary", summary, + "shops", shops, "dup", dup, "groups", groups, "total_dup", dup.size()); + } + + public Map pendingConsole() { + return mapOf("pending", true, "scanned_at", "", "summary", Collections.emptyMap(), + "shops", Collections.emptyList(), "dup", Collections.emptyList(), + "groups", Collections.emptyList(), "total_dup", 0); + } + + /** 单条台账行:品牌取首个非空;店铺/分组/站点保持出现顺序去重;最早/最近上架取归一化时间。 */ + private LedgerRow ledgerRow(DuplicateItem item) { + LinkedHashSet stores = new LinkedHashSet<>(); + LinkedHashSet groups = new LinkedHashSet<>(); + LinkedHashSet countries = new LinkedHashSet<>(); + String brand = ""; + String earliest = ""; + String latest = ""; + for (DuplicateOccurrence occ : item.occurrences()) { + if (!safe(occ.shopName()).isEmpty()) { + stores.add(occ.shopName()); + } + for (String group : splitGroups(safe(occ.groupName()))) { + groups.add(group); + } + String country = safe(occ.country()).trim().toUpperCase(Locale.ROOT); + if (!country.isEmpty()) { + countries.add(country); + } + if (brand.isEmpty()) { + brand = safe(occ.brand()); + } + String normalized = DuplicateCheckTimeNormalizer.normalizeSortTime(safe(occ.date())); + if (!normalized.isEmpty()) { + if (earliest.isEmpty() || normalized.compareTo(earliest) < 0) { + earliest = normalized; + } + if (latest.isEmpty() || normalized.compareTo(latest) > 0) { + latest = normalized; + } + } + } + return new LedgerRow(item.asin(), brand, List.copyOf(stores), List.copyOf(groups), + List.copyOf(countries), item.recordCount(), earliest, latest, + new ArrayList<>(item.occurrences())); + } + + private Map ledgerMap(LedgerRow row) { + return mapOf("asin", row.asin(), "brand", row.brand(), "store_count", row.stores().size(), + "stores", row.stores(), "groups", row.groups(), "countries", row.countries(), + "record_count", row.recordCount(), "earliest", row.earliest(), "latest", row.latest(), + "occurrences", row.occurrences()); + } + + private int ledgerCompare(LedgerRow a, LedgerRow b, String key, boolean asc) { + int cmp; + switch (key) { + case "asin" -> cmp = safe(a.asin()).compareTo(safe(b.asin())); + case "brand" -> cmp = safe(a.brand()).compareTo(safe(b.brand())); + case "store_count" -> cmp = Integer.compare(a.stores().size(), b.stores().size()); + case "record_count" -> cmp = Integer.compare(a.recordCount(), b.recordCount()); + case "latest" -> cmp = safe(a.latest()).compareTo(safe(b.latest())); + default -> cmp = safe(a.earliest()).compareTo(safe(b.earliest())); + } + return asc ? cmp : -cmp; + } + + /** 可见店铺按 group_name 拆分组;再在命中行上统计组内 唯一ASIN/上架记录/撞款ASIN。 */ + private List> groupStats(List shops, List matched) { + LinkedHashMap> members = new LinkedHashMap<>(); + for (DuplicateShop shop : shops) { + String display = shop.shopName() == null ? "" : shop.shopName().trim(); + if (display.isEmpty()) { + continue; + } + for (String group : splitGroups(safe(shop.groupName()))) { + members.computeIfAbsent(group, k -> new LinkedHashSet<>()).add(shopKey(display)); + } + } + if (members.isEmpty()) { + return List.of(); + } + List groupNames = new ArrayList<>(members.keySet()); + int[] asinUnique = new int[groupNames.size()]; + int[] recordCount = new int[groupNames.size()]; + int[] dupCount = new int[groupNames.size()]; + for (DuplicateItem item : matched) { + Set shopsOnItem = new LinkedHashSet<>(); + for (DuplicateOccurrence occ : item.occurrences()) { + String key = shopKey(occ.shopName()); + if (!key.isEmpty()) { + shopsOnItem.add(key); + } + } + for (int gi = 0; gi < groupNames.size(); gi++) { + Set memberKeys = members.get(groupNames.get(gi)); + int inGroup = 0; + for (String key : shopsOnItem) { + if (memberKeys.contains(key)) { + inGroup++; + } + } + if (inGroup > 0) { + asinUnique[gi]++; + for (DuplicateOccurrence occ : item.occurrences()) { + if (memberKeys.contains(shopKey(occ.shopName()))) { + recordCount[gi]++; + } + } + if (inGroup >= 2) { + dupCount[gi]++; + } + } + } + } + List> result = new ArrayList<>(groupNames.size()); + for (int gi = 0; gi < groupNames.size(); gi++) { + result.add(mapOf("name", groupNames.get(gi), "shop_count", members.get(groupNames.get(gi)).size(), + "asin_unique", asinUnique[gi], "record_count", recordCount[gi], "dup_count", dupCount[gi])); + } + return result; + } + + private static List splitGroups(String value) { + if (value == null || value.isBlank()) { + return List.of(); + } + List out = new ArrayList<>(); + for (String token : value.split(GROUP_SPLIT)) { + String group = token.trim(); + if (!group.isEmpty() && !out.contains(group)) { + out.add(group); + } + } + return out; + } + public void writeExport(DuplicateScanView view, Set visibleKeys, String viewMode, Filters filters, OutputStream out) throws IOException { boolean monitor = "monitor".equals(viewMode); diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/shopduplicatecheck/service/ShopDataDuplicateCheckQueryServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/shopduplicatecheck/service/ShopDataDuplicateCheckQueryServiceTest.java index bb287353..02f489ab 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/shopduplicatecheck/service/ShopDataDuplicateCheckQueryServiceTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/shopduplicatecheck/service/ShopDataDuplicateCheckQueryServiceTest.java @@ -137,6 +137,113 @@ class ShopDataDuplicateCheckQueryServiceTest { assertThat(list.get("scanned_at")).isEqualTo(""); } + @Test + void consoleSuperAdminReturnsDupSetGroupsAndSummary() { + Map data = service.consoleData(view, null, noFilter()); + assertThat(data.get("pending")).isEqualTo(false); + assertThat(data.get("total_dup")).isEqualTo(3); + @SuppressWarnings("unchecked") + List dup = (List) data.get("dup"); + assertThat(dup).extracting(DuplicateItem::asin) + .containsExactlyInAnyOrder("A0000001", "E0000001", "F0000001"); + assertThat(dup).allSatisfy(item -> assertThat(item.shopCount()).isGreaterThanOrEqualTo(2)); + DuplicateScanSummary summary = (DuplicateScanSummary) data.get("summary"); + assertThat(summary.asinTotal()).isEqualTo(6); + assertThat(summary.recordTotal()).isEqualTo(10); + assertThat(summary.duplicateAsinTotal()).isEqualTo(3); + assertThat(summary.siteCount()).isEqualTo(3); + @SuppressWarnings("unchecked") + List> groups = (List>) data.get("groups"); + assertThat(groups).extracting(map -> map.get("name")) + .containsExactlyInAnyOrder("GroupA", "GroupC", "GroupD"); + Map groupA = groups.stream() + .filter(map -> "GroupA".equals(map.get("name"))).findFirst().orElseThrow(); + assertThat(groupA.get("shop_count")).isEqualTo(2); + assertThat(groupA.get("asin_unique")).isEqualTo(4); + assertThat(groupA.get("record_count")).isEqualTo(6); + assertThat(groupA.get("dup_count")).isEqualTo(2); + } + + @Test + void consoleLeaderCropRemovesCrossGroupDupAndKeepsOwnGroups() { + Set visible = Set.of("shopa", "shopb"); + Map data = service.consoleData(view, visible, noFilter()); + assertThat(data.get("total_dup")).isEqualTo(2); + @SuppressWarnings("unchecked") + List dup = (List) data.get("dup"); + assertThat(dup).extracting(DuplicateItem::asin) + .containsExactlyInAnyOrder("A0000001", "E0000001"); + @SuppressWarnings("unchecked") + List> groups = (List>) data.get("groups"); + assertThat(groups).hasSize(1); + assertThat(groups.get(0).get("name")).isEqualTo("GroupA"); + @SuppressWarnings("unchecked") + List shops = (List) data.get("shops"); + assertThat(shops).extracting(DuplicateShop::shopName).containsExactly("ShopA", "ShopB"); + } + + @Test + void consolePendingShapes() { + Map data = service.pendingConsole(); + assertThat(data.get("pending")).isEqualTo(true); + assertThat(data.get("dup")).isEqualTo(List.of()); + assertThat(data.get("groups")).isEqualTo(List.of()); + assertThat(data.get("summary")).isEqualTo(Map.of()); + } + + @Test + void ledgerSingleAsinRowDerivesStoresGroupsCountriesAndDates() { + ShopDataDuplicateCheckQueryService.Filters filters = + ShopDataDuplicateCheckQueryService.Filters.clean("E0000001", "", "", "", "", ""); + Map data = service.ledgerData(view, null, 1, 200, "asin", "asc", filters); + assertThat(data.get("total")).isEqualTo(1); + @SuppressWarnings("unchecked") + List> items = (List>) data.get("items"); + Map e = items.get(0); + assertThat(e.get("asin")).isEqualTo("E0000001"); + assertThat(e.get("brand")).isEqualTo("BrandE"); + assertThat(e.get("store_count")).isEqualTo(3); + assertThat(e.get("stores")).isEqualTo(List.of("ShopA", "ShopB", "ShopC")); + assertThat(e.get("groups")).isEqualTo(List.of("GroupA", "GroupC")); + assertThat(e.get("countries")).isEqualTo(List.of("UK", "FR")); + assertThat(e.get("record_count")).isEqualTo(3); + assertThat(String.valueOf(e.get("earliest"))).startsWith("2026-08-29"); + assertThat(String.valueOf(e.get("latest"))).startsWith("2026-08-31"); + } + + @Test + void ledgerStoreCountDescPutsThreeShopAsinFirst() { + Map data = service.ledgerData(view, null, 1, 200, "store_count", "desc", noFilter()); + assertThat(data.get("total")).isEqualTo(6); + @SuppressWarnings("unchecked") + List> items = (List>) data.get("items"); + assertThat(items.get(0).get("asin")).isEqualTo("E0000001"); + assertThat(items.get(0).get("store_count")).isEqualTo(3); + } + + @Test + void ledgerLeaderCropCountsOnlyVisibleStores() { + Set visible = Set.of("shopa", "shopb"); + ShopDataDuplicateCheckQueryService.Filters filters = + ShopDataDuplicateCheckQueryService.Filters.clean("E0000001", "", "", "", "", ""); + Map data = service.ledgerData(view, visible, 1, 200, "", "", filters); + @SuppressWarnings("unchecked") + List> items = (List>) data.get("items"); + assertThat(items).hasSize(1); + assertThat(items.get(0).get("store_count")).isEqualTo(2); + assertThat(items.get(0).get("stores")).isEqualTo(List.of("ShopA", "ShopB")); + assertThat(items.get(0).get("record_count")).isEqualTo(2); + } + + @Test + void ledgerPendingShapes() { + Map data = service.pendingLedger(2, 100); + assertThat(data.get("pending")).isEqualTo(true); + assertThat(data.get("total")).isEqualTo(0); + assertThat(data.get("page")).isEqualTo(2); + assertThat(data.get("page_size")).isEqualTo(100); + } + @Test void exportCsvBomHeaderAndMonitorRows() throws Exception { ByteArrayOutputStream out = new ByteArrayOutputStream(); @@ -157,4 +264,16 @@ class ShopDataDuplicateCheckQueryServiceTest { assertThat(text).contains(",法国,"); assertThat(lines[1]).doesNotContain(",UK,"); } + + @Test + void groupFilterNarrowsToGroupMembers() { + // 分组筛选:GroupA 只保留 ShopA/ShopB 键。 + Set keys = service.withGroupFilter(null, view, "GroupA"); + assertThat(keys).containsExactlyInAnyOrder("shopa", "shopb"); + // 与数据范围交集:可见集只剩 ShopA 时,交集只剩 ShopA。 + Set cropped = service.withGroupFilter(Set.of("shopa"), view, "GroupA"); + assertThat(cropped).containsExactly("shopa"); + // 空分组名不裁剪。 + assertThat(service.withGroupFilter(null, view, " ")).isNull(); + } }