task-280(验收反馈): 重复检测对齐reference P0(分组筛选生效/抽屉聚合+汇总徽章/台账计数预载/total_dup计数/矩阵表头吸顶;后端含工作区在途console化代码)
This commit is contained in:
@@ -0,0 +1,694 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 店铺数据重复检查 · 撞款控制台(reference asin-store-console 后台化,自家样式)。
|
||||
* 数据面:/duplicate-check-console(指标/店铺/撞款全集/分组统计) + /duplicate-check-ledger(台账分页) + /duplicate-check-export(CSV)。
|
||||
*/
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { DuplicateItem, DuplicateOccurrence, DuplicateMetrics } from './duplicate-model.ts'
|
||||
import { fetchDuplicateConsole, fetchDuplicateLedger, requestDuplicateCsvBlob, type LedgerSortKey } from './duplicate-console-api.ts'
|
||||
import { aggregateDrawerRows, drawerSummaryOf, dupWithinGroup, indexShopGroups, type DrawerAggRow, type DrawerSummary, type ShopGroups } from './duplicate-console-logic.ts'
|
||||
import type { DuplicateConsole, LedgerPage, LedgerRow } from './duplicate-console-model.ts'
|
||||
import { ASIN_COUNTRY_CODES, asinCountryLabel } from '../asin/asin-country.ts'
|
||||
|
||||
type Tab = 'dup' | 'ledger'
|
||||
type Scope = 'global' | 'group'
|
||||
|
||||
const consoleData = ref<DuplicateConsole | null>(null)
|
||||
const loading = ref(false)
|
||||
const loadError = ref('')
|
||||
const reanalyzing = ref(false)
|
||||
|
||||
const filter = reactive({ asin: '', shop: '', group: '', country: '', dateRange: [] as string[] })
|
||||
|
||||
const activeTab = ref<Tab>('dup')
|
||||
const scope = ref<Scope>('global')
|
||||
const selectedGroup = ref('')
|
||||
const shopGroups = ref<ShopGroups>({ members: {}, shopGroupNames: {} })
|
||||
|
||||
const ledgerItems = ref<LedgerRow[]>([])
|
||||
const ledgerTotal = ref(0)
|
||||
const ledgerPage = ref(1)
|
||||
const ledgerLoading = ref(false)
|
||||
const sortState = reactive<{ key: LedgerSortKey | ''; dir: 'asc' | 'desc' }>({ key: 'earliest', dir: 'asc' })
|
||||
|
||||
const drawerVisible = ref(false)
|
||||
const drawerTitle = ref('')
|
||||
const drawerBrand = ref('')
|
||||
const drawerDup = ref(false)
|
||||
const drawerOccurrences = ref<DuplicateOccurrence[]>([])
|
||||
/** 明细抽屉:店铺×站点聚合 + 汇总徽章(对齐 reference 抽屉的 次数/国家chips/最早上架)。 */
|
||||
const drawerAggRows = computed<DrawerAggRow[]>(() => aggregateDrawerRows(drawerOccurrences.value))
|
||||
const drawerSummary = computed<DrawerSummary>(() => drawerSummaryOf(drawerOccurrences.value))
|
||||
|
||||
const groupInfoVisible = ref(false)
|
||||
|
||||
const overview = computed(() => consoleData.value?.overview ?? null)
|
||||
const summary = computed<DuplicateMetrics | null>(() => overview.value?.summary ?? null)
|
||||
const shops = computed(() => overview.value?.shops ?? [])
|
||||
const dupItems = computed(() => consoleData.value?.dup ?? [])
|
||||
const groupStats = computed(() => consoleData.value?.groups ?? [])
|
||||
|
||||
const countryOptions = computed<string[]>(() => {
|
||||
const codes = new Set<string>(ASIN_COUNTRY_CODES as readonly string[])
|
||||
for (const shop of shops.value) for (const code of shop.countryCodes || []) codes.add(code.toUpperCase())
|
||||
for (const item of dupItems.value) for (const occ of item.occurrences) if (occ.country) codes.add(occ.country.toUpperCase())
|
||||
return [...codes]
|
||||
})
|
||||
|
||||
const kpis = computed(() => {
|
||||
const s = summary.value
|
||||
const totalDup = dupItems.value.length
|
||||
const groupDupTotal = groupStats.value.reduce((sum, group) => sum + group.dupCount, 0)
|
||||
const siteCount = s?.siteCount ?? 0
|
||||
return [
|
||||
{ label: '唯一ASIN', value: (s?.asinTotal ?? 0).toLocaleString(), sub: '去重后', danger: false },
|
||||
{ label: '上架记录', value: (s?.recordTotal ?? 0).toLocaleString(), sub: '含重复扫描', danger: false },
|
||||
{ label: '在售店铺', value: (s?.shopCount ?? 0).toLocaleString(), sub: '本批数据', danger: false },
|
||||
{ label: '分组', value: groupStats.value.length.toLocaleString(), sub: groupStats.value.map((group) => group.name).join(' / ') || '未配置', danger: false },
|
||||
{ label: '全店撞款', value: (s?.duplicateAsinTotal ?? totalDup).toLocaleString(), sub: '≥2店在售', danger: true },
|
||||
{ label: '组内撞款', value: groupDupTotal.toLocaleString(), sub: '分组内≥2店', danger: groupDupTotal > 0 },
|
||||
{ label: '覆盖站点', value: siteCount.toLocaleString(), sub: countryOptions.value.map(asinCountryLabel).join(' / '), danger: false },
|
||||
]
|
||||
})
|
||||
|
||||
const storeDistribution = computed(() => {
|
||||
const list = shops.value.map((shop) => ({ name: shop.shopName, group: shop.groupName, records: shop.recordCount, asins: shop.asinCount }))
|
||||
const max = Math.max(1, ...list.map((s) => s.records))
|
||||
return { max, list }
|
||||
})
|
||||
|
||||
function dateFrom(): string {
|
||||
return filter.dateRange?.[0] || ''
|
||||
}
|
||||
function dateTo(): string {
|
||||
return filter.dateRange?.[1] || ''
|
||||
}
|
||||
|
||||
function activeFilterParams() {
|
||||
return { asin: filter.asin.trim(), shopName: filter.shop.trim(), group: filter.group.trim(), country: filter.country, site: '', dateFrom: dateFrom(), dateTo: dateTo() }
|
||||
}
|
||||
|
||||
function selectedGroupMembers(): string[] {
|
||||
if (!selectedGroup.value) return []
|
||||
return shopGroups.value.members[selectedGroup.value] || []
|
||||
}
|
||||
|
||||
const groupChips = computed(() => groupStats.value.filter((group) => group.shopCount >= 2))
|
||||
|
||||
/** 当前撞款行集:全局=撞款全集;分组=组内成员撞款子集。 */
|
||||
const dupRows = computed<DuplicateItem[]>(() => {
|
||||
if (scope.value === 'global') return dupItems.value
|
||||
return dupWithinGroup(dupItems.value, selectedGroupMembers())
|
||||
})
|
||||
|
||||
/** 矩阵列:分组范围=该组成员店;全店=全部可见店铺。 */
|
||||
const matrixShops = computed<string[]>(() => {
|
||||
if (scope.value === 'group') return selectedGroupMembers()
|
||||
return shops.value.map((shop) => shop.shopName)
|
||||
})
|
||||
|
||||
const activeGroupStat = computed(() => groupStats.value.find((group) => group.name === selectedGroup.value) || null)
|
||||
/** 撞款 Tab 计数:全店范围取后端权威 total_dup(防后端截断计数偏小);分组范围取组内子集长度。 */
|
||||
const totalDupCount = computed(() => (scope.value === 'global' ? (consoleData.value?.totalDup ?? dupRows.value.length) : dupRows.value.length))
|
||||
|
||||
function itemBrand(item: DuplicateItem): string {
|
||||
for (const occ of item.occurrences || []) if (occ.brand) return occ.brand
|
||||
return ''
|
||||
}
|
||||
|
||||
/* ----- 矩阵单元格辅助 ----- */
|
||||
function shopKey(name: string): string {
|
||||
return (name || '').trim().toLowerCase()
|
||||
}
|
||||
function cellOccurrences(item: DuplicateItem, shop: string): DuplicateOccurrence[] {
|
||||
const key = shopKey(shop)
|
||||
return (item.occurrences || []).filter((occ) => shopKey(occ.shopName) === key)
|
||||
}
|
||||
function cellCount(item: DuplicateItem, shop: string): number {
|
||||
return cellOccurrences(item, shop).length
|
||||
}
|
||||
function cellCountryDots(item: DuplicateItem, shop: string): string[] {
|
||||
const codes = new Set<string>()
|
||||
for (const occ of cellOccurrences(item, shop)) if (occ.country) codes.add(occ.country.toUpperCase())
|
||||
return [...codes]
|
||||
}
|
||||
function cellCountryCount(item: DuplicateItem, shop: string, code: string): number {
|
||||
return cellOccurrences(item, shop).filter((occ) => (occ.country || '').toUpperCase() === code).length
|
||||
}
|
||||
function cellTitle(item: DuplicateItem, shop: string): string {
|
||||
const count = cellCount(item, shop)
|
||||
if (!count) return `${shop} 无此 ASIN`
|
||||
const sites = cellCountryDots(item, shop).map((code) => `${asinCountryLabel(code)} ${cellCountryCount(item, shop, code)}条`).join('、')
|
||||
return `${shop} 在售 ${count} 条 · ${sites}`
|
||||
}
|
||||
|
||||
async function loadConsole() {
|
||||
loading.value = true
|
||||
loadError.value = ''
|
||||
try {
|
||||
consoleData.value = await fetchDuplicateConsole({ view: '', ...activeFilterParams() })
|
||||
shopGroups.value = indexShopGroups(shops.value)
|
||||
if (groupStats.value.length && !groupStats.value.some((group) => group.name === selectedGroup.value)) {
|
||||
selectedGroup.value = groupChips.value[0] ? groupChips.value[0].name : ''
|
||||
}
|
||||
} catch (error) {
|
||||
loadError.value = error instanceof Error ? error.message : '撞款数据加载失败'
|
||||
ElMessage.error(loadError.value)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLedger(page: number, resetSort = false) {
|
||||
ledgerLoading.value = true
|
||||
try {
|
||||
if (resetSort) {
|
||||
sortState.key = 'earliest'
|
||||
sortState.dir = 'asc'
|
||||
}
|
||||
const result: LedgerPage = await fetchDuplicateLedger({ view: '', ...activeFilterParams() }, page, 100, sortState.key, sortState.dir)
|
||||
ledgerItems.value = result.items
|
||||
ledgerTotal.value = result.total
|
||||
ledgerPage.value = result.page
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '台账加载失败')
|
||||
} finally {
|
||||
ledgerLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function applyQuery() {
|
||||
if (dateFrom() && dateTo() && dateFrom() > dateTo()) {
|
||||
ElMessage.warning('开始日期不能晚于结束日期')
|
||||
return
|
||||
}
|
||||
loadConsole()
|
||||
if (activeTab.value === 'ledger') loadLedger(1)
|
||||
}
|
||||
|
||||
function resetQuery() {
|
||||
filter.asin = ''
|
||||
filter.shop = ''
|
||||
filter.group = ''
|
||||
filter.country = ''
|
||||
filter.dateRange = []
|
||||
scope.value = 'global'
|
||||
selectedGroup.value = ''
|
||||
activeTab.value = 'dup'
|
||||
applyQuery()
|
||||
}
|
||||
|
||||
function switchTab(tab: Tab) {
|
||||
activeTab.value = tab
|
||||
if (tab === 'ledger') loadLedger(1, true)
|
||||
}
|
||||
|
||||
function switchScope(next: Scope) {
|
||||
scope.value = next
|
||||
if (next === 'group' && !selectedGroup.value && groupChips.value.length) selectedGroup.value = groupChips.value[0].name
|
||||
}
|
||||
|
||||
async function reAnalyze() {
|
||||
reanalyzing.value = true
|
||||
try {
|
||||
await fetchDuplicateConsole({ view: '', asin: '', shopName: '', country: '', site: '', dateFrom: '', dateTo: '' })
|
||||
await loadConsole()
|
||||
ElMessage.success('重新分析完成,已同步最新扫描结果')
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '重新分析失败')
|
||||
} finally {
|
||||
reanalyzing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
interface ShopSiteBlock {
|
||||
shopName: string
|
||||
groupName: string
|
||||
sites: { country: string; times: string[] }[]
|
||||
count: number
|
||||
}
|
||||
|
||||
/** occurrence 按 店铺 → 站点 → 时间 汇总成撞款卡结构。 */
|
||||
function shopBlocks(item: DuplicateItem, restrictShops: string[] = []): ShopSiteBlock[] {
|
||||
const restrict = new Set(restrictShops.map(shopKey))
|
||||
const order: string[] = []
|
||||
const blocks = new Map<string, ShopSiteBlock>()
|
||||
for (const occ of item.occurrences || []) {
|
||||
const shop = occ.shopName || ''
|
||||
if (restrict.size && !restrict.has(shopKey(shop))) continue
|
||||
let block = blocks.get(shop)
|
||||
if (!block) {
|
||||
block = { shopName: shop, groupName: occ.groupName || '', sites: [], count: 0 }
|
||||
blocks.set(shop, block)
|
||||
order.push(shop)
|
||||
}
|
||||
const country = occ.country || ''
|
||||
let site = block.sites.find((s) => s.country === country)
|
||||
if (!site) {
|
||||
site = { country, times: [] }
|
||||
block.sites.push(site)
|
||||
}
|
||||
if (occ.date) site.times.push(occ.date)
|
||||
block.count++
|
||||
}
|
||||
return order.map((shop) => blocks.get(shop) as ShopSiteBlock)
|
||||
}
|
||||
|
||||
function openDrawer(asin: string, brand: string, dupFlag: boolean, occurrences: DuplicateOccurrence[]) {
|
||||
drawerTitle.value = asin
|
||||
drawerBrand.value = brand
|
||||
drawerDup.value = dupFlag
|
||||
drawerOccurrences.value = [...occurrences]
|
||||
drawerVisible.value = true
|
||||
}
|
||||
|
||||
function openDupDrawer(item: DuplicateItem) {
|
||||
openDrawer(item.asin, itemBrand(item), item.shopCount >= 2, item.occurrences)
|
||||
}
|
||||
|
||||
function openLedgerDrawer(row: LedgerRow) {
|
||||
openDrawer(row.asin, row.brand, row.storeCount >= 2, row.occurrences)
|
||||
}
|
||||
|
||||
function onLedgerSort(change: { prop: string; order: string | null }) {
|
||||
const key = change.prop as LedgerSortKey
|
||||
if (!change.order || !key) return
|
||||
sortState.key = key
|
||||
sortState.dir = change.order === 'descending' ? 'desc' : 'asc'
|
||||
loadLedger(1)
|
||||
}
|
||||
|
||||
async function exportCsv() {
|
||||
let view = 'monitor'
|
||||
const params = activeFilterParams()
|
||||
if (activeTab.value === 'ledger') {
|
||||
view = 'all'
|
||||
} else if (scope.value === 'group' && selectedGroup.value) {
|
||||
params.shopName = selectedGroup.value
|
||||
}
|
||||
try {
|
||||
const blob = await requestDuplicateCsvBlob({ view, ...params }, view)
|
||||
saveBlob(blob, `店铺数据_${activeTab.value === 'ledger' ? '台账' : '撞款'}_${new Date().toISOString().slice(0, 10)}.csv`)
|
||||
ElMessage.success('已导出 CSV')
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '导出失败')
|
||||
}
|
||||
}
|
||||
|
||||
function saveBlob(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = filename
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function siteColor(code: string): string {
|
||||
const map: Record<string, string> = { UK: '#2E5BE6', DE: '#D9912B', FR: '#7C5CD6', ES: '#1F9D62', IT: '#D63A4A' }
|
||||
return map[code.toUpperCase()] || '#98A1B1'
|
||||
}
|
||||
|
||||
function displayDate(value: string): string {
|
||||
return (value || '').slice(0, 16)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadConsole()
|
||||
// 台账 Tab 计数打开即显示:挂载时预取第一页拿 total(对齐 reference 渲染即见台账条数)。
|
||||
void loadLedger(1)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack dup-console">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<h2>店铺数据撞款监控</h2>
|
||||
<p>按 ASIN×店铺×站点分析同一 ASIN 在多家自营店铺/分组内的重复上架;分组归属来自店铺管理,只读展示。</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<span v-if="overview" class="updated">数据更新时间 {{ overview.scannedAt || '—' }}</span>
|
||||
<el-button :loading="reanalyzing" @click="reAnalyze">重新分析</el-button>
|
||||
<el-button @click="exportCsv">导出CSV</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-alert v-if="loadError" type="error" :title="loadError" :closable="false" show-icon style="margin-bottom: 12px" />
|
||||
|
||||
<el-card shadow="never" style="margin-bottom: 14px">
|
||||
<div class="filter-grid">
|
||||
<div class="f-item">
|
||||
<label>ASIN</label>
|
||||
<el-input v-model="filter.asin" placeholder="模糊搜索ASIN" clearable @keyup.enter="applyQuery" />
|
||||
</div>
|
||||
<div class="f-item">
|
||||
<label>店铺</label>
|
||||
<el-select v-model="filter.shop" placeholder="全部店铺" clearable filterable>
|
||||
<el-option v-for="shop in shops" :key="shop.shopName" :label="shop.shopName" :value="shop.shopName" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="f-item">
|
||||
<label>分组</label>
|
||||
<el-select v-model="filter.group" placeholder="全部分组" clearable filterable>
|
||||
<el-option v-for="group in groupStats" :key="group.name" :label="group.name" :value="group.name" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="f-item">
|
||||
<label>国家/站点</label>
|
||||
<el-select v-model="filter.country" placeholder="全部国家" clearable>
|
||||
<el-option v-for="code in countryOptions" :key="code" :label="asinCountryLabel(code)" :value="code" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="f-item wide">
|
||||
<label>上架时间</label>
|
||||
<el-date-picker v-model="filter.dateRange" type="daterange" range-separator="至" start-placeholder="开始" end-placeholder="结束" value-format="YYYY-MM-DD" unlink-panels />
|
||||
</div>
|
||||
<div class="f-item btn-row">
|
||||
<el-button type="primary" @click="applyQuery">查询</el-button>
|
||||
<el-button @click="resetQuery">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<template v-if="consoleData && !loading">
|
||||
<!-- KPI -->
|
||||
<div class="kpi-row">
|
||||
<div v-for="kpi in kpis" :key="kpi.label" class="kpi-card" :class="{ danger: kpi.danger }">
|
||||
<div class="k-label">{{ kpi.label }}</div>
|
||||
<div class="k-value">{{ kpi.value }}</div>
|
||||
<div class="k-sub">{{ kpi.sub }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 店铺上架分布 -->
|
||||
<el-card shadow="never" style="margin-bottom: 14px">
|
||||
<div class="card-title">店铺上架分布 <span class="hint">各店铺在本批站点中的上架记录量</span></div>
|
||||
<div class="store-bars">
|
||||
<div v-for="bar in storeDistribution.list" :key="bar.name" class="s-bar">
|
||||
<div class="s-name">
|
||||
<span>{{ bar.name }}<span v-if="bar.group" class="s-group"> · {{ bar.group }}</span></span>
|
||||
<b>{{ bar.records.toLocaleString() }}</b>
|
||||
</div>
|
||||
<div class="s-track"><div class="s-fill" :style="{ width: Math.round((bar.records / storeDistribution.max) * 100) + '%' }"></div></div>
|
||||
<div class="s-meta">{{ bar.asins.toLocaleString() }} 个 ASIN</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 主区 Tabs -->
|
||||
<div class="tab-bar">
|
||||
<div class="tabs">
|
||||
<button class="tab" :class="{ active: activeTab === 'dup' }" @click="switchTab('dup')">撞款监控 <span class="t-count">{{ totalDupCount }}</span></button>
|
||||
<button class="tab" :class="{ active: activeTab === 'ledger' }" @click="switchTab('ledger')">全部ASIN台账 <span class="t-count">{{ ledgerTotal }}</span></button>
|
||||
</div>
|
||||
<el-button text type="primary" @click="groupInfoVisible = true">分组说明</el-button>
|
||||
</div>
|
||||
|
||||
<!-- ============ 撞款监控 ============ -->
|
||||
<template v-if="activeTab === 'dup'">
|
||||
<div class="scope-bar">
|
||||
<div class="seg">
|
||||
<button class="seg-btn" :class="{ active: scope === 'global' }" @click="switchScope('global')">全店撞款</button>
|
||||
<button class="seg-btn" :class="{ active: scope === 'group' }" @click="switchScope('group')">分组撞款</button>
|
||||
</div>
|
||||
<div v-if="scope === 'group'" class="group-chips">
|
||||
<template v-if="groupChips.length">
|
||||
<button v-for="group in groupChips" :key="group.name" class="g-chip" :class="{ active: selectedGroup === group.name, dup: group.dupCount > 0 }" @click="selectedGroup = group.name">
|
||||
{{ group.name }} <span class="g-count">{{ group.shopCount }}店 · {{ group.dupCount }}撞款</span>
|
||||
</button>
|
||||
</template>
|
||||
<span v-else class="dim">暂无 ≥2 家店铺的分组,无法做分组撞款分析</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="scope === 'group' && activeGroupStat" class="group-summary">
|
||||
<div class="gs-item"><div class="gs-v">{{ activeGroupStat.shopCount }}</div><div class="gs-l">组员店铺</div></div>
|
||||
<div class="gs-item"><div class="gs-v">{{ activeGroupStat.asinUnique.toLocaleString() }}</div><div class="gs-l">组内唯一ASIN</div></div>
|
||||
<div class="gs-item"><div class="gs-v">{{ activeGroupStat.recordCount.toLocaleString() }}</div><div class="gs-l">组内上架记录</div></div>
|
||||
<div class="gs-item" :class="{ warn: activeGroupStat.dupCount > 0 }"><div class="gs-v">{{ activeGroupStat.dupCount }}</div><div class="gs-l">组内撞款ASIN</div></div>
|
||||
</div>
|
||||
|
||||
<div class="section-title">撞款覆盖矩阵 <span class="hint">行 = ASIN,列 = 店铺;数字为该店在售此 ASIN 的记录量,点击行看明细</span></div>
|
||||
<div class="matrix-wrap">
|
||||
<table v-if="dupRows.length" class="matrix">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ASIN</th>
|
||||
<th>品牌</th>
|
||||
<th v-for="shop in matrixShops" :key="shop" style="text-align: center">{{ shop }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in dupRows" :key="item.asin" @click="openDupDrawer(item)">
|
||||
<td class="asin-cell">{{ item.asin }}</td>
|
||||
<td class="brand-cell">{{ itemBrand(item) || '—' }}</td>
|
||||
<td v-for="shop in matrixShops" :key="shop" style="text-align: center">
|
||||
<div class="m-cell" :class="{ empty: cellCount(item, shop) === 0 }" :title="cellTitle(item, shop)">
|
||||
{{ cellCount(item, shop) || '' }}
|
||||
<span class="dots"><span v-for="code in cellCountryDots(item, shop)" :key="code" class="m-dot" :style="{ background: siteColor(code) }"></span></span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<el-empty v-else description="当前范围下没有撞款ASIN" />
|
||||
</div>
|
||||
|
||||
<div class="section-title" style="margin-top: 22px">撞款详情 <span class="hint">同一 ASIN 在多家店铺的上架时间明细</span></div>
|
||||
<div v-if="dupRows.length" class="dup-cards">
|
||||
<div v-for="item in dupRows" :key="item.asin" class="dup-card">
|
||||
<div class="dup-head">
|
||||
<span class="dup-asin">{{ item.asin }}</span>
|
||||
<span class="badge dup">{{ item.shopCount }}店在售</span>
|
||||
<span class="dup-brand">{{ itemBrand(item) || '未知品牌' }}</span>
|
||||
<el-button text type="primary" size="small" @click="openDupDrawer(item)">查看明细</el-button>
|
||||
</div>
|
||||
<div class="dup-body">
|
||||
<div v-for="block in shopBlocks(item)" :key="block.shopName" class="store-col">
|
||||
<div class="st-name">{{ block.shopName }}<span class="st-count">{{ block.count }} 条</span></div>
|
||||
<div v-if="block.groupName" class="st-grp">分组:{{ block.groupName }}</div>
|
||||
<div v-for="site in block.sites" :key="site.country" class="c-site">
|
||||
<div class="c-site-head">
|
||||
<span class="m-dot" :style="{ background: siteColor(site.country) }"></span>{{ asinCountryLabel(site.country) }} · {{ site.times.length }} 次
|
||||
</div>
|
||||
<div class="c-times">
|
||||
<span v-for="(time, index) in site.times" :key="index" class="c-time">{{ time }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="当前范围下没有撞款ASIN" />
|
||||
</template>
|
||||
|
||||
<!-- ============ 全部ASIN台账 ============ -->
|
||||
<template v-else>
|
||||
<el-card shadow="never">
|
||||
<el-table v-loading="ledgerLoading" :data="ledgerItems" stripe border @sort-change="onLedgerSort">
|
||||
<el-table-column prop="asin" label="ASIN" min-width="140" sortable="custom">
|
||||
<template #default="{ row }">
|
||||
<el-link type="primary" @click="openLedgerDrawer(row as LedgerRow)">{{ row.asin }}</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="brand" label="品牌" min-width="120" sortable="custom" />
|
||||
<el-table-column prop="storeCount" label="上架店铺" width="110" sortable="custom" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="(row as LedgerRow).storeCount >= 2 ? 'danger' : 'success'" size="small">{{ (row as LedgerRow).storeCount }} 店</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="涉及店铺" min-width="190">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-for="store in (row as LedgerRow).stores" :key="store" size="small" class="tag-gap" type="info" effect="plain">{{ store }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="分组" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<template v-if="(row as LedgerRow).groups.length">
|
||||
<el-tag v-for="group in (row as LedgerRow).groups" :key="group" size="small" class="tag-gap">{{ group }}</el-tag>
|
||||
</template>
|
||||
<span v-else class="dim">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="站点" min-width="130">
|
||||
<template #default="{ row }">
|
||||
<span v-for="code in (row as LedgerRow).countries" :key="code" class="site-chip" :style="{ background: siteColor(code) + '1F', color: siteColor(code) }">{{ asinCountryLabel(code) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="recordCount" label="上架次数" width="100" sortable="custom" align="center" />
|
||||
<el-table-column prop="earliest" label="最早上架" min-width="165" sortable="custom">
|
||||
<template #default="{ row }">{{ displayDate((row as LedgerRow).earliest) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="latest" label="最近上架" min-width="165" sortable="custom">
|
||||
<template #default="{ row }">{{ displayDate((row as LedgerRow).latest) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button text type="primary" size="small" @click="openLedgerDrawer(row as LedgerRow)">明细</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="table-footer">
|
||||
<span>共 <b>{{ ledgerTotal.toLocaleString() }}</b> 条</span>
|
||||
<el-pagination background layout="prev, pager, next, jumper" :total="ledgerTotal" :page-size="100" :current-page="ledgerPage" @current-change="(page: number) => loadLedger(page)" />
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<div v-else-if="!loadError && !loading" class="empty-state" v-loading="loading">
|
||||
<el-empty description="暂无扫描结果,请点击右上角「重新分析」触发撞款扫描" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 明细抽屉 -->
|
||||
<el-drawer v-model="drawerVisible" :title="drawerTitle" size="600px">
|
||||
<div class="drawer-head">
|
||||
<span v-if="drawerDup" class="badge dup">撞款</span>
|
||||
<span class="d-brand">{{ drawerTitle }} · 品牌:{{ drawerBrand || '未知' }}</span>
|
||||
</div>
|
||||
<div v-if="drawerOccurrences.length" class="drawer-badges">
|
||||
<span class="badge store">{{ drawerSummary.shopCount }} 家店铺</span>
|
||||
<span v-for="code in drawerSummary.countries" :key="code" class="chip" :style="{ background: siteColor(code) }">{{ asinCountryLabel(code) }}</span>
|
||||
<span class="badge count">{{ drawerSummary.totalRecords }} 次上架</span>
|
||||
<span class="badge time">最早上架 {{ drawerSummary.earliest }}</span>
|
||||
</div>
|
||||
<el-table v-if="drawerAggRows.length" :data="drawerAggRows" border size="small">
|
||||
<el-table-column prop="shopName" label="店铺" min-width="130" />
|
||||
<el-table-column label="分组" min-width="90">
|
||||
<template #default="{ row }">{{ (row as DrawerAggRow).groupName || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="站点" min-width="80">
|
||||
<template #default="{ row }">{{ asinCountryLabel((row as DrawerAggRow).country || '') }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="上架时间(升序)" min-width="170">
|
||||
<template #default="{ row }">
|
||||
<div v-for="(time, index) in (row as DrawerAggRow).times" :key="index" class="drawer-time">{{ time }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="次数" min-width="70" align="center">
|
||||
<template #default="{ row }">{{ (row as DrawerAggRow).count }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-else description="暂无上架时间明细" />
|
||||
</el-drawer>
|
||||
|
||||
<!-- 分组说明(只读) -->
|
||||
<el-drawer v-model="groupInfoVisible" title="分组撞款说明" size="520px">
|
||||
<el-alert type="info" :closable="false" show-icon title="分组归属来自「店铺管理」的真实店铺分组,仅用于本页分组撞款范围切换;本页不做分组编辑。如需调整店铺所属分组,请到店铺管理维护(会影响对应负责人的数据查看范围)。" />
|
||||
<el-table :data="groupStats" border style="margin-top: 14px">
|
||||
<el-table-column prop="name" label="分组" min-width="110" />
|
||||
<el-table-column label="店铺数" width="80" align="center">
|
||||
<template #default="{ row }">{{ (row as { shopCount: number }).shopCount }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="成员店铺" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-for="shop in (shopGroups.members[(row as { name: string }).name] || [])" :key="shop" size="small" class="tag-gap" effect="plain">{{ shop }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="组内撞款" width="90" align="center">
|
||||
<template #default="{ row }">{{ (row as { dupCount: number }).dupCount }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dup-console { max-width: 1560px; }
|
||||
.page-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; flex-wrap: wrap; }
|
||||
.page-heading .actions { display: flex; align-items: center; gap: 10px; }
|
||||
.updated { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; }
|
||||
.f-item { display: flex; flex-direction: column; gap: 6px; width: 170px; }
|
||||
.f-item label { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.f-item.wide { width: 320px; }
|
||||
.f-item.btn-row { flex-direction: row; align-items: flex-end; gap: 8px; width: auto; }
|
||||
.kpi-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; margin-bottom: 14px; }
|
||||
.kpi-card { background: var(--el-bg-color); border: 1px solid var(--el-border-color-lighter); border-radius: 10px; padding: 12px 16px; box-shadow: var(--el-box-shadow-lighter); }
|
||||
.k-label { color: var(--el-text-color-secondary); font-size: 12px; margin-bottom: 6px; }
|
||||
.k-value { font-size: 26px; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.kpi-card.danger .k-value { color: var(--el-color-danger); }
|
||||
.k-sub { color: var(--el-text-color-secondary); font-size: 11px; margin-top: 3px; }
|
||||
.card-title { font-size: 13px; font-weight: 600; margin-bottom: 12px; display: flex; gap: 8px; align-items: baseline; }
|
||||
.hint { font-size: 11px; font-weight: 400; color: var(--el-text-color-secondary); }
|
||||
.store-bars { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 16px; }
|
||||
.s-name { display: flex; justify-content: space-between; font-size: 12px; color: var(--el-text-color-secondary); margin-bottom: 5px; }
|
||||
.s-name b { color: var(--el-text-color-primary); font-weight: 600; }
|
||||
.s-group { color: var(--el-text-color-secondary); }
|
||||
.s-track { height: 8px; background: var(--el-fill-color-light); border-radius: 5px; overflow: hidden; }
|
||||
.s-fill { height: 100%; border-radius: 5px; background: linear-gradient(90deg, #6366f1, #8b9cf6); }
|
||||
.s-meta { font-size: 11px; color: var(--el-text-color-secondary); margin-top: 5px; }
|
||||
.tab-bar { display: flex; justify-content: space-between; align-items: center; margin: 4px 0 14px; }
|
||||
.tabs { display: flex; gap: 4px; background: var(--el-bg-color); border: 1px solid var(--el-border-color-lighter); border-radius: 9px; padding: 4px; }
|
||||
.tab { padding: 7px 18px; border-radius: 7px; font-size: 13px; font-weight: 600; color: var(--el-text-color-regular); cursor: pointer; background: transparent; border: none; display: inline-flex; gap: 7px; align-items: center; }
|
||||
.tab:hover { color: var(--el-color-primary); }
|
||||
.tab.active { background: var(--el-color-primary); color: #fff; }
|
||||
.t-count { background: rgba(0, 0, 0, 0.12); border-radius: 10px; padding: 0 7px; font-size: 11px; }
|
||||
.tab.active .t-count { background: rgba(255, 255, 255, 0.25); }
|
||||
.scope-bar { display: flex; align-items: center; gap: 16px; margin-bottom: 14px; flex-wrap: wrap; }
|
||||
.seg { display: flex; gap: 4px; background: var(--el-bg-color); border: 1px solid var(--el-border-color-lighter); border-radius: 9px; padding: 4px; }
|
||||
.seg-btn { padding: 7px 16px; border-radius: 7px; font-size: 13px; font-weight: 600; color: var(--el-text-color-regular); border: none; background: transparent; cursor: pointer; }
|
||||
.seg-btn:hover { color: var(--el-color-primary); }
|
||||
.seg-btn.active { background: var(--el-color-primary); color: #fff; }
|
||||
.group-chips { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
.g-chip { padding: 7px 14px; border-radius: 20px; font-size: 12.5px; font-weight: 600; background: var(--el-bg-color); border: 1px solid var(--el-border-color); color: var(--el-text-color-regular); cursor: pointer; transition: all 0.15s; }
|
||||
.g-chip:hover { border-color: var(--el-color-primary); color: var(--el-color-primary); }
|
||||
.g-chip.active { background: var(--el-color-primary); border-color: var(--el-color-primary); color: #fff; }
|
||||
.g-chip.dup:not(.active) { border-color: #f3c6cc; background: #fdebed; color: #d63a4a; }
|
||||
.g-count { font-size: 11px; opacity: 0.85; }
|
||||
.dim { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.group-summary { display: grid; grid-template-columns: repeat(4, 1fr); background: var(--el-bg-color); border: 1px solid var(--el-border-color-lighter); border-radius: 10px; margin-bottom: 16px; overflow: hidden; }
|
||||
.gs-item { padding: 14px 20px; border-right: 1px solid var(--el-border-color-lighter); }
|
||||
.gs-item:last-child { border-right: none; }
|
||||
.gs-v { font-size: 24px; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.gs-item.warn .gs-v { color: var(--el-color-danger); }
|
||||
.gs-l { font-size: 12px; color: var(--el-text-color-secondary); margin-top: 4px; }
|
||||
.section-title { font-size: 14px; font-weight: 700; display: flex; align-items: baseline; gap: 8px; margin-bottom: 12px; }
|
||||
.matrix-wrap { overflow-x: auto; border-radius: 10px; background: var(--el-bg-color); border: 1px solid var(--el-border-color-lighter); }
|
||||
table.matrix { border-collapse: separate; border-spacing: 0; width: 100%; font-size: 12.5px; }
|
||||
table.matrix th { background: var(--el-fill-color-lighter); color: var(--el-text-color-regular); font-size: 12px; font-weight: 600; padding: 10px 12px; border-bottom: 1px solid var(--el-border-color-lighter); text-align: left; white-space: nowrap; }
|
||||
table.matrix td { padding: 8px 12px; border-bottom: 1px solid var(--el-border-color-extra-light); white-space: nowrap; }
|
||||
table.matrix tbody tr { cursor: pointer; }
|
||||
table.matrix tbody tr:hover td { background: var(--el-fill-color-light); }
|
||||
.asin-cell { font-family: 'Cascadia Mono', Consolas, monospace; font-weight: 700; color: var(--el-color-primary); }
|
||||
.brand-cell { color: var(--el-text-color-secondary); }
|
||||
.m-cell { width: 42px; height: 30px; border-radius: 7px; display: inline-flex; align-items: center; justify-content: center; font-weight: 700; color: var(--el-color-primary); background: var(--el-color-primary-light-9); position: relative; }
|
||||
.m-cell.empty { background: var(--el-fill-color-light); color: var(--el-text-color-placeholder); font-weight: 400; }
|
||||
.dots { position: absolute; bottom: 2px; left: 0; right: 0; display: flex; justify-content: center; gap: 2px; }
|
||||
.m-dot { width: 5px; height: 5px; border-radius: 50%; display: inline-block; }
|
||||
.dup-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap: 14px; }
|
||||
.dup-card { background: var(--el-bg-color); border: 1px solid var(--el-border-color-lighter); border-radius: 10px; overflow: hidden; }
|
||||
.dup-head { display: flex; align-items: center; gap: 10px; padding: 11px 14px; border-bottom: 1px solid var(--el-border-color-lighter); background: var(--el-fill-color-lighter); flex-wrap: wrap; }
|
||||
.dup-asin { font-family: 'Cascadia Mono', Consolas, monospace; font-weight: 700; color: var(--el-color-primary); font-size: 13px; }
|
||||
.badge { display: inline-flex; align-items: center; border-radius: 20px; padding: 2px 9px; font-size: 11px; font-weight: 600; white-space: nowrap; }
|
||||
.badge.dup { background: #fdebed; color: #d63a4a; }
|
||||
.dup-brand { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.dup-body { padding: 12px 14px; display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 16px; }
|
||||
.store-col .st-name { font-size: 12.5px; font-weight: 700; margin-bottom: 6px; display: flex; gap: 6px; align-items: center; }
|
||||
.st-count { font-size: 11px; color: var(--el-text-color-secondary); font-weight: 400; }
|
||||
.st-grp { font-size: 10.5px; color: var(--el-text-color-secondary); margin-bottom: 4px; }
|
||||
.c-site { font-size: 11px; color: var(--el-text-color-regular); margin-top: 6px; }
|
||||
.c-site-head { font-weight: 600; display: flex; gap: 5px; align-items: center; }
|
||||
.c-times { margin-top: 2px; }
|
||||
.c-time { font-family: 'Cascadia Mono', Consolas, monospace; font-size: 11px; color: var(--el-text-color-secondary); line-height: 1.7; display: block; }
|
||||
.site-chip { border-radius: 5px; padding: 1px 7px; font-size: 11.5px; margin-right: 4px; font-weight: 600; display: inline-block; }
|
||||
.tag-gap { margin-right: 4px; margin-bottom: 2px; }
|
||||
.table-footer { display: flex; justify-content: space-between; align-items: center; padding-top: 14px; }
|
||||
.table-footer span { color: var(--el-text-color-secondary); font-size: 12.5px; }
|
||||
.table-footer b { color: var(--el-text-color-primary); }
|
||||
.drawer-head { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
|
||||
.d-brand { color: var(--el-text-color-secondary); font-size: 13px; }
|
||||
/* 明细抽屉汇总徽章组(对齐 reference:N家店铺/国家chips/N次上架/最早上架)。 */
|
||||
.drawer-badges { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
|
||||
.badge.store { background: #e8f1fa; color: #2f5d8b; }
|
||||
.badge.count { background: #fdebed; color: #d63a4a; }
|
||||
.badge.time { background: var(--el-fill-color-light); color: var(--el-text-color-regular); }
|
||||
.chip { border-radius: 20px; padding: 2px 9px; font-size: 11px; font-weight: 600; color: #fff; white-space: nowrap; }
|
||||
.drawer-time { font-family: 'Cascadia Mono', Consolas, monospace; font-size: 11.5px; color: var(--el-text-color-regular); line-height: 1.7; }
|
||||
/* 矩阵表头吸顶(对齐 reference sticky 表头)。 */
|
||||
.matrix-wrap { max-height: 480px; overflow-y: auto; }
|
||||
table.matrix th { position: sticky; top: 0; z-index: 1; }
|
||||
.empty-state { margin-top: 12px; }
|
||||
</style>
|
||||
@@ -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<string, string> {
|
||||
const query: Record<string, string> = {}
|
||||
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<DuplicateConsole> {
|
||||
const { data } = await http.get<unknown>(`${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<LedgerPage> {
|
||||
const params: Record<string, string | number> = { ...toConsoleQuery(filter), page, page_size: pageSize }
|
||||
if (sortKey) params.sort_key = sortKey
|
||||
params.sort_dir = sortDir
|
||||
const { data } = await http.get<unknown>(`${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<Blob> {
|
||||
const { data } = await http.get<Blob>(`${DUPLICATE_CHECK_ENDPOINT}/duplicate-check-export`, {
|
||||
params: toDuplicateItemsQuery({ ...filter, view }, 1, 100),
|
||||
responseType: 'blob',
|
||||
})
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/** 撞款控制台视图派生(分组索引 / 组内撞款子集 / 店铺列并集):纯逻辑。 */
|
||||
import type { DuplicateItem, DuplicateOccurrence, DuplicateShop } from './duplicate-model.ts'
|
||||
|
||||
export interface ShopGroups {
|
||||
/** 分组名 → 成员店铺(展示名,保持首次出现顺序)。 */
|
||||
members: Record<string, string[]>
|
||||
/** 店铺展示名 → 所属分组名(含多分组拆分的每一组)。 */
|
||||
shopGroupNames: Record<string, string[]>
|
||||
}
|
||||
|
||||
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<string, string[]> = {}
|
||||
const shopGroupNames: Record<string, string[]> = {}
|
||||
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<string>()
|
||||
for (const shop of memberShops || []) memberKeys.add(shopKey(shop))
|
||||
const result: DuplicateItem[] = []
|
||||
for (const item of items || []) {
|
||||
const kept: DuplicateOccurrence[] = []
|
||||
const shops = new Set<string>()
|
||||
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<string>()
|
||||
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<string, DrawerAggRow>()
|
||||
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<string>()
|
||||
const countries: string[] = []
|
||||
const seenCountry = new Set<string>()
|
||||
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 }
|
||||
}
|
||||
@@ -9,6 +9,8 @@ export interface DuplicateFilter {
|
||||
view: string
|
||||
asin: string
|
||||
shopName: string
|
||||
/** 分组筛选(撞款分组名,后端按组员店铺裁剪可见集)。 */
|
||||
group?: string
|
||||
country: string
|
||||
site: string
|
||||
dateFrom: string
|
||||
|
||||
@@ -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/, '矩阵容器限高滚动')
|
||||
})
|
||||
+52
@@ -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<Object> 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<String> visibleKeys = queryService.resolveVisibleShopKeys(operator.id(), operator.superAdmin());
|
||||
Set<String> effectiveKeys = queryService.withGroupFilter(visibleKeys, scan, group);
|
||||
return ApiResponse.success(queryService.consoleData(scan, effectiveKeys, filters));
|
||||
}
|
||||
|
||||
@GetMapping("/duplicate-check-ledger")
|
||||
@Operation(summary = "全部 ASIN 台账(含未撞款):筛选 + 列排序 + 分页")
|
||||
public ApiResponse<Object> 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<String> visibleKeys = queryService.resolveVisibleShopKeys(operator.id(), operator.superAdmin());
|
||||
Set<String> 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(
|
||||
|
||||
+216
@@ -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<String> withGroupFilter(Set<String> visibleKeys, DuplicateScanView view, String groupName) {
|
||||
String group = trim(groupName);
|
||||
if (group.isEmpty()) {
|
||||
return visibleKeys;
|
||||
}
|
||||
Set<String> 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<DuplicateShop> cropShops(List<DuplicateShop> shops, Set<String> 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<String> 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<String> stores, List<String> groups,
|
||||
List<String> countries, int recordCount, String earliest, String latest,
|
||||
List<DuplicateOccurrence> occurrences) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 全部 ASIN 台账(含单店未撞款):筛选 + 可选列排序 + 分页。
|
||||
* sortKey 白名单外回退 earliest,sortDir 非 desc 视为 asc。
|
||||
*/
|
||||
public Map<String, Object> ledgerData(DuplicateScanView view, Set<String> visibleKeys,
|
||||
int page, int pageSize, String sortKey, String sortDir,
|
||||
Filters filters) {
|
||||
List<DuplicateItem> cropped = cropItems(view.payload().items(), visibleKeys);
|
||||
List<DuplicateItem> 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<LedgerRow> 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<Map<String, Object>> 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<String, Object> 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<String, Object> consoleData(DuplicateScanView view, Set<String> visibleKeys, Filters filters) {
|
||||
List<DuplicateShop> shops = cropShops(view.payload().shops(), visibleKeys);
|
||||
List<DuplicateItem> cropped = cropItems(view.payload().items(), visibleKeys);
|
||||
String source = view.summary() == null ? "" : safe(view.summary().source());
|
||||
DuplicateScanSummary summary = recomputeSummary(cropped, shops, source);
|
||||
List<DuplicateItem> dup = filterItems(cropped, true, filters);
|
||||
List<Map<String, Object>> 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<String, Object> 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<String> stores = new LinkedHashSet<>();
|
||||
LinkedHashSet<String> groups = new LinkedHashSet<>();
|
||||
LinkedHashSet<String> 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<String, Object> 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<Map<String, Object>> groupStats(List<DuplicateShop> shops, List<DuplicateItem> matched) {
|
||||
LinkedHashMap<String, Set<String>> 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<String> 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<String> 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<String> 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<Map<String, Object>> 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<String> splitGroups(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> 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<String> visibleKeys,
|
||||
String viewMode, Filters filters, OutputStream out) throws IOException {
|
||||
boolean monitor = "monitor".equals(viewMode);
|
||||
|
||||
+119
@@ -137,6 +137,113 @@ class ShopDataDuplicateCheckQueryServiceTest {
|
||||
assertThat(list.get("scanned_at")).isEqualTo("");
|
||||
}
|
||||
|
||||
@Test
|
||||
void consoleSuperAdminReturnsDupSetGroupsAndSummary() {
|
||||
Map<String, Object> data = service.consoleData(view, null, noFilter());
|
||||
assertThat(data.get("pending")).isEqualTo(false);
|
||||
assertThat(data.get("total_dup")).isEqualTo(3);
|
||||
@SuppressWarnings("unchecked")
|
||||
List<DuplicateItem> dup = (List<DuplicateItem>) 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<Map<String, Object>> groups = (List<Map<String, Object>>) data.get("groups");
|
||||
assertThat(groups).extracting(map -> map.get("name"))
|
||||
.containsExactlyInAnyOrder("GroupA", "GroupC", "GroupD");
|
||||
Map<String, Object> 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<String> visible = Set.of("shopa", "shopb");
|
||||
Map<String, Object> data = service.consoleData(view, visible, noFilter());
|
||||
assertThat(data.get("total_dup")).isEqualTo(2);
|
||||
@SuppressWarnings("unchecked")
|
||||
List<DuplicateItem> dup = (List<DuplicateItem>) data.get("dup");
|
||||
assertThat(dup).extracting(DuplicateItem::asin)
|
||||
.containsExactlyInAnyOrder("A0000001", "E0000001");
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> groups = (List<Map<String, Object>>) data.get("groups");
|
||||
assertThat(groups).hasSize(1);
|
||||
assertThat(groups.get(0).get("name")).isEqualTo("GroupA");
|
||||
@SuppressWarnings("unchecked")
|
||||
List<DuplicateShop> shops = (List<DuplicateShop>) data.get("shops");
|
||||
assertThat(shops).extracting(DuplicateShop::shopName).containsExactly("ShopA", "ShopB");
|
||||
}
|
||||
|
||||
@Test
|
||||
void consolePendingShapes() {
|
||||
Map<String, Object> 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<String, Object> data = service.ledgerData(view, null, 1, 200, "asin", "asc", filters);
|
||||
assertThat(data.get("total")).isEqualTo(1);
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> items = (List<Map<String, Object>>) data.get("items");
|
||||
Map<String, Object> 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<String, Object> data = service.ledgerData(view, null, 1, 200, "store_count", "desc", noFilter());
|
||||
assertThat(data.get("total")).isEqualTo(6);
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> items = (List<Map<String, Object>>) data.get("items");
|
||||
assertThat(items.get(0).get("asin")).isEqualTo("E0000001");
|
||||
assertThat(items.get(0).get("store_count")).isEqualTo(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ledgerLeaderCropCountsOnlyVisibleStores() {
|
||||
Set<String> visible = Set.of("shopa", "shopb");
|
||||
ShopDataDuplicateCheckQueryService.Filters filters =
|
||||
ShopDataDuplicateCheckQueryService.Filters.clean("E0000001", "", "", "", "", "");
|
||||
Map<String, Object> data = service.ledgerData(view, visible, 1, 200, "", "", filters);
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> items = (List<Map<String, Object>>) 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<String, Object> 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<String> keys = service.withGroupFilter(null, view, "GroupA");
|
||||
assertThat(keys).containsExactlyInAnyOrder("shopa", "shopb");
|
||||
// 与数据范围交集:可见集只剩 ShopA 时,交集只剩 ShopA。
|
||||
Set<String> cropped = service.withGroupFilter(Set.of("shopa"), view, "GroupA");
|
||||
assertThat(cropped).containsExactly("shopa");
|
||||
// 空分组名不裁剪。
|
||||
assertThat(service.withGroupFilter(null, view, " ")).isNull();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user