后台店铺数据重复检查独立成菜单:删除店铺导入测试、新增撞款详情卡片区
- 删除「店铺导入测试」菜单及全部实现:admin.html 面板、admin.js 逻辑、 admin_api.py 路由/常量/权限映射、Java 默认菜单 - 「店铺数据记录」页内子 tab 移除,只保留店铺数据列表 - 「店铺管理」下新增「店铺数据重复检查」菜单(V106 导入测试菜单保留并新增 V107 清理 + 幂等插入新菜单,Java 清单同步) - 新面板:筛选/指标卡/店铺上架分布/矩阵表格/撞款详情卡片区(同 ASIN 跨店 上架明细,查看明细开抽屉,上架时间倒序) - 重复扫描结果改为 MySQL 缓存 + 每日定时扫描(start_duplicate_scan_scheduler) - 新增 test_admin_shop_data_duplicate_check.py 单元测试
This commit is contained in:
+1
@@ -71,6 +71,7 @@ public class PermissionMenuSchemaInitializer {
|
||||
new DefaultAdminMenu("店铺管理", "admin_shop_manage", "shop-manage", 50, "admin_group_shop"),
|
||||
new DefaultAdminMenu("最低价ASIN设置", "admin_skip_price_asin", "skip-price-asin", 60, "admin_group_shop"),
|
||||
new DefaultAdminMenu("店铺数据记录", "admin_shop_data_crawl_tasks", "shop-data-crawl-tasks", 82, "admin_group_shop"),
|
||||
new DefaultAdminMenu("店铺数据重复检查", "admin_shop_data_duplicate_check", "shop-data-duplicate-check", 84, "admin_group_shop"),
|
||||
new DefaultAdminMenu("生成记录", "admin_history", "history", 70, "admin_group_record"),
|
||||
new DefaultAdminMenu("视频任务记录", "admin_image_video_tasks", "image-video-tasks", 75, "admin_group_record"),
|
||||
new DefaultAdminMenu("软件版本管理", "admin_version", "version", 80, "admin_group_record"),
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- V106: 新增「店铺导入测试」后台菜单(用于重复 ASIN 结果模拟验证)
|
||||
-- 幂等:仅当 column_key 不存在时插入,绑定到「店铺管理」分组(admin_group_shop)。
|
||||
INSERT INTO columns (name, column_key, menu_type, route_path, sort_order, parent_id)
|
||||
SELECT '店铺导入测试', 'admin_shop_data_import', 'admin', 'shop-data-import', 83, parent.id
|
||||
FROM columns parent
|
||||
WHERE parent.column_key = 'admin_group_shop'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM columns WHERE column_key = 'admin_shop_data_import'
|
||||
);
|
||||
@@ -0,0 +1,18 @@
|
||||
-- V107: 「店铺导入测试」菜单下线,新增「店铺数据重复检查」菜单
|
||||
-- 1) 删除「店铺导入测试」(admin_shop_data_import)后台菜单行与其授权记录(幂等)
|
||||
DELETE FROM user_column_permission
|
||||
WHERE column_id IN (
|
||||
SELECT id FROM columns WHERE column_key = 'admin_shop_data_import'
|
||||
);
|
||||
|
||||
DELETE FROM columns WHERE column_key = 'admin_shop_data_import';
|
||||
|
||||
-- 2) 新增「店铺数据重复检查」菜单,挂到「店铺管理」分组(admin_group_shop)
|
||||
-- 幂等:仅当 column_key 不存在时插入。
|
||||
INSERT INTO columns (name, column_key, menu_type, route_path, sort_order, parent_id)
|
||||
SELECT '店铺数据重复检查', 'admin_shop_data_duplicate_check', 'admin', 'shop-data-duplicate-check', 84, parent.id
|
||||
FROM columns parent
|
||||
WHERE parent.column_key = 'admin_group_shop'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM columns WHERE column_key = 'admin_shop_data_duplicate_check'
|
||||
);
|
||||
+3
-1
@@ -12,7 +12,7 @@ from flask_cors import CORS
|
||||
from utils.db import init_db
|
||||
from blueprints.auth import auth
|
||||
from blueprints.main import main
|
||||
from blueprints.admin_api import admin_api
|
||||
from blueprints.admin_api import admin_api, start_duplicate_scan_scheduler
|
||||
from blueprints.version import version_bp
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
@@ -50,6 +50,8 @@ app.register_blueprint(version_bp)
|
||||
|
||||
def run_app(host='0.0.0.0', port=15124):
|
||||
init_db()
|
||||
# 每日凌晨全量扫描重复 ASIN,结果缓存 MySQL,页面读取缓存不再实时拉取 Excel
|
||||
start_duplicate_scan_scheduler(app)
|
||||
app.run(host=host, port=port, threaded=True, use_reloader=False)
|
||||
|
||||
|
||||
|
||||
+798
-151
File diff suppressed because it is too large
Load Diff
+577
-202
@@ -97,7 +97,7 @@
|
||||
var ADMIN_MENU_GROUPS = [
|
||||
{ key: 'account', title: '账号与权限', items: ['users', 'columns', 'group-manage'] },
|
||||
{ key: 'data', title: '数据管理', items: ['dedupe-total-data', 'invalid-asin-data', 'query-asin', 'product-categories'] },
|
||||
{ key: 'shop', title: '店铺管理', items: ['shop-keys', 'shop-manage', 'skip-price-asin', 'shop-data-crawl-tasks'] },
|
||||
{ key: 'shop', title: '店铺管理', items: ['shop-keys', 'shop-manage', 'skip-price-asin', 'shop-data-crawl-tasks', 'shop-data-duplicate-check'] },
|
||||
{ key: 'record', title: '记录与版本', items: ['history', 'version', 'digital-human-version', 'image-video-tasks'] }
|
||||
];
|
||||
var ADMIN_MENU_ICONS = {
|
||||
@@ -113,6 +113,7 @@
|
||||
'product-categories': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"></path></svg>',
|
||||
'image-video-tasks': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m22 8-6 4 6 4V8Z"></path><rect width="14" height="12" x="2" y="6" rx="2" ry="2"></rect></svg>',
|
||||
'shop-data-crawl-tasks': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><ellipse cx="12" cy="5" rx="9" ry="3"></ellipse><path d="M3 5v14a9 3 0 0 0 18 0V5"></path><path d="M3 12a9 3 0 0 0 18 0"></path></svg>',
|
||||
'shop-data-duplicate-check': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21.35 11.1h-9.17a2 2 0 0 1-1.75-2.98l1.67-2.79a2 2 0 0 0-1.75-2.98l-5.98-.01a2 2 0 0 0-2 2v1.5a2 2 0 0 0 2 2h3.36l-2.38 3.97a2 2 0 0 0 1.75 2.98h11.5a2 2 0 0 0 2-2v-1.5a2 2 0 0 0-2-2Z"></path><path d="M3 21h18"></path></svg>',
|
||||
'history': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path><path d="M12 7v5l4 2"></path></svg>',
|
||||
'version': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m7.5 4.27 9 5.15"></path><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"></path><path d="M3.3 7 12 12l8.7-5"></path><path d="M12 22V12"></path></svg>',
|
||||
'digital-human-version': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 8V4H8"></path><rect width="16" height="12" x="4" y="8" rx="2"></rect><path d="M2 14h2"></path><path d="M20 14h2"></path><path d="M15 13v2"></path><path d="M9 13v2"></path></svg>'
|
||||
@@ -139,6 +140,7 @@
|
||||
'product-categories': 'panel-product-categories',
|
||||
'image-video-tasks': 'panel-image-video-tasks',
|
||||
'shop-data-crawl-tasks': 'panel-shop-data-crawl-tasks',
|
||||
'shop-data-duplicate-check': 'panel-shop-data-duplicate-check',
|
||||
'history': 'panel-history',
|
||||
'version': 'panel-version',
|
||||
'digital-human-version': 'panel-digital-human-version'
|
||||
@@ -162,6 +164,7 @@
|
||||
else if (tabName === 'product-categories') loadProductCategories();
|
||||
else if (tabName === 'image-video-tasks') loadImageVideoTasks(1);
|
||||
else if (tabName === 'shop-data-crawl-tasks') loadShopDataCrawlTasks(1);
|
||||
else if (tabName === 'shop-data-duplicate-check') loadShopDataDuplicateCheckOverview();
|
||||
else if (tabName === 'history') loadHistory(1);
|
||||
else if (tabName === 'version') loadSoftwareVersions();
|
||||
else if (tabName === 'digital-human-version') loadDigitalHumanVersions();
|
||||
@@ -1778,22 +1781,45 @@
|
||||
}
|
||||
|
||||
function renderShopDataRecordTable() {
|
||||
var rows = shopDataTasks.map(renderShopDataRecordRow).join('');
|
||||
if (!shopDataTasks.length) {
|
||||
return '<div class="shop-data-empty-hint">暂无符合条件的店铺数据任务</div>';
|
||||
}
|
||||
return '<table>' +
|
||||
'<thead><tr>' +
|
||||
'<th style="width:36px;"><input type="checkbox" id="shopDataTaskSelectAll"></th>' +
|
||||
'<th style="width:18%;">店铺</th>' +
|
||||
'<th style="width:14%;">分组</th>' +
|
||||
'<th style="width:16%;">国家</th>' +
|
||||
'<th style="width:10%;">状态</th>' +
|
||||
'<th style="width:18%;">更新时间</th>' +
|
||||
'<th style="width:170px;">操作</th>' +
|
||||
'</tr></thead>' +
|
||||
'<tbody>' + rows + '</tbody>' +
|
||||
'</table>';
|
||||
// 卡片网格:每店铺一张卡片,按截图样式(店铺名、当日累计文件、分组、最新时间、任务号+状态、国家、文件名、下载/删除)
|
||||
var cards = shopDataTasks.map(function (item) {
|
||||
var resultId = shopDataResultId(item);
|
||||
var selected = resultId > 0 && selectedShopDataResultIds.has(resultId);
|
||||
var status = String(item.status || item.file_status || '').toUpperCase();
|
||||
var terminal = ['SUCCESS', 'FAILED', 'CANCELLED'].indexOf(status) >= 0;
|
||||
var countryCodes = item.country_codes != null ? item.country_codes : item.countryCodes;
|
||||
var updatedAt = item.updated_at || item.latest_created_at || item.created_at || item.finished_at || '-';
|
||||
var taskNo = item.task_id || item.taskId || '-';
|
||||
var errorTitle = item.error ? ' title="' + escapeHtml(item.error) + '"' : '';
|
||||
return '<article class="shop-data-card" data-shop-data-card="' + (resultId || '') + '">' +
|
||||
'<div class="shop-data-card-head">' +
|
||||
'<span class="shop-data-card-shop">' + escapeHtml(item.shop_name || '-') + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="shop-data-card-select-row">' +
|
||||
'<input type="checkbox" data-shop-data-select="' + (resultId || '') + '"' +
|
||||
(selected ? ' checked' : '') + (item.file_ready && resultId > 0 ? '' : ' disabled') + '>' +
|
||||
'<span class="shop-data-card-task-no">任务 ' + escapeHtml(String(taskNo)) + '</span>' +
|
||||
'<span class="shop-data-status ' + (terminal ? (status === 'SUCCESS' ? 'success' : 'failed') : 'running') + '"' + errorTitle + '>' +
|
||||
escapeHtml(imageVideoStatusLabel(status)) + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="shop-data-card-row"><span class="shop-data-card-label">分组</span>' +
|
||||
'<span class="shop-data-card-value">' + escapeHtml(item.group_name || '-') + '</span></div>' +
|
||||
'<div class="shop-data-card-row"><span class="shop-data-card-label">最新</span>' +
|
||||
'<span class="shop-data-card-value">' + escapeHtml(updatedAt) + '</span></div>' +
|
||||
'<div class="shop-data-card-row"><span class="shop-data-card-label">国家</span>' +
|
||||
'<span class="shop-data-card-value">' + escapeHtml(countryListLabel(countryCodes)) + '</span></div>' +
|
||||
'<div class="shop-data-card-row"><span class="shop-data-card-label">文件</span>' +
|
||||
'<span class="shop-data-card-value shop-data-card-file">' + escapeHtml(item.output_filename || '-') + '</span></div>' +
|
||||
'<div class="shop-data-card-actions">' +
|
||||
'<button class="shop-data-record-action" type="button" data-shop-data-download="' + (resultId || '') + '"' + (item.file_ready && resultId > 0 ? '' : ' disabled') + '>' + imageVideoDownloadIcon() + '下载文件</button>' +
|
||||
'<button class="shop-data-record-action danger" type="button" data-shop-data-delete="' + (resultId || '') + '"' + (terminal && resultId > 0 ? '' : ' disabled') + '>' + shopDataDeleteIcon() + '删除</button>' +
|
||||
'</div>' +
|
||||
'</article>';
|
||||
}).join('');
|
||||
return '<div class="shop-data-card-grid">' + cards + '</div>';
|
||||
}
|
||||
|
||||
function renderShopDataTasks() {
|
||||
@@ -1858,21 +1884,27 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ========== 重复 ASIN 分析 ==========
|
||||
var shopDataDuplicatePage = 1, shopDataDuplicatePageSize = 10;
|
||||
// ========== 店铺数据重复检查(指标卡 + 分布 + 矩阵表格 + 抽屉)==========
|
||||
var shopDataDuplicatePage = 1, shopDataDuplicatePageSize = 20;
|
||||
var shopDataDuplicateItems = [];
|
||||
var shopDataDuplicateShops = [];
|
||||
var shopDataDuplicateTotal = 0;
|
||||
var shopDataDuplicateAnalyzed = { shopCount: 0, resultCount: 0 };
|
||||
var shopDataDuplicateView = 'monitor';
|
||||
var shopDataDuplicateLoading = false;
|
||||
var shopDataDuplicateScannedAt = '';
|
||||
var shopDataDuplicateOverviewPending = false;
|
||||
var shopDataDuplicateExporting = false;
|
||||
|
||||
function buildShopDataDuplicateQuery(page) {
|
||||
var params = new URLSearchParams();
|
||||
params.set('page', String(page || 1));
|
||||
params.set('page_size', String(shopDataDuplicatePageSize));
|
||||
params.set('view', shopDataDuplicateView);
|
||||
var values = {
|
||||
asin: document.getElementById('shopDataDupFilterAsin').value.trim(),
|
||||
shop_name: document.getElementById('shopDataDupFilterShop').value.trim(),
|
||||
country: document.getElementById('shopDataDupFilterCountry').value.trim(),
|
||||
site: document.getElementById('shopDataDupFilterSite').value.trim(),
|
||||
date_from: document.getElementById('shopDataDupFilterDateFrom').value,
|
||||
date_to: document.getElementById('shopDataDupFilterDateTo').value
|
||||
};
|
||||
@@ -1882,166 +1914,372 @@
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
function shopDataDuplicateHeader(header) {
|
||||
return '<thead><tr>' + header.map(function (col) {
|
||||
return '<th style="width:' + (col.width || '') + ';">' + col.label + '</th>';
|
||||
}).join('') + '</tr></thead>';
|
||||
function pad2(value) {
|
||||
var n = parseInt(value, 10);
|
||||
if (isNaN(n)) return String(value);
|
||||
return n < 10 ? '0' + n : String(n);
|
||||
}
|
||||
|
||||
function renderShopDataDuplicateCard(item) {
|
||||
var occurrences = Array.isArray(item.occurrences) ? item.occurrences : [];
|
||||
var brand = '';
|
||||
occurrences.forEach(function (occ) { if (occ.brand && !brand) brand = occ.brand; });
|
||||
var rows = occurrences.map(function (occ) {
|
||||
var countries = countryListLabel(occ.country_codes);
|
||||
return '<tr>' +
|
||||
'<td class="dup-shop">' + escapeHtml(occ.shop_name || '-') + '</td>' +
|
||||
'<td class="dup-country">' + escapeHtml(occ.group_name || '-') + '</td>' +
|
||||
'<td class="dup-country">' + escapeHtml(countries) + '</td>' +
|
||||
'<td class="dup-date">' + escapeHtml(occ.date || '-') + '</td>' +
|
||||
'<td class="dup-date">' + escapeHtml(occ.price || '-') + '</td>' +
|
||||
'</tr>';
|
||||
// 中文日期(如 2026年8月18日 上午4:34)归一化为 YYYY-MM-DD HH:MM[:SS],便于展示与排序
|
||||
function normalizeDuplicateTime(raw) {
|
||||
if (!raw) return '';
|
||||
var s = String(raw).trim();
|
||||
var m = s.match(/^(\d{4})年(\d{1,2})月(\d{1,2})日\s*(.*)$/);
|
||||
if (m) {
|
||||
var md = m[1] + '-' + pad2(m[2]) + '-' + pad2(m[3]);
|
||||
var tm = m[4].match(/(上午|下午|晚上|凌晨)?\s*(\d{1,2})[::](\d{2})(?:[::](\d{1,2}))?/);
|
||||
if (!tm) return md;
|
||||
var hour = parseInt(tm[2], 10);
|
||||
var period = tm[1] || '';
|
||||
if (period === '下午' || period === '晚上') hour = hour % 12 + 12;
|
||||
if ((period === '上午' || period === '凌晨') && hour === 12) hour = 0;
|
||||
return md + ' ' + pad2(hour) + ':' + tm[3] + (tm[4] ? ':' + pad2(tm[4]) : '');
|
||||
}
|
||||
var dm = s.replace(/\//g, '-').match(/^(\d{4})-(\d{1,2})-(\d{1,2})(.*)$/);
|
||||
if (dm) return dm[1] + '-' + pad2(dm[2]) + '-' + pad2(dm[3]) + (dm[4] || '');
|
||||
return s;
|
||||
}
|
||||
|
||||
function renderShopDataDuplicateMetrics(summary) {
|
||||
var metricsEl = document.getElementById('shopDataDuplicateMetrics');
|
||||
if (!summary || summary.asin_total == null) {
|
||||
metricsEl.innerHTML = '<div class="dup-check-metric" style="grid-column:1/-1;"><span class="dup-check-metric-label">暂无扫描结果</span>' +
|
||||
'<span class="dup-check-metric-value">-</span></div>';
|
||||
return;
|
||||
}
|
||||
var defs = [
|
||||
{ label: '唯一ASIN', value: summary.asin_total, accent: true },
|
||||
{ label: '上架记录', value: summary.record_total, accent: true },
|
||||
{ label: '在线店铺', value: summary.shop_count, accent: true },
|
||||
{ label: '重复ASIN', value: summary.duplicate_asin_total, warn: true },
|
||||
{ label: '重复店铺', value: summary.duplicate_shop_count, warn: true },
|
||||
{ label: '店铺平均ASIN', value: summary.asin_per_shop }
|
||||
];
|
||||
metricsEl.innerHTML = defs.map(function (def) {
|
||||
return '<div class="dup-check-metric' + (def.accent ? ' accent' : '') + (def.warn ? ' warn' : '') + '">' +
|
||||
'<span class="dup-check-metric-label">' + def.label + '</span>' +
|
||||
'<span class="dup-check-metric-value">' + def.value + '</span></div>';
|
||||
}).join('');
|
||||
return '<article class="duplicate-asin-card" data-duplicate-asin="' + escapeHtml(item.asin) + '">' +
|
||||
'<div class="duplicate-asin-card-head">' +
|
||||
'<span class="dup-asin">' + escapeHtml(item.asin) + '</span>' +
|
||||
'<span class="dup-count">' + item.shop_count + ' 家店铺</span>' +
|
||||
'<span class="dup-brand" title="' + escapeHtml(brand) + '">' + escapeHtml(brand || '') + '</span>' +
|
||||
'</div>' +
|
||||
'<table class="duplicate-asin-table">' +
|
||||
shopDataDuplicateHeader([
|
||||
{ label: '店铺', width: '18%' },
|
||||
{ label: '分组', width: '14%' },
|
||||
{ label: '国家', width: '16%' },
|
||||
{ label: '日期', width: '14%' },
|
||||
{ label: '价格', width: '12%' }
|
||||
]) +
|
||||
'<tbody>' + rows + '</tbody>' +
|
||||
'</table>' +
|
||||
'</article>';
|
||||
}
|
||||
|
||||
function renderShopDataDuplicateDistribution(shops) {
|
||||
var barsEl = document.getElementById('shopDataDuplicateDistribution');
|
||||
var noteEl = document.getElementById('shopDataDuplicateDistributionNote');
|
||||
if (!shops || !shops.length) {
|
||||
barsEl.innerHTML = '<div class="shop-data-empty-hint">暂无店铺数据</div>';
|
||||
noteEl.textContent = '';
|
||||
return;
|
||||
}
|
||||
var max = 0;
|
||||
shops.forEach(function (shop) { if (shop.asin_count > max) max = shop.asin_count; });
|
||||
barsEl.innerHTML = shops.map(function (shop) {
|
||||
var width = max ? Math.max(3, Math.round(shop.asin_count / max * 100)) : 0;
|
||||
return '<div class="dup-chart-row">' +
|
||||
'<span class="dup-chart-name" title="' + escapeHtml(shop.shop_name) + '">' + escapeHtml(shop.shop_name) + '</span>' +
|
||||
'<span class="dup-chart-track"><span class="dup-chart-fill" style="width:' + width + '%"></span></span>' +
|
||||
'<span class="dup-chart-num">' + shop.asin_count + '</span></div>';
|
||||
}).join('');
|
||||
noteEl.textContent = '共 ' + shops.length + ' 家店铺 · 按 ASIN 数量排序';
|
||||
}
|
||||
|
||||
function shopDataDuplicateCellCount(item, shopName) {
|
||||
var count = 0;
|
||||
(item.occurrences || []).forEach(function (occ) {
|
||||
if ((occ.shop_name || '') === shopName) count++;
|
||||
});
|
||||
return count;
|
||||
}
|
||||
|
||||
function renderShopDataDuplicateMatrix() {
|
||||
var list = document.getElementById('shopDataDuplicateList');
|
||||
if (!shopDataDuplicateItems.length) {
|
||||
list.innerHTML = '<div class="shop-data-empty-hint">暂无数据'
|
||||
+ (shopDataDuplicateView === 'monitor' && !shopDataDuplicateTotal ? ':当前没有跨店铺重复的 ASIN' : '')
|
||||
+ '。可点击右上角「重新分析」重新扫描。</div>';
|
||||
return;
|
||||
}
|
||||
var shops = shopDataDuplicateShops;
|
||||
var thead = '<thead><tr><th>ASIN</th><th class="dup-cell">店铺数</th>' +
|
||||
shops.map(function (shop) {
|
||||
return '<th class="dup-cell" title="' + escapeHtml(shop.shop_name) + '">' + escapeHtml(shop.shop_name) + '</th>';
|
||||
}).join('') + '</tr></thead>';
|
||||
var tbody = shopDataDuplicateItems.map(function (item) {
|
||||
var cells = shops.map(function (shop) {
|
||||
var count = shopDataDuplicateCellCount(item, shop.shop_name);
|
||||
var cls = count === 0 ? 'zero' : (item.shop_count >= 2 ? 'danger' : 'has');
|
||||
return '<td class="dup-cell"><span class="dup-cell-num ' + cls + '"'
|
||||
+ (count ? ' data-open-drawer="' + escapeHtml(item.asin) + '" title="' + escapeHtml(item.asin) + ' 在 ' + escapeHtml(shop.shop_name) + ':' + count + ' 条"' : '')
|
||||
+ '>' + (count === 0 ? '0' : count) + '</span></td>';
|
||||
}).join('');
|
||||
return '<tr>' +
|
||||
'<td class="dup-asin-col" data-open-drawer="' + escapeHtml(item.asin) + '" title="查看详情">' + escapeHtml(item.asin) + '</td>' +
|
||||
'<td class="dup-cell"><span class="dup-cell-num ' + (item.shop_count >= 2 ? 'danger' : 'has') + '">' + item.shop_count + '</span></td>' +
|
||||
cells + '</tr>';
|
||||
}).join('');
|
||||
list.innerHTML = '<table class="dup-check-table"><thead>' + thead + '</thead><tbody>' + tbody + '</tbody></table>';
|
||||
}
|
||||
|
||||
function renderShopDataDuplicateList() {
|
||||
var list = document.getElementById('shopDataDuplicateList');
|
||||
if (!shopDataDuplicateItems.length) {
|
||||
list.innerHTML = '<div class="shop-data-empty-hint">暂无重复 ASIN。请在筛选条件后点击"查询",或"重新分析"。' +
|
||||
'<span id="dupEmptyHint"></span></div>';
|
||||
renderShopDataDuplicateMatrix();
|
||||
}
|
||||
|
||||
// 主时间戳(转数值失败时按字符串比较)
|
||||
function dupTimeRank(raw) {
|
||||
var t = Date.parse(normalizeDuplicateTime(raw));
|
||||
return isNaN(t) ? -1 : t;
|
||||
}
|
||||
|
||||
// 「查看明细」按钮点击开抽屉(动态元素,事件委托)
|
||||
function handleDuplicateDetailClick(event) {
|
||||
var button = event.target.closest('[data-open-asin-detail]');
|
||||
if (button) openShopDataDuplicateDrawer(button.dataset.openAsinDetail);
|
||||
}
|
||||
|
||||
// 撞款详情卡片区:同一 ASIN 在多条店铺的上架明细(仅跨店铺重复,按店铺数倒序)
|
||||
function renderShopDataDuplicateDetailCards() {
|
||||
var block = document.getElementById('dupCheckDetailBlock');
|
||||
var cardsEl = document.getElementById('dupCheckDetailCards');
|
||||
if (!block || !cardsEl) return;
|
||||
var repeated = (shopDataDuplicateItems || []).filter(function (item) {
|
||||
return Number(item.shop_count) >= 2;
|
||||
}).slice().sort(function (a, b) {
|
||||
return (Number(b.shop_count) - Number(a.shop_count)) || dupTimeRank(b.first_date || '') - dupTimeRank(a.first_date || '');
|
||||
});
|
||||
if (!repeated.length) {
|
||||
block.style.display = 'none';
|
||||
cardsEl.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
var header = [
|
||||
{ label: 'ASIN', width: '14%' },
|
||||
{ label: '店铺数', width: '8%' },
|
||||
{ label: '记录条数', width: '10%' },
|
||||
{ label: '品牌', width: '12%' },
|
||||
{ label: '对应店铺', width: '22%' },
|
||||
{ label: '国家', width: '14%' },
|
||||
{ label: '上架时间', width: '12%' },
|
||||
{ label: '操作', width: '8%' }
|
||||
];
|
||||
var tbody = shopDataDuplicateItems.map(function (item) {
|
||||
return renderShopDataDuplicateRow(item);
|
||||
block.style.display = '';
|
||||
cardsEl.innerHTML = repeated.map(function (item) {
|
||||
// 按店铺分组:名称 + 次数 + 站点 + 上架时间(倒序)
|
||||
var shopsMap = {};
|
||||
(item.occurrences || []).forEach(function (occ) {
|
||||
var key = occ.shop_name || '-';
|
||||
if (!shopsMap[key]) shopsMap[key] = { shop: key, sites: {}, times: [], count: 0 };
|
||||
var info = shopsMap[key];
|
||||
info.count++;
|
||||
if (occ.country) info.sites[occ.country] = true;
|
||||
var d = normalizeDuplicateTime(occ.date);
|
||||
if (d) info.times.push(d);
|
||||
});
|
||||
var rows = Object.keys(shopsMap).sort().map(function (key) {
|
||||
var info = shopsMap[key];
|
||||
info.times.sort(function (a, b) { return (a < b ? 1 : (a > b ? -1 : 0)); });
|
||||
info.times = Array.from(new Set(info.times));
|
||||
var rowSites = Object.keys(info.sites).sort().map(function (site) {
|
||||
return '<span class="dup-check-site">' + escapeHtml(site) + '</span>';
|
||||
}).join('');
|
||||
return '<tr>' +
|
||||
'<td class="dup-shop">' + escapeHtml(info.shop) + '<span class="dup-detail-count">' + info.count + ' 次</span></td>' +
|
||||
'<td>' + (rowSites || '-') + '</td>' +
|
||||
'<td class="dup-date">' + info.times.map(escapeHtml).join('、') + '</td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
return '<div class="duplicate-asin-card">' +
|
||||
'<div class="duplicate-asin-card-head">' +
|
||||
'<span class="dup-asin" title="' + escapeHtml(item.asin) + '">' + escapeHtml(item.asin) + '</span>' +
|
||||
'<span class="dup-count">' + item.shop_count + ' 家店铺</span>' +
|
||||
'<span class="dup-brand">' + escapeHtml(item.brand || '') + '</span>' +
|
||||
'<button class="btn btn-sm btn-secondary dup-detail-view-btn" type="button" data-open-asin-detail="' + escapeHtml(item.asin) + '">查看明细</button>' +
|
||||
'</div>' +
|
||||
'<table class="duplicate-asin-table dup-card-table"><thead>' +
|
||||
'<tr><th>店铺</th><th>站点</th><th>上架时间</th></tr></thead>' +
|
||||
'<tbody>' + rows + '</tbody></table>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
list.innerHTML = '<table>' + shopDataDuplicateHeader(header) + '<tbody>' + tbody + '</tbody></table>';
|
||||
}
|
||||
|
||||
function renderShopDataDuplicateRow(item) {
|
||||
var occurrences = Array.isArray(item.occurrences) ? item.occurrences : [];
|
||||
var brand = '';
|
||||
var countries = {};
|
||||
var shops = {};
|
||||
function openShopDataDuplicateDrawer(asin) {
|
||||
var item = null;
|
||||
(shopDataDuplicateItems || []).forEach(function (it) { if (it.asin === asin) item = it; });
|
||||
if (!item) return;
|
||||
var occurrences = item.occurrences || [];
|
||||
var brand = '', dateMin = '', dateMax = '', sites = {}, prices = [];
|
||||
var shopsMap = {};
|
||||
occurrences.forEach(function (occ) {
|
||||
if (occ.brand && !brand) brand = occ.brand;
|
||||
(occ.country_codes || []).forEach(function (c) { countries[c] = true; });
|
||||
shops[occ.shop_name || '-'] = true;
|
||||
if (!brand && occ.brand) brand = occ.brand;
|
||||
var d = normalizeDuplicateTime(occ.date);
|
||||
if (d) {
|
||||
if (!dateMin || d < dateMin) dateMin = d;
|
||||
if (!dateMax || d > dateMax) dateMax = d;
|
||||
}
|
||||
if (occ.country) sites[occ.country] = true;
|
||||
if (occ.price && prices.indexOf(occ.price) < 0) prices.push(occ.price);
|
||||
var shopKey = occ.shop_name || '-';
|
||||
if (!shopsMap[shopKey]) {
|
||||
shopsMap[shopKey] = { shop: occ.shop_name || '-', group: occ.group_name || '', sites: {}, times: {}, price: '', count: 0 };
|
||||
}
|
||||
var info = shopsMap[shopKey];
|
||||
info.count++;
|
||||
if (occ.country) info.sites[occ.country] = true;
|
||||
if (d) info.times[d] = true;
|
||||
if (occ.price && !info.price) info.price = occ.price;
|
||||
});
|
||||
var countryLabel = countryListLabel(Object.keys(countries));
|
||||
var shopList = Object.keys(shops).join('、');
|
||||
var dateList = occurrences.map(function (occ) { return occ.date; })
|
||||
.filter(function (d) { return d; }).join('、');
|
||||
return '<tr data-duplicate-asin="' + escapeHtml(item.asin) + '">' +
|
||||
'<td><span class="dup-asin">' + escapeHtml(item.asin) + '</span></td>' +
|
||||
'<td><span class="dup-count">' + (item.shop_count || 1) + ' 家</span></td>' +
|
||||
'<td>' + (item.record_count || occurrences.length) + ' 条</td>' +
|
||||
'<td>' + escapeHtml(brand || '-') + '</td>' +
|
||||
'<td>' + escapeHtml(shopList || '-') + '</td>' +
|
||||
'<td>' + escapeHtml(countryLabel || '-') + '</td>' +
|
||||
'<td>' + escapeHtml(dateList || '-') + '</td>' +
|
||||
'<td><button class="btn btn-sm" type="button" data-dup-toggle>详情</button></td>' +
|
||||
'</tr>' + renderShopDataDuplicateDetailRows(item);
|
||||
}
|
||||
|
||||
function renderShopDataDuplicateDetailRows(item) {
|
||||
var occurrences = Array.isArray(item.occurrences) ? item.occurrences : [];
|
||||
if (!occurrences.length) return '';
|
||||
var rows = occurrences.map(function (occ) {
|
||||
var countries = countryListLabel(occ.country_codes);
|
||||
return '<tr class="dup-detail-row" style="display:none;">' +
|
||||
'<td></td>' +
|
||||
'<td class="dup-shop">' + escapeHtml(occ.shop_name || '-') + '</td>' +
|
||||
'<td class="dup-country">' + escapeHtml(occ.group_name || '-') + '</td>' +
|
||||
'<td class="dup-country">' + escapeHtml(countries) + '</td>' +
|
||||
'<td class="dup-date">' + escapeHtml(occ.date || '-') + '</td>' +
|
||||
'<td class="dup-date">' + escapeHtml(occ.price || '-') + '</td>' +
|
||||
'<td>' + escapeHtml(occ.brand || '-') + '</td>' +
|
||||
'<td></td>' +
|
||||
'</tr>';
|
||||
var dateRange = dateMin && dateMax ? (dateMin === dateMax ? dateMin : dateMin + ' ~ ' + dateMax) : '-';
|
||||
var priceRange = prices.length ? prices.slice(0, 4).join(' / ') + (prices.length > 4 ? ' 等' : '') : '-';
|
||||
var siteBadges = Object.keys(sites).sort().map(function (site) {
|
||||
return '<span class="dup-check-site">' + escapeHtml(site) + '</span>';
|
||||
}).join('');
|
||||
return rows;
|
||||
var rows = Object.keys(shopsMap).sort().map(function (key) {
|
||||
var info = shopsMap[key];
|
||||
var times = Object.keys(info.times).sort().reverse();
|
||||
var rowSites = Object.keys(info.sites).sort().map(function (site) {
|
||||
return '<span class="dup-check-site">' + escapeHtml(site) + '</span>';
|
||||
}).join('');
|
||||
return '<tr>' +
|
||||
'<td><span class="dup-asin-cell">' + escapeHtml(info.shop) + '</span></td>' +
|
||||
'<td>' + escapeHtml(info.group || '-') + '</td>' +
|
||||
'<td>' + (rowSites || '-') + '</td>' +
|
||||
'<td class="dup-date">' + times.map(escapeHtml).join('、') + '</td>' +
|
||||
'<td class="dup-date">' + escapeHtml(info.price || '-') + '</td>' +
|
||||
'<td>' + info.count + '</td></tr>';
|
||||
}).join('');
|
||||
document.getElementById('dupCheckDrawerAsin').textContent = item.asin;
|
||||
document.getElementById('dupCheckDrawerSubtitle').textContent =
|
||||
item.shop_count + ' 家店铺 · ' + item.record_count + ' 条上架记录';
|
||||
document.getElementById('dupCheckDrawerBody').innerHTML =
|
||||
'<dl class="dup-check-drawer-meta">' +
|
||||
'<dt>品牌</dt><dd>' + escapeHtml(brand || '-') + '</dd>' +
|
||||
'<dt>价格</dt><dd>' + escapeHtml(priceRange) + '</dd>' +
|
||||
'<dt>日期范围</dt><dd>' + escapeHtml(dateRange) + '</dd>' +
|
||||
'<dt>站点</dt><dd>' + (siteBadges || '-') + '</dd></dl>' +
|
||||
'<h4 style="margin:14px 0 6px;font-size:13.5px;">店铺 / 站点 / 上架时间 / 次数</h4>' +
|
||||
'<div class="table-scroll"><table class="dup-check-drawer-table">' +
|
||||
'<thead><tr><th>店铺</th><th>分组</th><th>站点</th><th>上架时间</th><th>价格</th><th>次数</th></tr></thead>' +
|
||||
'<tbody>' + rows + '</tbody></table></div>';
|
||||
document.getElementById('dupCheckDrawerMask').classList.add('show');
|
||||
}
|
||||
|
||||
function loadShopDataDuplicateAsins(page) {
|
||||
function closeShopDataDuplicateDrawer() {
|
||||
document.getElementById('dupCheckDrawerMask').classList.remove('show');
|
||||
}
|
||||
|
||||
function loadShopDataDuplicateCheckOverview(force) {
|
||||
var progress = document.getElementById('shopDataDuplicateProgress');
|
||||
var button = document.getElementById('btnRefreshShopDataDuplicates');
|
||||
if (button) button.disabled = true;
|
||||
if (force) progress.textContent = '正在全量扫描各店铺结果文件,请稍候...';
|
||||
fetch('/api/admin/shop-data-crawl/duplicate-check-overview' + (force ? '?force=1' : ''))
|
||||
.then(function (response) { return response.json(); })
|
||||
.then(function (res) {
|
||||
if (!res.success) throw new Error(res.error || '加载失败');
|
||||
shopDataDuplicateOverviewPending = !!res.pending;
|
||||
shopDataDuplicateScannedAt = res.scanned_at || '';
|
||||
var summary = res.summary || {};
|
||||
renderShopDataDuplicateMetrics(summary);
|
||||
renderShopDataDuplicateDistribution(res.shops || []);
|
||||
var dupTabCount = document.getElementById('dupCheckMonitorCount');
|
||||
var allTabCount = document.getElementById('dupCheckAllCount');
|
||||
if (dupTabCount) dupTabCount.textContent = summary.duplicate_asin_total != null ? summary.duplicate_asin_total : 0;
|
||||
if (allTabCount) allTabCount.textContent = summary.asin_total != null ? summary.asin_total : 0;
|
||||
loadShopDataDuplicateCheckItems(1);
|
||||
})
|
||||
.catch(function (error) {
|
||||
var metricsEl = document.getElementById('shopDataDuplicateMetrics');
|
||||
if (metricsEl) {
|
||||
metricsEl.innerHTML = '<div class="shop-data-empty-hint">加载失败:'
|
||||
+ escapeHtml(error.message || '') + '</div>';
|
||||
}
|
||||
})
|
||||
.finally(function () {
|
||||
if (button) button.disabled = false;
|
||||
if (progress) progress.textContent = '';
|
||||
});
|
||||
}
|
||||
|
||||
function loadShopDataDuplicateCheckItems(page) {
|
||||
if (shopDataDuplicateLoading) return;
|
||||
shopDataDuplicatePage = page || 1;
|
||||
shopDataDuplicateLoading = true;
|
||||
var list = document.getElementById('shopDataDuplicateList');
|
||||
var progress = document.getElementById('shopDataDuplicateProgress');
|
||||
var button = document.getElementById('btnRefreshShopDataDuplicates');
|
||||
progress.textContent = '正在读取各店铺结果文件并分析,请稍候...';
|
||||
button.disabled = true;
|
||||
list.innerHTML = '<div class="shop-data-empty-hint">分析中...</div>';
|
||||
fetch('/api/admin/shop-data-crawl/duplicate-asins?' + buildShopDataDuplicateQuery(shopDataDuplicatePage))
|
||||
var exportButton = document.getElementById('btnExportShopDataDuplicates');
|
||||
list.innerHTML = '<div class="shop-data-empty-hint">加载中...</div>';
|
||||
if (exportButton) exportButton.disabled = true;
|
||||
fetch('/api/admin/shop-data-crawl/duplicate-check-items?' + buildShopDataDuplicateQuery(shopDataDuplicatePage))
|
||||
.then(function (response) { return response.json(); })
|
||||
.then(function (res) {
|
||||
if (!res.success) throw new Error(res.error || '分析失败');
|
||||
if (!res.success) throw new Error(res.error || '加载失败');
|
||||
shopDataDuplicateItems = res.items || [];
|
||||
shopDataDuplicateShops = res.shops || [];
|
||||
shopDataDuplicateTotal = Number(res.total) || 0;
|
||||
shopDataDuplicateAnalyzed.shopCount = Number(res.analyzed_shop_count) || 0;
|
||||
shopDataDuplicateAnalyzed.resultCount = Number(res.analyzed_result_count) || 0;
|
||||
if (res.scanned_at) shopDataDuplicateScannedAt = res.scanned_at;
|
||||
var totalEl = document.getElementById('shopDataDuplicateTotal');
|
||||
totalEl.textContent = '共 ' + shopDataDuplicateTotal + ' 个重复 ASIN · 已分析 ' + shopDataDuplicateAnalyzed.shopCount + ' 家店铺';
|
||||
var tabCount = document.getElementById('shopDataDuplicateTabCount');
|
||||
if (tabCount) tabCount.textContent = shopDataDuplicateTotal;
|
||||
renderShopDataDuplicateList();
|
||||
renderPagination('shopDataDuplicatePagination', shopDataDuplicateTotal, shopDataDuplicatePage, shopDataDuplicatePageSize, loadShopDataDuplicateAsins);
|
||||
totalEl.textContent = (shopDataDuplicateView === 'monitor' ? '重复监控' : '全部ASIN台账')
|
||||
+ ' · 共 ' + shopDataDuplicateTotal + ' 个 ASIN'
|
||||
+ (shopDataDuplicateScannedAt ? ' · 扫描时间 ' + shopDataDuplicateScannedAt
|
||||
: (shopDataDuplicateOverviewPending ? ' · 尚无扫描结果(点击「重新分析」立即扫描)' : ''));
|
||||
renderShopDataDuplicateMatrix();
|
||||
renderShopDataDuplicateDetailCards();
|
||||
renderPagination('shopDataDuplicatePagination', shopDataDuplicateTotal,
|
||||
shopDataDuplicatePage, shopDataDuplicatePageSize, loadShopDataDuplicateCheckItems);
|
||||
})
|
||||
.catch(function (error) {
|
||||
shopDataDuplicateItems = [];
|
||||
shopDataDuplicateTotal = 0;
|
||||
list.innerHTML = '<div class="shop-data-empty-hint">分析失败:' + escapeHtml(error.message || '') + '</div>';
|
||||
list.innerHTML = '<div class="shop-data-empty-hint">加载失败:' + escapeHtml(error.message || '') + '</div>';
|
||||
document.getElementById('shopDataDuplicateTotal').textContent = '';
|
||||
renderShopDataDuplicateDetailCards();
|
||||
})
|
||||
.finally(function () {
|
||||
shopDataDuplicateLoading = false;
|
||||
progress.textContent = '';
|
||||
button.disabled = false;
|
||||
if (progress) progress.textContent = '';
|
||||
if (exportButton) exportButton.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function switchShopDataSubTab(view) {
|
||||
var recordsView = document.getElementById('shopDataRecordsView');
|
||||
var duplicatesView = document.getElementById('shopDataDuplicatesView');
|
||||
var recordsTab = document.getElementById('shopDataSubTabRecords');
|
||||
var duplicatesTab = document.getElementById('shopDataSubTabDuplicates');
|
||||
var recordsActive = view === 'records';
|
||||
recordsView.style.display = recordsActive ? '' : 'none';
|
||||
duplicatesView.style.display = recordsActive ? 'none' : '';
|
||||
recordsTab.classList.toggle('active', recordsActive);
|
||||
recordsTab.setAttribute('aria-selected', recordsActive ? 'true' : 'false');
|
||||
duplicatesTab.classList.toggle('active', !recordsActive);
|
||||
duplicatesTab.setAttribute('aria-selected', !recordsActive ? 'true' : 'false');
|
||||
if (!recordsActive) {
|
||||
loadShopDataDuplicateAsins(1);
|
||||
}
|
||||
function switchDupCheckView(view) {
|
||||
if (view === shopDataDuplicateView) return;
|
||||
shopDataDuplicateView = view;
|
||||
var monitorTab = document.getElementById('dupCheckTabMonitor');
|
||||
var allTab = document.getElementById('dupCheckTabAll');
|
||||
var monitorActive = view === 'monitor';
|
||||
monitorTab.classList.toggle('active', monitorActive);
|
||||
monitorTab.setAttribute('aria-selected', monitorActive ? 'true' : 'false');
|
||||
allTab.classList.toggle('active', !monitorActive);
|
||||
allTab.setAttribute('aria-selected', !monitorActive ? 'true' : 'false');
|
||||
loadShopDataDuplicateCheckItems(1);
|
||||
}
|
||||
|
||||
function exportShopDataDuplicates() {
|
||||
if (shopDataDuplicateExporting) return;
|
||||
shopDataDuplicateExporting = true;
|
||||
var progress = document.getElementById('shopDataDuplicateProgress');
|
||||
var button = document.getElementById('btnExportShopDataDuplicates');
|
||||
if (button) button.disabled = true;
|
||||
if (progress) progress.textContent = '正在导出当前筛选结果...';
|
||||
var query = new URLSearchParams(buildShopDataDuplicateQuery(1));
|
||||
query.delete('page');
|
||||
query.delete('page_size');
|
||||
fetch('/api/admin/shop-data-crawl/duplicate-check-export?' + query.toString())
|
||||
.then(function (response) {
|
||||
if (!response.ok) {
|
||||
return response.json().then(function (data) { throw new Error(data.error || '导出失败'); });
|
||||
}
|
||||
return response.blob();
|
||||
})
|
||||
.then(function (blob) {
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
var now = new Date();
|
||||
function pad(n) { return n < 10 ? '0' + n : String(n); }
|
||||
a.download = '店铺数据重复检查_' + now.getFullYear() + pad(now.getMonth() + 1) + pad(now.getDate())
|
||||
+ '_' + pad(now.getHours()) + pad(now.getMinutes()) + '.csv';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
|
||||
})
|
||||
.catch(function (error) {
|
||||
if (progress) progress.textContent = '导出失败:' + (error.message || '');
|
||||
})
|
||||
.finally(function () {
|
||||
shopDataDuplicateExporting = false;
|
||||
if (button) button.disabled = false;
|
||||
if (progress) progress.textContent = '';
|
||||
});
|
||||
}
|
||||
|
||||
function downloadShopDataTask(item) {
|
||||
@@ -2223,27 +2461,33 @@
|
||||
|
||||
document.getElementById('btnFilterShopDataTasks').onclick = function () {
|
||||
loadShopDataCrawlTasks(1);
|
||||
if (document.getElementById('shopDataDuplicatesView').style.display !== 'none') {
|
||||
loadShopDataDuplicateAsins(1);
|
||||
}
|
||||
};
|
||||
document.getElementById('btnResetShopDataTasks').onclick = function () {
|
||||
['shopDataTaskFilterShop', 'shopDataTaskFilterGroup', 'shopDataTaskFilterCountry', 'shopDataTaskFilterFrom', 'shopDataTaskFilterTo']
|
||||
.forEach(function (id) { document.getElementById(id).value = ''; });
|
||||
loadShopDataCrawlTasks(1);
|
||||
if (document.getElementById('shopDataDuplicatesView').style.display !== 'none') {
|
||||
loadShopDataDuplicateAsins(1);
|
||||
}
|
||||
};
|
||||
document.getElementById('shopDataSubTabRecords').onclick = function () { switchShopDataSubTab('records'); };
|
||||
document.getElementById('shopDataSubTabDuplicates').onclick = function () { switchShopDataSubTab('duplicates'); };
|
||||
document.getElementById('btnRefreshShopDataDuplicates').onclick = function () { loadShopDataDuplicateAsins(1); };
|
||||
document.getElementById('btnFilterShopDataDuplicates').onclick = function () { loadShopDataDuplicateAsins(1); };
|
||||
document.getElementById('btnRefreshShopDataDuplicates').onclick = function () { loadShopDataDuplicateCheckOverview(true); };
|
||||
document.getElementById('btnFilterShopDataDuplicates').onclick = function () { loadShopDataDuplicateCheckItems(1); };
|
||||
document.getElementById('btnResetShopDataDuplicates').onclick = function () {
|
||||
['shopDataDupFilterAsin', 'shopDataDupFilterShop', 'shopDataDupFilterCountry', 'shopDataDupFilterDateFrom', 'shopDataDupFilterDateTo']
|
||||
['shopDataDupFilterAsin', 'shopDataDupFilterShop', 'shopDataDupFilterCountry', 'shopDataDupFilterSite', 'shopDataDupFilterDateFrom', 'shopDataDupFilterDateTo']
|
||||
.forEach(function (id) { document.getElementById(id).value = ''; });
|
||||
loadShopDataDuplicateAsins(1);
|
||||
loadShopDataDuplicateCheckItems(1);
|
||||
};
|
||||
document.getElementById('dupCheckTabMonitor').onclick = function () { switchDupCheckView('monitor'); };
|
||||
document.getElementById('dupCheckTabAll').onclick = function () { switchDupCheckView('all'); };
|
||||
document.getElementById('btnExportShopDataDuplicates').onclick = exportShopDataDuplicates;
|
||||
document.getElementById('btnCloseDupCheckDrawer').onclick = closeShopDataDuplicateDrawer;
|
||||
document.getElementById('dupCheckDrawerMask').onclick = function (event) {
|
||||
if (event.target === this) closeShopDataDuplicateDrawer();
|
||||
};
|
||||
// 矩阵表格内 ASIN / 数字徽标点击打开抽屉(动态元素,事件委托)
|
||||
document.getElementById('shopDataDuplicateList').onclick = function (event) {
|
||||
var target = event.target.closest('[data-open-drawer]');
|
||||
if (target) openShopDataDuplicateDrawer(target.dataset.openDrawer);
|
||||
};
|
||||
// 撞款详情卡片区「查看明细」按钮(动态元素,事件委托)
|
||||
document.getElementById('dupCheckDetailCards').onclick = handleDuplicateDetailClick;
|
||||
// 全选移到表格表头后为动态元素,用事件委托
|
||||
document.getElementById('shopDataTaskGrid').onchange = function (event) {
|
||||
var checkbox = event.target.closest('[data-shop-data-select]');
|
||||
@@ -2253,18 +2497,18 @@
|
||||
if (checkbox.checked) selectedShopDataResultIds.add(resultId);
|
||||
else selectedShopDataResultIds.delete(resultId);
|
||||
syncShopDataSelectionUi();
|
||||
return;
|
||||
}
|
||||
if (event.target.closest('#shopDataTaskSelectAll')) {
|
||||
shopDataTasks.forEach(function (item) {
|
||||
var sid = shopDataResultId(item);
|
||||
if (!item.file_ready || !sid) return;
|
||||
if (event.target.checked) selectedShopDataResultIds.add(sid);
|
||||
else selectedShopDataResultIds.delete(sid);
|
||||
});
|
||||
syncShopDataSelectionUi();
|
||||
}
|
||||
};
|
||||
// 全选移到工具栏后不在 grid 容器内,单独绑定 change 事件
|
||||
document.getElementById('shopDataTaskSelectAll').onchange = function (event) {
|
||||
shopDataTasks.forEach(function (item) {
|
||||
var sid = shopDataResultId(item);
|
||||
if (!item.file_ready || !sid) return;
|
||||
if (event.target.checked) selectedShopDataResultIds.add(sid);
|
||||
else selectedShopDataResultIds.delete(sid);
|
||||
});
|
||||
syncShopDataSelectionUi();
|
||||
};
|
||||
document.getElementById('shopDataTaskGrid').onclick = function (event) {
|
||||
var downloadButton = event.target.closest('[data-shop-data-download]');
|
||||
if (downloadButton) {
|
||||
@@ -2279,28 +2523,6 @@
|
||||
}
|
||||
};
|
||||
document.getElementById('btnBatchDownloadShopDataTasks').onclick = downloadShopDataTasksZip;
|
||||
document.getElementById('shopDataDuplicateList').onclick = function (event) {
|
||||
var toggle = event.target.closest('[data-dup-toggle]');
|
||||
if (!toggle) return;
|
||||
var row = toggle.closest('tr');
|
||||
var allDetailRows = document.querySelectorAll('.dup-detail-row');
|
||||
// 若当前 ASIN 的详情行已展开,则本次点击收起
|
||||
var cur = row ? row.nextElementSibling : null;
|
||||
var wasExpanded = false;
|
||||
while (cur && cur.classList && cur.classList.contains('dup-detail-row')) {
|
||||
if (cur.style.display !== 'none') { wasExpanded = true; break; }
|
||||
cur = cur.nextElementSibling;
|
||||
}
|
||||
allDetailRows.forEach(function (dr) { dr.style.display = 'none'; });
|
||||
if (row && !wasExpanded) {
|
||||
// 展开当前 ASIN 的详情行(紧随其后)
|
||||
cur = row.nextElementSibling;
|
||||
while (cur && cur.classList && cur.classList.contains('dup-detail-row')) {
|
||||
cur.style.display = '';
|
||||
cur = cur.nextElementSibling;
|
||||
}
|
||||
}
|
||||
};
|
||||
document.getElementById('btnOpenShopDataTaskPermissions').onclick = openShopDataTaskPermissions;
|
||||
document.getElementById('btnCloseShopDataTaskPermissions').onclick = closeShopDataTaskPermissions;
|
||||
document.getElementById('btnCancelShopDataTaskPermissions').onclick = closeShopDataTaskPermissions;
|
||||
@@ -2541,30 +2763,160 @@
|
||||
String(u.created_by_id || '') === String(leaderUserId || '');
|
||||
});
|
||||
}
|
||||
|
||||
// ========== 分组管理通讯录:按拼音分组 + 模糊搜索 + 字母索引 ==========
|
||||
// 隐藏 select(shopManageGroupMemberSelect)仅作为数据交换层,保存逻辑不变。
|
||||
var shopManageGroupEligibleUsers = [];
|
||||
var shopManageGroupSelectedIds = {};
|
||||
var shopManageGroupSearchKeyword = '';
|
||||
var shopManageGroupContactGroupsCache = [];
|
||||
var SHOP_GROUP_AVATAR_COLORS = ['#f97316', '#0ea5e9', '#8b5cf6', '#10b981', '#ef4444', '#eab308', '#14b8a6', '#6366f1'];
|
||||
|
||||
function shopGroupAvatarColor(name) {
|
||||
var code = String(name || '#').charCodeAt(0) || 0;
|
||||
return SHOP_GROUP_AVATAR_COLORS[code % SHOP_GROUP_AVATAR_COLORS.length];
|
||||
}
|
||||
function shopGroupUserInitial(user) {
|
||||
var abbr = String(user.pinyin_abbr || '').trim();
|
||||
if (abbr) {
|
||||
var first = abbr.charAt(0).toUpperCase();
|
||||
return /^[A-Z0-9]$/.test(first) ? first : '#';
|
||||
}
|
||||
var name = String(user.username || '').trim();
|
||||
if (!name) return '#';
|
||||
var first = name.charAt(0);
|
||||
if (/[A-Za-z0-9]/.test(first)) return first.toUpperCase();
|
||||
return '#';
|
||||
}
|
||||
function shopGroupMatchSearch(user, keyword) {
|
||||
if (!keyword) return true;
|
||||
var kw = keyword.toLowerCase();
|
||||
if (String(user.username || '').toLowerCase().indexOf(kw) !== -1) return true;
|
||||
if (String(user.pinyin_abbr || '').toLowerCase().indexOf(kw) !== -1) return true;
|
||||
return false;
|
||||
}
|
||||
function buildShopManageGroupContactGroups(users) {
|
||||
var groups = {};
|
||||
users.forEach(function (u) {
|
||||
var initial = shopGroupUserInitial(u);
|
||||
var key = /^[A-Z]$/.test(initial) ? initial : '#';
|
||||
(groups[key] = groups[key] || []).push(u);
|
||||
});
|
||||
var keys = Object.keys(groups).sort(function (a, b) {
|
||||
if (a === '#') return 1;
|
||||
if (b === '#') return -1;
|
||||
return a < b ? -1 : 1;
|
||||
});
|
||||
return keys.map(function (key) {
|
||||
var list = groups[key].slice().sort(function (a, b) {
|
||||
return String(a.username || '').localeCompare(String(b.username || ''), 'zh');
|
||||
});
|
||||
return { key: key, items: list };
|
||||
});
|
||||
}
|
||||
function renderShopManageGroupContact() {
|
||||
var contactEl = document.getElementById('shopManageGroupContact');
|
||||
var indexEl = document.getElementById('shopManageGroupMemberIndex');
|
||||
if (!contactEl) return;
|
||||
var keyword = (shopManageGroupSearchKeyword || '').trim();
|
||||
var filtered = shopManageGroupEligibleUsers.filter(function (u) {
|
||||
return shopGroupMatchSearch(u, keyword);
|
||||
});
|
||||
if (!shopManageGroupEligibleUsers.length) {
|
||||
contactEl.innerHTML = '<div class="shop-group-contact-empty">当前组长名下暂无可添加的普通员工账号。</div>';
|
||||
if (indexEl) indexEl.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
if (!filtered.length) {
|
||||
contactEl.innerHTML = '<div class="shop-group-contact-empty">未找到与「' + escapeHtml(keyword) + '」匹配的用户,可尝试输入拼音首字母,如 zwh。</div>';
|
||||
if (indexEl) indexEl.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
shopManageGroupContactGroupsCache = buildShopManageGroupContactGroups(filtered);
|
||||
contactEl.innerHTML = shopManageGroupContactGroupsCache.map(function (group) {
|
||||
var itemsHtml = group.items.map(function (u) {
|
||||
var selected = !!shopManageGroupSelectedIds[String(u.id)];
|
||||
var name = u.username || '';
|
||||
return '<div class="shop-group-contact-item' + (selected ? ' is-selected' : '') + '" data-user-id="' + escapeHtml(u.id) + '" title="' + escapeHtml(name) + '">' +
|
||||
'<span class="shop-group-item-avatar" style="background:' + shopGroupAvatarColor(name) + ';">' + escapeHtml(shopGroupUserInitial(u)) + '</span>' +
|
||||
'<span class="shop-group-item-name">' + escapeHtml(name) + '</span>' +
|
||||
'<span class="shop-group-item-check" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"></path></svg></span>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
return '<div class="shop-group-contact-group" data-letter="' + escapeHtml(group.key) + '">' + escapeHtml(group.key) + '</div>' +
|
||||
'<div class="shop-group-contact-items">' + itemsHtml + '</div>';
|
||||
}).join('');
|
||||
if (indexEl) {
|
||||
indexEl.innerHTML = shopManageGroupContactGroupsCache.map(function (group) {
|
||||
return '<button type="button" data-letter="' + escapeHtml(group.key) + '">' + escapeHtml(group.key) + '</button>';
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
function syncShopManageGroupMemberSelect() {
|
||||
var sel = document.getElementById('shopManageGroupMemberSelect');
|
||||
if (!sel) return;
|
||||
sel.innerHTML = shopManageGroupEligibleUsers.map(function (u) {
|
||||
var selected = !!shopManageGroupSelectedIds[String(u.id)];
|
||||
return '<option value="' + escapeHtml(u.id) + '"' + (selected ? ' selected' : '') + '>' + escapeHtml(u.username || '') + '</option>';
|
||||
}).join('');
|
||||
}
|
||||
function updateShopManageGroupMemberCount() {
|
||||
var countEl = document.getElementById('shopManageGroupMemberCount');
|
||||
if (countEl) {
|
||||
countEl.textContent = '已选 ' + Object.keys(shopManageGroupSelectedIds).length + ' 人';
|
||||
}
|
||||
}
|
||||
function toggleShopManageGroupMember(userId) {
|
||||
var key = String(userId || '');
|
||||
if (!key) return;
|
||||
var contactEl = document.getElementById('shopManageGroupContact');
|
||||
var itemEl = contactEl ? Array.prototype.find.call(
|
||||
contactEl.querySelectorAll('.shop-group-contact-item'),
|
||||
function (el) { return el.dataset.userId === key; }
|
||||
) : null;
|
||||
if (shopManageGroupSelectedIds[key]) {
|
||||
delete shopManageGroupSelectedIds[key];
|
||||
} else {
|
||||
shopManageGroupSelectedIds[key] = true;
|
||||
}
|
||||
if (itemEl) itemEl.classList.toggle('is-selected', !!shopManageGroupSelectedIds[key]);
|
||||
syncShopManageGroupMemberSelect();
|
||||
updateShopManageGroupMemberCount();
|
||||
}
|
||||
function shopGroupScrollToLetter(letter) {
|
||||
var contactEl = document.getElementById('shopManageGroupContact');
|
||||
if (!contactEl) return;
|
||||
var groupEl = Array.prototype.find.call(
|
||||
contactEl.querySelectorAll('.shop-group-contact-group'),
|
||||
function (el) { return el.dataset.letter === letter; }
|
||||
);
|
||||
if (groupEl) groupEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
function resetShopManageGroupSearch() {
|
||||
shopManageGroupSearchKeyword = '';
|
||||
var searchInput = document.getElementById('shopManageGroupMemberSearch');
|
||||
if (searchInput) searchInput.value = '';
|
||||
}
|
||||
|
||||
function refreshShopManageGroupMemberSelect(leaderUserId, selectedUserIds) {
|
||||
var sel = document.getElementById('shopManageGroupMemberSelect');
|
||||
var helpEl = document.getElementById('shopManageGroupMemberHelp');
|
||||
if (!sel) return;
|
||||
var selectedMap = {};
|
||||
resetShopManageGroupSearch();
|
||||
shopManageGroupEligibleUsers = getEligibleShopManageGroupUsers(leaderUserId);
|
||||
shopManageGroupSelectedIds = {};
|
||||
(selectedUserIds || []).forEach(function (id) {
|
||||
selectedMap[String(id)] = true;
|
||||
shopManageGroupSelectedIds[String(id)] = true;
|
||||
});
|
||||
var eligibleUsers = getEligibleShopManageGroupUsers(leaderUserId);
|
||||
var options = eligibleUsers.map(function (u) {
|
||||
return '<option value="' + escapeHtml(u.id) + '"' + (selectedMap[String(u.id)] ? ' selected' : '') + '>' + escapeHtml(u.username || '') + '</option>';
|
||||
});
|
||||
if (options.length) {
|
||||
sel.innerHTML = options.join('');
|
||||
if (helpEl) {
|
||||
helpEl.textContent = '可添加当前组长创建的普通员工账号,按住 Ctrl 或 Command 可多选。';
|
||||
}
|
||||
return;
|
||||
}
|
||||
sel.innerHTML = '<option value="" disabled>当前组长暂无可添加组员</option>';
|
||||
syncShopManageGroupMemberSelect();
|
||||
renderShopManageGroupContact();
|
||||
updateShopManageGroupMemberCount();
|
||||
if (helpEl) {
|
||||
helpEl.textContent = currentUserRole === 'normal'
|
||||
? '普通账号没有下属普通员工时,这里会为空;当前账号只能作为组长使用。'
|
||||
: '当前组长名下暂无可添加的普通员工账号。';
|
||||
helpEl.textContent = shopManageGroupEligibleUsers.length
|
||||
? '可添加当前组长创建的普通员工账号,点击用户即可选中 / 取消;顶部支持按用户名或拼音首字母搜索。'
|
||||
: (currentUserRole === 'normal'
|
||||
? '普通账号没有下属普通员工时,这里会为空;当前账号只能作为组长使用。'
|
||||
: '当前组长名下暂无可添加的普通员工账号。');
|
||||
}
|
||||
}
|
||||
function setShopManageGroupLeader(leaderUserId, leaderUsername, selectedUserIds) {
|
||||
@@ -2595,7 +2947,8 @@
|
||||
id: u.id,
|
||||
username: u.username || '',
|
||||
role: u.role || 'normal',
|
||||
created_by_id: u.created_by_id || null
|
||||
created_by_id: u.created_by_id || null,
|
||||
pinyin_abbr: u.pinyin_abbr || ''
|
||||
};
|
||||
});
|
||||
setShopManageGroupLeader(
|
||||
@@ -3891,6 +4244,28 @@
|
||||
document.getElementById('btnCloseShopManageGroupModal').onclick = function () {
|
||||
document.getElementById('shopManageGroupModal').classList.remove('show');
|
||||
};
|
||||
// 组员通讯录:搜索 / 字母索引 / 点击选中(打开弹窗时 refresh 会重置搜索词)
|
||||
var groupMemberSearchInput = document.getElementById('shopManageGroupMemberSearch');
|
||||
if (groupMemberSearchInput) {
|
||||
groupMemberSearchInput.addEventListener('input', function () {
|
||||
shopManageGroupSearchKeyword = groupMemberSearchInput.value || '';
|
||||
renderShopManageGroupContact();
|
||||
});
|
||||
}
|
||||
var groupMemberIndexEl = document.getElementById('shopManageGroupMemberIndex');
|
||||
if (groupMemberIndexEl) {
|
||||
groupMemberIndexEl.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('button[data-letter]');
|
||||
if (btn) shopGroupScrollToLetter(btn.dataset.letter);
|
||||
});
|
||||
}
|
||||
var groupMemberContactEl = document.getElementById('shopManageGroupContact');
|
||||
if (groupMemberContactEl) {
|
||||
groupMemberContactEl.addEventListener('click', function (e) {
|
||||
var item = e.target.closest('.shop-group-contact-item');
|
||||
if (item) toggleShopManageGroupMember(item.dataset.userId);
|
||||
});
|
||||
}
|
||||
document.getElementById('btnSearchShopManage').onclick = function () {
|
||||
loadShopManage(1);
|
||||
};
|
||||
|
||||
@@ -30,6 +30,7 @@ def _make_workbook(rows_by_sheet):
|
||||
class ShopDataDuplicateAsinTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.app = Flask(__name__)
|
||||
self.app.config['SECRET_KEY'] = 'test-secret'
|
||||
self.group_rows = [
|
||||
{'shop_name': 'Shop A', 'latest_created_at': datetime(2026, 8, 31, 1, 15, 1)},
|
||||
{'shop_name': 'Shop B', 'latest_created_at': datetime(2026, 8, 31, 1, 15, 2)},
|
||||
@@ -133,23 +134,38 @@ class ShopDataDuplicateAsinTest(unittest.TestCase):
|
||||
self.shop_files[shop_name].save(stream)
|
||||
return stream.getvalue()
|
||||
|
||||
def _run_request(self, query=''):
|
||||
cursor = self._FakeCursor(
|
||||
self.group_rows,
|
||||
[self._result_row(i + 1, row['shop_name'], self.shop_country[row['shop_name']])
|
||||
for i, row in enumerate(self.group_rows)],
|
||||
)
|
||||
connection = self._FakeConnection(cursor)
|
||||
def _build_cache(self):
|
||||
"""构造与旧扫描一致的缓存:3 家店铺、跨店重复 B0ABC111。"""
|
||||
return {
|
||||
'scanned_at': '2026-09-03 03:10:00',
|
||||
'summary': {'shop_count': 3, 'total': 1},
|
||||
'items': [{
|
||||
'asin': 'B0ABC111',
|
||||
'shop_count': 2,
|
||||
'record_count': 3,
|
||||
'occurrences': [
|
||||
{'asin': 'B0ABC111', 'date': '2026-08-30', 'price': 'GBP 9.99', 'brand': 'BrandA',
|
||||
'shop_name': 'Shop A', 'group_name': 'Group-Shop A', 'country_codes': ['UK', 'DE']},
|
||||
{'asin': 'B0ABC111', 'date': '2026-08-30', 'price': 'EUR 10.99', 'brand': 'BrandA',
|
||||
'shop_name': 'Shop A', 'group_name': 'Group-Shop A', 'country_codes': ['DE']},
|
||||
{'asin': 'B0ABC111', 'date': '2026-08-31', 'price': 'GBP 8.50', 'brand': 'BrandA',
|
||||
'shop_name': 'Shop B', 'group_name': 'Group-Shop B', 'country_codes': ['UK']},
|
||||
],
|
||||
}],
|
||||
}
|
||||
|
||||
def _run_request(self, query='', cache=None):
|
||||
# 界面默认读缓存;force 走实时扫描(需 mock 扫描核心)
|
||||
with self.app.test_request_context('/api/admin/shop-data-crawl/duplicate-asins?' + query):
|
||||
with patch.object(admin_api, 'get_db', return_value=connection), \
|
||||
with patch('utils.auth.session', {'user_id': 1}), \
|
||||
patch('utils.auth.is_session_user_valid', return_value=True), \
|
||||
patch.object(admin_api, '_latest_duplicate_scan', return_value=cache), \
|
||||
patch.object(admin_api, '_ensure_backend_menu_access', return_value=(None, None, None)), \
|
||||
patch.object(admin_api, '_ensure_shop_data_crawl_data_access', return_value=(None, None, None)), \
|
||||
patch.object(admin_api, '_shop_data_crawl_fetch_result_bytes',
|
||||
side_effect=lambda row: self._make_workbook_bytes(row['shop_name'])):
|
||||
return admin_api.shop_data_crawl_duplicate_asins.__wrapped__()
|
||||
patch.object(admin_api, '_ensure_shop_data_crawl_data_access', return_value=(None, None, None)):
|
||||
return admin_api.shop_data_crawl_duplicate_asins()
|
||||
|
||||
def test_detects_duplicate_asins_across_shops(self):
|
||||
response = self._run_request('page=1&page_size=10')
|
||||
response = self._run_request('page=1&page_size=10', cache=self._build_cache())
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = response.get_json()
|
||||
self.assertTrue(body['success'])
|
||||
@@ -165,44 +181,103 @@ class ShopDataDuplicateAsinTest(unittest.TestCase):
|
||||
shop_a = next(occ for occ in item['occurrences'] if occ['shop_name'] == 'Shop A')
|
||||
self.assertIn('UK', shop_a['country_codes'])
|
||||
self.assertEqual(shop_a['date'], '2026-08-30')
|
||||
self.assertEqual(body['scanned_at'], '2026-09-03 03:10:00')
|
||||
|
||||
def test_filters_asin_and_date_range(self):
|
||||
# asin 模糊:匹配 B0ABC222 的只有 Shop B 一家,不构成跨店重复 → total 0
|
||||
response = self._run_request('page=1&page_size=10&asin=B0ABC222')
|
||||
def test_no_cache_returns_pending_empty(self):
|
||||
# 无缓存时(定时任务尚未执行):返回空 + pending 提示
|
||||
response = self._run_request('page=1&page_size=10', cache=None)
|
||||
body = response.get_json()
|
||||
self.assertTrue(body['success'])
|
||||
self.assertEqual(body['total'], 0)
|
||||
# 日期范围 08-31:B0ABC111 在 Shop B 有该日记录 → 命中,展示完整记录(跨店仍 2 家)
|
||||
response = self._run_request('page=1&page_size=10&date_from=2026-08-31&date_to=2026-08-31')
|
||||
body = response.get_json()
|
||||
self.assertEqual(body['total'], 1)
|
||||
self.assertEqual(body['items'][0]['asin'], 'B0ABC111')
|
||||
self.assertEqual(body['items'][0]['shop_count'], 2) # 完整记录仍跨店
|
||||
# 日期范围 08-30:Shop A 两条命中,同样返回完整记录
|
||||
response = self._run_request('page=1&page_size=10&date_from=2026-08-30T00:00&date_to=2026-08-30T23:59')
|
||||
body = response.get_json()
|
||||
self.assertEqual(body['total'], 1)
|
||||
|
||||
def test_filters_shop_and_country(self):
|
||||
# 店铺过滤 Shop B:命中含 Shop B 记录的 ASIN → B0ABC111,展示完整记录跨店
|
||||
response = self._run_request('page=1&page_size=10&shop_name=Shop+B')
|
||||
body = response.get_json()
|
||||
self.assertEqual(body['total'], 1)
|
||||
self.assertEqual(body['items'][0]['asin'], 'B0ABC111')
|
||||
# 国家过滤 FR:只有 Shop C 有 FR,其 ASIN 仅单店 → 不构成重复 → total 0
|
||||
response = self._run_request('page=1&page_size=10&country=FR')
|
||||
body = response.get_json()
|
||||
self.assertEqual(body['total'], 0)
|
||||
# 国家过滤 DE:Shop A 命中 → B0ABC111 完整记录 → total 1
|
||||
response = self._run_request('page=1&page_size=10&country=DE')
|
||||
body = response.get_json()
|
||||
self.assertEqual(body['total'], 1)
|
||||
self.assertEqual(body['items'][0]['asin'], 'B0ABC111')
|
||||
|
||||
def test_pagination_when_page_out_of_range(self):
|
||||
response = self._run_request('page=2&page_size=10')
|
||||
body = response.get_json()
|
||||
self.assertEqual(body['total'], 1)
|
||||
self.assertEqual(body['items'], [])
|
||||
self.assertTrue(body['pending'])
|
||||
|
||||
def test_cache_filters_match_live_semantics(self):
|
||||
cache = self._build_cache()
|
||||
# 店铺过滤 Shop B:命中 → 完整记录
|
||||
response = self._run_request('page=1&page_size=10&shop_name=Shop+B', cache=cache)
|
||||
body = response.get_json()
|
||||
self.assertEqual(body['total'], 1)
|
||||
self.assertEqual(body['items'][0]['asin'], 'B0ABC111')
|
||||
# 日期范围 08-31 命中
|
||||
response = self._run_request('page=1&page_size=10&date_from=2026-08-31&date_to=2026-08-31', cache=cache)
|
||||
body = response.get_json()
|
||||
self.assertEqual(body['total'], 1)
|
||||
# 国家 FR 无命中
|
||||
response = self._run_request('page=1&page_size=10&country=FR', cache=cache)
|
||||
body = response.get_json()
|
||||
self.assertEqual(body['total'], 0)
|
||||
|
||||
def test_force_runs_live_scan(self):
|
||||
# force=1 触发实时扫描并保存缓存后返回
|
||||
cursor = self._FakeCursor(
|
||||
self.group_rows,
|
||||
[self._result_row(i + 1, row['shop_name'], self.shop_country[row['shop_name']])
|
||||
for i, row in enumerate(self.group_rows)],
|
||||
)
|
||||
connection = self._FakeConnection(cursor)
|
||||
with self.app.test_request_context('/api/admin/shop-data-crawl/duplicate-asins?page=1&page_size=10&force=1'):
|
||||
with patch('utils.auth.session', {'user_id': 1}), \
|
||||
patch('utils.auth.is_session_user_valid', return_value=True), \
|
||||
patch.object(admin_api, 'get_db', return_value=connection), \
|
||||
patch.object(admin_api, '_ensure_backend_menu_access', return_value=(None, None, None)), \
|
||||
patch.object(admin_api, '_ensure_shop_data_crawl_data_access', return_value=(None, None, None)), \
|
||||
patch.object(admin_api, '_save_duplicate_scan', return_value=1), \
|
||||
patch.object(admin_api, '_latest_duplicate_scan',
|
||||
return_value=self._build_cache()), \
|
||||
patch.object(admin_api, '_shop_data_crawl_fetch_result_bytes',
|
||||
side_effect=lambda row: self._make_workbook_bytes(row['shop_name'])):
|
||||
response = admin_api.shop_data_crawl_duplicate_asins()
|
||||
self.assertIsNotNone(response)
|
||||
body = response.get_json()
|
||||
self.assertTrue(body['success'])
|
||||
self.assertEqual(body['total'], 1)
|
||||
self.assertEqual(body['items'][0]['asin'], 'B0ABC111')
|
||||
|
||||
def test_force_conflict_when_lock_held(self):
|
||||
# 锁被占(定时任务/他请求在扫)时 force 返回 409
|
||||
cursor = self._FakeCursor(self.group_rows[:0], [])
|
||||
connection = self._FakeConnection(cursor)
|
||||
with self.app.test_request_context('/api/admin/shop-data-crawl/duplicate-asins?page=1&page_size=10&force=1'):
|
||||
with patch('utils.auth.session', {'user_id': 1}), \
|
||||
patch('utils.auth.is_session_user_valid', return_value=True), \
|
||||
patch.object(admin_api, 'get_db', return_value=connection), \
|
||||
patch.object(admin_api, '_ensure_backend_menu_access', return_value=(None, None, None)), \
|
||||
patch.object(admin_api, '_ensure_shop_data_crawl_data_access', return_value=(None, None, None)), \
|
||||
patch.object(admin_api, '_duplicate_scan_lock'):
|
||||
admin_api._duplicate_scan_lock.acquire.return_value = False
|
||||
response = admin_api.shop_data_crawl_duplicate_asins()
|
||||
# 409 返回 (jsonify, status) tuple
|
||||
self.assertEqual(response[1], 409)
|
||||
|
||||
def test_latest_duplicate_scan_deserializes_json_columns(self):
|
||||
"""真实读库:summary_json / payload_json 为 JSON 字符串,需反序列化为 dict/list。"""
|
||||
class _ScanCursor(self._FakeCursor):
|
||||
def __init__(self):
|
||||
self.kind = None
|
||||
|
||||
def execute(self, sql, params=()):
|
||||
self.kind = 'scan'
|
||||
|
||||
def fetchone(self):
|
||||
return {
|
||||
'id': 9,
|
||||
'summary_json': '{"shop_count": 3, "total": 1, "source": "import"}',
|
||||
'payload_json': '[{"asin": "B0ABC111", "shop_count": 2}]',
|
||||
'created_at': datetime(2026, 9, 3, 13, 35, 25),
|
||||
}
|
||||
|
||||
class _ScanConnection(self._FakeConnection):
|
||||
def cursor(self):
|
||||
return _ScanCursor()
|
||||
|
||||
with patch.object(admin_api, 'get_db', return_value=_ScanConnection(None)):
|
||||
cache = admin_api._latest_duplicate_scan()
|
||||
self.assertIsNotNone(cache)
|
||||
self.assertEqual(cache['summary']['shop_count'], 3)
|
||||
self.assertEqual(cache['summary']['source'], 'import')
|
||||
self.assertEqual(cache['items'][0]['asin'], 'B0ABC111')
|
||||
self.assertEqual(cache['scanned_at'], '2026-09-03 13:35:25')
|
||||
|
||||
def test_parse_workbook_skips_unknown_sheets(self):
|
||||
wb = _make_workbook({'英国': [('2026-08-30', 'B0TEST01', 'GBP 1.00', '')]})
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
"""店铺数据重复检查接口单元测试:全量缓存格式、矩阵分页、主管/超管数据范围过滤。"""
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from flask import Flask
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from blueprints import admin_api
|
||||
|
||||
|
||||
def _shop(name, group, country_codes=None):
|
||||
return {
|
||||
'shop_name': name,
|
||||
'group_name': group,
|
||||
'country_codes': country_codes or [],
|
||||
'rows': [],
|
||||
}
|
||||
|
||||
|
||||
class DuplicateCheckApiTest(unittest.TestCase):
|
||||
"""直接对接口函数做单元测试:缓存通过 _latest_duplicate_scan mock 注入。"""
|
||||
|
||||
def setUp(self):
|
||||
self.app = Flask(__name__)
|
||||
self.app.config['SECRET_KEY'] = 'test-secret'
|
||||
# 4 家店 / 3 个组;ASIN 分布:
|
||||
# A1: ShopA(UK) + ShopB(UK) —— 跨店重复
|
||||
# B1: ShopA(DE) 唯一
|
||||
# C1: ShopC(FR) 唯一
|
||||
# D1: ShopD(UK) 唯一
|
||||
self.cache = {
|
||||
'scanned_at': '2026-09-04 03:10:00',
|
||||
'summary': {
|
||||
'shop_count': 4, 'asin_total': 4, 'record_total': 5,
|
||||
'duplicate_asin_total': 1, 'duplicate_shop_count': 2,
|
||||
'site_count': 3, 'asin_per_shop': 1.2, 'source': 'job',
|
||||
},
|
||||
'shops': [
|
||||
{'shop_name': 'ShopA', 'group_name': 'GroupA', 'country_codes': ['UK', 'DE'], 'asin_count': 2, 'record_count': 3},
|
||||
{'shop_name': 'ShopB', 'group_name': 'GroupA', 'country_codes': ['UK'], 'asin_count': 1, 'record_count': 1},
|
||||
{'shop_name': 'ShopC', 'group_name': 'GroupC', 'country_codes': ['FR'], 'asin_count': 1, 'record_count': 1},
|
||||
{'shop_name': 'ShopD', 'group_name': 'GroupD', 'country_codes': ['UK'], 'asin_count': 1, 'record_count': 1},
|
||||
],
|
||||
'items': [
|
||||
{'asin': 'A0000001', 'shop_count': 2, 'record_count': 2, 'occurrences': [
|
||||
{'asin': 'A0000001', 'date': '2026-08-30', 'price': 'GBP 9.99', 'brand': 'BrandA',
|
||||
'shop_name': 'ShopA', 'group_name': 'GroupA', 'country_codes': ['UK', 'DE'], 'country': 'UK'},
|
||||
{'asin': 'A0000001', 'date': '2026-08-31', 'price': 'GBP 8.50', 'brand': 'BrandA',
|
||||
'shop_name': 'ShopB', 'group_name': 'GroupA', 'country_codes': ['UK'], 'country': 'UK'},
|
||||
]},
|
||||
{'asin': 'B0000001', 'shop_count': 1, 'record_count': 1, 'occurrences': [
|
||||
{'asin': 'B0000001', 'date': '2026-08-30', 'price': 'EUR 10.99', 'brand': 'BrandA',
|
||||
'shop_name': 'ShopA', 'group_name': 'GroupA', 'country_codes': ['DE'], 'country': 'DE'},
|
||||
]},
|
||||
{'asin': 'C0000001', 'shop_count': 1, 'record_count': 1, 'occurrences': [
|
||||
{'asin': 'C0000001', 'date': '2026-08-29', 'price': 'EUR 7.50', 'brand': 'BrandC',
|
||||
'shop_name': 'ShopC', 'group_name': 'GroupC', 'country_codes': ['FR'], 'country': 'FR'},
|
||||
]},
|
||||
{'asin': 'D0000001', 'shop_count': 1, 'record_count': 1, 'occurrences': [
|
||||
{'asin': 'D0000001', 'date': '2026-08-28', 'price': 'GBP 5.00', 'brand': 'BrandD',
|
||||
'shop_name': 'ShopD', 'group_name': 'GroupD', 'country_codes': ['UK'], 'country': 'UK'},
|
||||
]},
|
||||
],
|
||||
}
|
||||
|
||||
def _access_patches(self, role='super_admin', current_row=None):
|
||||
return [
|
||||
patch.object(admin_api, '_ensure_backend_menu_access',
|
||||
return_value=(role, current_row or {'id': 1}, None)),
|
||||
patch.object(admin_api, '_ensure_shop_data_crawl_data_access',
|
||||
return_value=(role, current_row or {'id': 1}, None)),
|
||||
patch.object(admin_api, '_latest_duplicate_scan', return_value=self.cache),
|
||||
]
|
||||
|
||||
def _call(self, url, role='super_admin', current_row=None):
|
||||
with self.app.test_request_context(url):
|
||||
with patch('utils.auth.session', {'user_id': 1}), \
|
||||
patch('utils.auth.is_session_user_valid', return_value=True):
|
||||
for p in self._access_patches(role, current_row):
|
||||
p.start()
|
||||
try:
|
||||
return admin_api.shop_data_crawl_duplicate_check_items()
|
||||
finally:
|
||||
for p in reversed(self._access_patches(role, current_row)):
|
||||
p.stop()
|
||||
|
||||
def test_overview_super_admin_sees_all(self):
|
||||
with self.app.test_request_context('/api/admin/shop-data-crawl/duplicate-check-overview'):
|
||||
with patch('utils.auth.session', {'user_id': 1}), \
|
||||
patch('utils.auth.is_session_user_valid', return_value=True), \
|
||||
patch.object(admin_api, '_ensure_backend_menu_access', return_value=('super_admin', {'id': 1}, None)), \
|
||||
patch.object(admin_api, '_ensure_shop_data_crawl_data_access', return_value=('super_admin', {'id': 1}, None)), \
|
||||
patch.object(admin_api, '_latest_duplicate_scan', return_value=self.cache):
|
||||
response = admin_api.shop_data_crawl_duplicate_check_overview()
|
||||
body = response.get_json()
|
||||
self.assertTrue(body['success'])
|
||||
self.assertFalse(body['pending'])
|
||||
self.assertEqual(body['summary']['asin_total'], 4)
|
||||
self.assertEqual(body['summary']['record_total'], 5)
|
||||
self.assertEqual(body['summary']['duplicate_asin_total'], 1)
|
||||
self.assertEqual(len(body['shops']), 4)
|
||||
|
||||
def test_items_matix_columns_are_shops(self):
|
||||
response = self._call('/api/admin/shop-data-crawl/duplicate-check-items?page=1&page_size=20&view=monitor')
|
||||
body = response.get_json()
|
||||
self.assertTrue(body['success'])
|
||||
self.assertEqual(body['total'], 1) # monitor 只保留跨店重复
|
||||
self.assertEqual(body['items'][0]['asin'], 'A0000001')
|
||||
self.assertEqual([shop['shop_name'] for shop in body['shops']],
|
||||
['ShopA', 'ShopB', 'ShopC', 'ShopD'])
|
||||
|
||||
def test_items_all_view_includes_unique_asins(self):
|
||||
response = self._call('/api/admin/shop-data-crawl/duplicate-check-items?page=1&page_size=20&view=all')
|
||||
body = response.get_json()
|
||||
self.assertEqual(body['total'], 4)
|
||||
|
||||
def test_items_filter_by_asin_and_site(self):
|
||||
response = self._call('/api/admin/shop-data-crawl/duplicate-check-items?page=1&page_size=20&asin=A0000001&site=UK')
|
||||
body = response.get_json()
|
||||
self.assertEqual(body['total'], 1)
|
||||
self.assertEqual(body['items'][0]['asin'], 'A0000001')
|
||||
|
||||
def test_leader_sees_only_own_group_shops(self):
|
||||
# 主管 id=653 只管理 GroupA(含 ShopA/ShopB)
|
||||
with patch.object(admin_api, '_shop_data_managed_shop_names',
|
||||
return_value={'shopa', 'shopb'}):
|
||||
response = self._call('/api/admin/shop-data-crawl/duplicate-check-items?page=1&page_size=20&view=all',
|
||||
role='admin', current_row={'id': 653})
|
||||
body = response.get_json()
|
||||
self.assertEqual([shop['shop_name'] for shop in body['shops']], ['ShopA', 'ShopB'])
|
||||
self.assertEqual(body['total'], 2) # A0000001(跨店)+ B0000001(唯一)
|
||||
# 跨店 ASIN 在主管范围内仍是 2 家店
|
||||
item = next(i for i in body['items'] if i['asin'] == 'A0000001')
|
||||
self.assertEqual(item['shop_count'], 2)
|
||||
|
||||
def test_leader_overview_stats_recomputed_after_filter(self):
|
||||
# 主管见 2 家店:唯一ASIN 2、上架记录 3、重复ASIN 1、重复店铺 2
|
||||
with patch.object(admin_api, '_shop_data_managed_shop_names',
|
||||
return_value={'shopa', 'shopb'}):
|
||||
with self.app.test_request_context('/api/admin/shop-data-crawl/duplicate-check-overview'):
|
||||
with patch('utils.auth.session', {'user_id': 1}), \
|
||||
patch('utils.auth.is_session_user_valid', return_value=True), \
|
||||
patch.object(admin_api, '_ensure_backend_menu_access', return_value=('admin', {'id': 653}, None)), \
|
||||
patch.object(admin_api, '_ensure_shop_data_crawl_data_access', return_value=('admin', {'id': 653}, None)), \
|
||||
patch.object(admin_api, '_latest_duplicate_scan', return_value=self.cache):
|
||||
response = admin_api.shop_data_crawl_duplicate_check_overview()
|
||||
body = response.get_json()
|
||||
self.assertEqual(body['summary']['shop_count'], 2)
|
||||
self.assertEqual(body['summary']['asin_total'], 2)
|
||||
self.assertEqual(body['summary']['record_total'], 3)
|
||||
self.assertEqual(body['summary']['duplicate_asin_total'], 1)
|
||||
|
||||
def test_leader_sees_nothing_when_no_group(self):
|
||||
with patch.object(admin_api, '_shop_data_managed_shop_names', return_value=set()):
|
||||
response = self._call('/api/admin/shop-data-crawl/duplicate-check-items?page=1&page_size=20&view=all',
|
||||
role='admin', current_row={'id': 999999})
|
||||
body = response.get_json()
|
||||
self.assertEqual(body['shops'], [])
|
||||
self.assertEqual(body['total'], 0)
|
||||
|
||||
def test_export_csv_contains_filtered_rows(self):
|
||||
"""导出 CSV:行=上架记录,含 BOM,按筛选裁剪。"""
|
||||
with self.app.test_request_context('/api/admin/shop-data-crawl/duplicate-check-export?'):
|
||||
with patch('utils.auth.session', {'user_id': 1}), \
|
||||
patch('utils.auth.is_session_user_valid', return_value=True), \
|
||||
patch.object(admin_api, '_ensure_backend_menu_access', return_value=('super_admin', {'id': 1}, None)), \
|
||||
patch.object(admin_api, '_ensure_shop_data_crawl_data_access', return_value=('super_admin', {'id': 1}, None)), \
|
||||
patch.object(admin_api, '_latest_duplicate_scan', return_value=self.cache):
|
||||
response = admin_api.shop_data_crawl_duplicate_check_export()
|
||||
self.assertEqual(response.status_code, 200)
|
||||
text = response.get_data(as_text=True)
|
||||
self.assertTrue(text.startswith(''))
|
||||
lines = text.lstrip('').strip().splitlines()
|
||||
self.assertEqual(lines[0], 'ASIN,店铺数,店铺,分组,站点,上架时间,价格,品牌')
|
||||
self.assertEqual(len(lines), 3) # header + 两条上架记录(A0000001 两店各一条)
|
||||
|
||||
def test_export_monitor_view_excludes_unique_asins(self):
|
||||
with self.app.test_request_context('/api/admin/shop-data-crawl/duplicate-check-export?view=monitor'):
|
||||
with patch('utils.auth.session', {'user_id': 1}), \
|
||||
patch('utils.auth.is_session_user_valid', return_value=True), \
|
||||
patch.object(admin_api, '_ensure_backend_menu_access', return_value=('super_admin', {'id': 1}, None)), \
|
||||
patch.object(admin_api, '_ensure_shop_data_crawl_data_access', return_value=('super_admin', {'id': 1}, None)), \
|
||||
patch.object(admin_api, '_latest_duplicate_scan', return_value=self.cache):
|
||||
response = admin_api.shop_data_crawl_duplicate_check_export()
|
||||
lines = response.get_data(as_text=True).lstrip('').strip().splitlines()
|
||||
self.assertEqual(len(lines), 3)
|
||||
self.assertIn('A0000001', lines[1])
|
||||
|
||||
def test_internal_request_falls_back_to_system_operator(self):
|
||||
"""无请求上下文(定时扫描线程)时,内部请求用系统级超管作为 operatorId。"""
|
||||
with patch('utils.auth.session', {'user_id': 1}): # 仅用于模拟无异常环境
|
||||
# 无请求上下文:has_request_context() 为 False,直接走 _resolve_system_operator_id
|
||||
with patch.object(admin_api, '_resolve_internal_token', return_value='test-token'), \
|
||||
patch.object(admin_api, '_resolve_system_operator_id', return_value=7):
|
||||
headers, params = admin_api._backend_java_internal_request()
|
||||
self.assertEqual(headers.get('X-Internal-Token'), 'test-token')
|
||||
self.assertEqual(params, {'operatorId': 7})
|
||||
|
||||
def test_internal_request_system_operator_missing_fails(self):
|
||||
"""无请求上下文且系统中没有任何管理员时,直接报错而不是传 0。"""
|
||||
with patch.object(admin_api, '_resolve_internal_token', return_value='test-token'), \
|
||||
patch.object(admin_api, '_resolve_system_operator_id', return_value=None):
|
||||
with self.assertRaises(ValueError):
|
||||
admin_api._backend_java_internal_request()
|
||||
|
||||
def test_fetch_result_bytes_rejects_json_error_body(self):
|
||||
"""Java 内部端点返回 JSON 错误体(如鉴权失败)时,抛出业务错误而不是 BadZipFile。"""
|
||||
fake_response = Mock()
|
||||
fake_response.raise_for_status = Mock()
|
||||
fake_response.iter_content = Mock(return_value=[
|
||||
'{"success":false,"message":"用户不存在","data":null,"code":401}'.encode('utf-8')])
|
||||
fake_response.close = Mock()
|
||||
with patch.object(admin_api, '_backend_java_internal_request',
|
||||
return_value=({'X-Internal-Token': 'test-token'}, {'operatorId': 7})), \
|
||||
patch.object(admin_api, '_get_backend_java_session') as fake_session:
|
||||
fake_session.return_value.get = Mock(return_value=fake_response)
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
admin_api._shop_data_crawl_fetch_result_bytes({'result_id': 123})
|
||||
self.assertIn('用户不存在', str(ctx.exception))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
+940
-113
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user