feat(collect): show skipped reason summaries
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
"""Pure helpers for classifying and presenting collection skip reasons."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
ALIAS_UNMATCHED = "alias_unmatched"
|
||||
LOGIN_REQUIRED = "login_required"
|
||||
OTHER = "other"
|
||||
|
||||
SKIP_REASON_ORDER = (ALIAS_UNMATCHED, LOGIN_REQUIRED, OTHER)
|
||||
SKIP_REASON_LABELS = {
|
||||
ALIAS_UNMATCHED: "别名未匹配",
|
||||
LOGIN_REQUIRED: "账号未登录",
|
||||
OTHER: "其他原因",
|
||||
}
|
||||
|
||||
|
||||
def empty_skip_reason_counts():
|
||||
return {reason: 0 for reason in SKIP_REASON_ORDER}
|
||||
|
||||
|
||||
def classify_skip_reason(reason="", *, alias_matched=True):
|
||||
"""Classify historical skip text without treating uncertain login checks as logout."""
|
||||
if not alias_matched:
|
||||
return ALIAS_UNMATCHED
|
||||
|
||||
text = str(reason or "").strip().lower()
|
||||
if "别名未匹配" in text or "alias_unmatched" in text:
|
||||
return ALIAS_UNMATCHED
|
||||
if (
|
||||
"账号未登录" in text
|
||||
or "采集中途掉登录" in text
|
||||
or "login_page" in text
|
||||
or ("accounts.shopee." in text and "/seller/login" in text)
|
||||
):
|
||||
return LOGIN_REQUIRED
|
||||
return OTHER
|
||||
|
||||
|
||||
def skipped_stage_text(reason="", *, alias_matched=True):
|
||||
reason_code = classify_skip_reason(reason, alias_matched=alias_matched)
|
||||
return f"略过 · {SKIP_REASON_LABELS[reason_code]}"
|
||||
|
||||
|
||||
def normalize_skip_reason_counts(counts=None, *, skipped_total=0):
|
||||
normalized = empty_skip_reason_counts()
|
||||
source = counts if isinstance(counts, dict) else {}
|
||||
for reason in SKIP_REASON_ORDER:
|
||||
try:
|
||||
normalized[reason] = max(0, int(source.get(reason, 0) or 0))
|
||||
except (TypeError, ValueError):
|
||||
normalized[reason] = 0
|
||||
|
||||
total = max(0, int(skipped_total or 0))
|
||||
accounted = sum(normalized.values())
|
||||
if accounted < total:
|
||||
normalized[OTHER] += total - accounted
|
||||
return normalized
|
||||
|
||||
|
||||
def format_skip_reason_summary(counts=None, *, skipped_total=0):
|
||||
total = max(0, int(skipped_total or 0))
|
||||
if total == 0:
|
||||
return ""
|
||||
normalized = normalize_skip_reason_counts(counts, skipped_total=total)
|
||||
parts = [
|
||||
f"{SKIP_REASON_LABELS[reason]}{normalized[reason]}"
|
||||
for reason in SKIP_REASON_ORDER
|
||||
if normalized[reason] > 0
|
||||
]
|
||||
return f"略过{total}:" + ",".join(parts)
|
||||
+17
-5
@@ -2,7 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import diagnostics
|
||||
from ..collect_skip import skipped_stage_text
|
||||
from .widgets import *
|
||||
|
||||
|
||||
class TaskTableModel(QAbstractTableModel):
|
||||
"""Table model for task rows shared by workflow tabs."""
|
||||
|
||||
@@ -88,11 +92,15 @@ class TaskTableModel(QAbstractTableModel):
|
||||
if role == Qt.ForegroundRole and index.column() == 3:
|
||||
return self._stage_color(task)
|
||||
if role == Qt.ToolTipRole:
|
||||
if self.is_unmatched(task):
|
||||
return "别名未匹配账号,采集时将略过"
|
||||
error = getattr(task, "last_error", "") or ""
|
||||
if self.is_unmatched(task) or getattr(task, "status", "") == "skipped":
|
||||
if error:
|
||||
return diagnostics.redact_log_text(str(error))
|
||||
if self.is_unmatched(task):
|
||||
return "别名未匹配账号,采集时将略过"
|
||||
return "未记录具体略过原因"
|
||||
if error:
|
||||
return str(error)
|
||||
return diagnostics.redact_log_text(str(error))
|
||||
return None
|
||||
|
||||
def flags(self, index):
|
||||
@@ -124,8 +132,12 @@ class TaskTableModel(QAbstractTableModel):
|
||||
return bool(error) and any(marker.lower() in error for marker in self.PRODUCT_UNAVAILABLE_MARKERS)
|
||||
|
||||
def _stage_text(self, task) -> str:
|
||||
if self.is_unmatched(task):
|
||||
return "略过"
|
||||
unmatched = self.is_unmatched(task)
|
||||
if unmatched or getattr(task, "status", "") == "skipped":
|
||||
return skipped_stage_text(
|
||||
getattr(task, "last_error", ""),
|
||||
alias_matched=not unmatched,
|
||||
)
|
||||
if self._is_product_unavailable(task):
|
||||
return "商品失效"
|
||||
if task.status in self.STATUS_TEXT and task.status != "pending":
|
||||
|
||||
+22
-3
@@ -2,6 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ...collect_skip import (
|
||||
ALIAS_UNMATCHED,
|
||||
LOGIN_REQUIRED,
|
||||
format_skip_reason_summary,
|
||||
normalize_skip_reason_counts,
|
||||
)
|
||||
from ..models import TaskTableModel
|
||||
from ..widgets import *
|
||||
from ..workers import CollectWorker as _RealCollectWorker, WriteBackWorker as _RealWriteBackWorker
|
||||
@@ -694,9 +700,19 @@ class CollectTab(QWidget):
|
||||
launched = payload.get("launched_accounts") or []
|
||||
reused = payload.get("reused_accounts") or []
|
||||
login_required = payload.get("login_required_accounts") or []
|
||||
if not launched and not reused and not login_required:
|
||||
skipped = max(0, int(payload.get("skipped", 0) or 0))
|
||||
skip_counts = normalize_skip_reason_counts(
|
||||
payload.get("skip_reason_counts"),
|
||||
skipped_total=skipped,
|
||||
)
|
||||
if not launched and not reused and not login_required and skipped == 0:
|
||||
return
|
||||
lines = [message]
|
||||
skip_summary = format_skip_reason_summary(skip_counts, skipped_total=skipped)
|
||||
if skip_summary:
|
||||
lines.append(skip_summary)
|
||||
if skip_counts[ALIAS_UNMATCHED] > 0:
|
||||
lines.append("请检查 Excel 别名是否与④账号管理中的账号别名一致。")
|
||||
if launched:
|
||||
lines.append(
|
||||
"本轮已自动启动账号 Chrome:"
|
||||
@@ -707,9 +723,12 @@ class CollectTab(QWidget):
|
||||
"以下账号需要补登录:"
|
||||
+ "、".join(self._account_label(item) for item in login_required)
|
||||
)
|
||||
lines.append("采集结束后不会自动关闭账号 Chrome,请按需自行关闭。")
|
||||
if skip_counts[LOGIN_REQUIRED] > 0:
|
||||
lines.append("请到④账号管理完成对应账号登录后,再重新采集略过任务。")
|
||||
if launched or reused or login_required:
|
||||
lines.append("采集结束后不会自动关闭账号 Chrome,请按需自行关闭。")
|
||||
text = "\n".join(lines)
|
||||
if login_required:
|
||||
if login_required or skip_counts[LOGIN_REQUIRED] > 0:
|
||||
QMessageBox.warning(self, "采集完成", text)
|
||||
else:
|
||||
QMessageBox.information(self, "采集完成", text)
|
||||
|
||||
+10
-1
@@ -8,6 +8,7 @@ import threading
|
||||
import time
|
||||
|
||||
from .. import image_studio, image_studio_export, image_studio_generation, image_studio_images
|
||||
from ..collect_skip import ALIAS_UNMATCHED, LOGIN_REQUIRED, empty_skip_reason_counts
|
||||
from .widgets import *
|
||||
|
||||
|
||||
@@ -1623,6 +1624,7 @@ class CollectWorker(BaseWorker):
|
||||
login_skip_reasons = {}
|
||||
login_required_accounts = {}
|
||||
preflight_info = {}
|
||||
skip_reason_counts = empty_skip_reason_counts()
|
||||
|
||||
self._run_id = self._create_run_log(eligible, batch_ids)
|
||||
self._log_run_event(
|
||||
@@ -1642,7 +1644,10 @@ class CollectWorker(BaseWorker):
|
||||
failed=failed,
|
||||
batch_ids=batch_ids,
|
||||
blocked=True,
|
||||
extra=blocked,
|
||||
extra={
|
||||
**blocked,
|
||||
"skip_reason_counts": dict(skip_reason_counts),
|
||||
},
|
||||
)
|
||||
self._finish_run_log("blocked", summary)
|
||||
return summary
|
||||
@@ -1665,6 +1670,7 @@ class CollectWorker(BaseWorker):
|
||||
account = account_by_alias.get(str(task.alias).strip())
|
||||
if account is None:
|
||||
skipped += 1
|
||||
skip_reason_counts[ALIAS_UNMATCHED] += 1
|
||||
done += 1
|
||||
reason = "别名未匹配账号"
|
||||
db.mark_skipped(task.id, reason, path=self.db_path)
|
||||
@@ -1684,6 +1690,7 @@ class CollectWorker(BaseWorker):
|
||||
alias = str(task.alias).strip()
|
||||
if alias in login_skip_reasons:
|
||||
skipped += 1
|
||||
skip_reason_counts[LOGIN_REQUIRED] += 1
|
||||
done += 1
|
||||
reason = login_skip_reasons[alias]
|
||||
db.mark_skipped(task.id, reason, path=self.db_path)
|
||||
@@ -1704,6 +1711,7 @@ class CollectWorker(BaseWorker):
|
||||
if self._is_definitive_logged_out(status):
|
||||
alias = str(task.alias).strip()
|
||||
skipped += 1
|
||||
skip_reason_counts[LOGIN_REQUIRED] += 1
|
||||
done += 1
|
||||
reason = self._midrun_login_skip_reason(status)
|
||||
login_skip_reasons[alias] = reason
|
||||
@@ -1840,6 +1848,7 @@ class CollectWorker(BaseWorker):
|
||||
extra={
|
||||
**preflight_info,
|
||||
"login_required_accounts": list(login_required_accounts.values()),
|
||||
"skip_reason_counts": dict(skip_reason_counts),
|
||||
},
|
||||
)
|
||||
self._finish_run_log("cancelled" if self.should_cancel() else "done", summary)
|
||||
|
||||
+5
-2
@@ -3,7 +3,7 @@ id: T-620
|
||||
title: 采集略过原因可见化与结束分类汇总
|
||||
phase: 2
|
||||
deps: [T-563, T-515]
|
||||
status: TODO
|
||||
status: DONE
|
||||
created: 2026-07-13
|
||||
---
|
||||
|
||||
@@ -48,4 +48,7 @@ T-563 已修复 `NO_SESSION_COOKIE`、CDP短暂异常等不确定登录状态导
|
||||
|
||||
## 执行记录
|
||||
|
||||
(完成后记录实现、验证命令与结果。)
|
||||
- 2026-07-13:新增 `app/collect_skip.py` 集中处理历史略过原因分类、阶段短文案、分类计数归一化和结束摘要;明确 `NO_SESSION_COOKIE` 归入未知兼容类型,不作为账号未登录依据。
|
||||
- 2026-07-13:①列表阶段列显示“略过 · 别名未匹配 / 账号未登录 / 其他原因”,tooltip展示脱敏完整原因;未新增列,也未修改数据库字段。
|
||||
- 2026-07-13:`CollectWorker` 按现有别名未匹配、采集前明确未登录、采集中明确掉登录三个结构化分支累计 `skip_reason_counts`;采集结束弹窗增加分类摘要和对应处理指引。
|
||||
- 2026-07-13:增加纯逻辑、表格历史记录、Worker分类汇总、结束弹窗及T-563 `NO_SESSION_COOKIE` 回归测试。为隔离工作区其它未提交改动,在仅含T-620差异的干净worktree验证:`python -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`py -3.10 -m unittest discover -s tests`(424项通过)、`git diff --check`,结果全部通过。
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import unittest
|
||||
|
||||
from app.collect_skip import (
|
||||
ALIAS_UNMATCHED,
|
||||
LOGIN_REQUIRED,
|
||||
OTHER,
|
||||
classify_skip_reason,
|
||||
format_skip_reason_summary,
|
||||
normalize_skip_reason_counts,
|
||||
skipped_stage_text,
|
||||
)
|
||||
|
||||
|
||||
class CollectSkipReasonTests(unittest.TestCase):
|
||||
def test_classifies_supported_and_unknown_historical_reasons(self):
|
||||
self.assertEqual(ALIAS_UNMATCHED, classify_skip_reason("别名未匹配账号"))
|
||||
self.assertEqual(LOGIN_REQUIRED, classify_skip_reason("采集中途掉登录:LOGIN_PAGE"))
|
||||
self.assertEqual(OTHER, classify_skip_reason("NO_SESSION_COOKIE"))
|
||||
self.assertEqual(OTHER, classify_skip_reason(""))
|
||||
|
||||
def test_unmatched_alias_has_priority_over_reason_text(self):
|
||||
self.assertEqual(
|
||||
ALIAS_UNMATCHED,
|
||||
classify_skip_reason("账号未登录", alias_matched=False),
|
||||
)
|
||||
self.assertEqual(
|
||||
"略过 · 别名未匹配",
|
||||
skipped_stage_text("", alias_matched=False),
|
||||
)
|
||||
|
||||
def test_summary_fills_unclassified_count_as_other(self):
|
||||
counts = normalize_skip_reason_counts(
|
||||
{ALIAS_UNMATCHED: 2, LOGIN_REQUIRED: 1},
|
||||
skipped_total=4,
|
||||
)
|
||||
self.assertEqual(1, counts[OTHER])
|
||||
self.assertEqual(
|
||||
"略过4:别名未匹配2,账号未登录1,其他原因1",
|
||||
format_skip_reason_summary(counts, skipped_total=4),
|
||||
)
|
||||
|
||||
def test_zero_skips_has_no_empty_summary(self):
|
||||
self.assertEqual("", format_skip_reason_summary({}, skipped_total=0))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+68
-4
@@ -6959,7 +6959,11 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
self.assertNotIn("padding", tab.delete_batch_button.styleSheet())
|
||||
self.assertEqual("Excel副店", tab.model.index(1, 0).data())
|
||||
self.assertEqual("missing", tab.model.index(1, 1).data())
|
||||
self.assertEqual("略过", tab.model.index(1, 3).data())
|
||||
self.assertEqual("略过 · 别名未匹配", tab.model.index(1, 3).data())
|
||||
self.assertEqual(
|
||||
"别名未匹配账号,采集时将略过",
|
||||
tab.model.index(1, 3).data(Qt.ToolTipRole),
|
||||
)
|
||||
self.assert_foreground(tab.model, 1, 3, gui.COLOR_MUTED)
|
||||
self.assertIn("2 行", tab.summary_label.text())
|
||||
self.assertIn("有效2/无效0", tab.summary_label.text())
|
||||
@@ -7061,7 +7065,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
tab.status_filter.setCurrentIndex(tab.status_filter.findData("skipped"))
|
||||
self.assertEqual(1, tab.model.rowCount())
|
||||
self.assertEqual("missing", tab.model.index(0, 1).data())
|
||||
self.assertEqual("略过", tab.model.index(0, 3).data())
|
||||
self.assertEqual("略过 · 别名未匹配", tab.model.index(0, 3).data())
|
||||
|
||||
tab.item_filter.setText("no-match")
|
||||
self.assertEqual(0, tab.model.rowCount())
|
||||
@@ -7234,7 +7238,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
|
||||
self.assertEqual(1, tab.model.rowCount())
|
||||
self.assertEqual("missing", tab.model.index(0, 1).data())
|
||||
self.assertEqual("略过", tab.model.index(0, 3).data())
|
||||
self.assertEqual("略过 · 别名未匹配", tab.model.index(0, 3).data())
|
||||
|
||||
tab.show_all_tasks()
|
||||
|
||||
@@ -7242,6 +7246,36 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_collect_task_model_shows_historical_skip_reasons_and_tooltips(self):
|
||||
model = gui.TaskTableModel()
|
||||
account = SimpleNamespace(alias="alias-a", account_name="主店")
|
||||
login_reason = "采集中途掉登录: 账号未登录: LOGIN_PAGE"
|
||||
tasks = [
|
||||
SimpleNamespace(
|
||||
alias="alias-a",
|
||||
account_name="主店",
|
||||
item_id="1001",
|
||||
stage="imported",
|
||||
status="skipped",
|
||||
last_error=login_reason,
|
||||
),
|
||||
SimpleNamespace(
|
||||
alias="alias-a",
|
||||
account_name="主店",
|
||||
item_id="1002",
|
||||
stage="imported",
|
||||
status="skipped",
|
||||
last_error="运营手动略过",
|
||||
),
|
||||
]
|
||||
|
||||
model.set_tasks(tasks, [account])
|
||||
|
||||
self.assertEqual("略过 · 账号未登录", model.index(0, 3).data())
|
||||
self.assertEqual(login_reason, model.index(0, 3).data(Qt.ToolTipRole))
|
||||
self.assertEqual("略过 · 其他原因", model.index(1, 3).data())
|
||||
self.assertEqual("运营手动略过", model.index(1, 3).data(Qt.ToolTipRole))
|
||||
|
||||
def test_collect_tab_import_button_imports_excel_and_refreshes_tasks(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg = self.make_config(temp_dir)
|
||||
@@ -7418,6 +7452,10 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
self.assertEqual(1, summary["collected"])
|
||||
self.assertEqual(2, summary["skipped"])
|
||||
self.assertEqual(0, summary["failed"])
|
||||
self.assertEqual(
|
||||
{"alias_unmatched": 1, "login_required": 1, "other": 0},
|
||||
summary["skip_reason_counts"],
|
||||
)
|
||||
self.assertEqual([batch_id], summary["batch_ids"])
|
||||
self.assertIsInstance(summary["run_id"], int)
|
||||
collect.assert_called_once()
|
||||
@@ -7587,11 +7625,13 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
tab = CollectTab(config=cfg, status_callback=statuses.append)
|
||||
self.addCleanup(tab.close)
|
||||
|
||||
with mock.patch.object(tab, "_start_write_back") as start_write_back:
|
||||
with mock.patch.object(tab, "_start_write_back") as start_write_back, \
|
||||
mock.patch("app.gui.QMessageBox.information") as information:
|
||||
tab._on_collect_finished({"collected": 0, "skipped": 1, "failed": 0})
|
||||
|
||||
start_write_back.assert_not_called()
|
||||
self.assertEqual("采集完成:成功0,略过1,失败0", statuses[-1])
|
||||
self.assertIn("略过1:其他原因1", information.call_args[0][2])
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
@@ -7761,6 +7801,10 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
self.assertFalse(summary.get("blocked"))
|
||||
self.assertTrue(summary["ok"])
|
||||
self.assertEqual(1, summary["skipped"])
|
||||
self.assertEqual(
|
||||
{"alias_unmatched": 0, "login_required": 1, "other": 0},
|
||||
summary["skip_reason_counts"],
|
||||
)
|
||||
self.assertEqual("alias-a", summary["logged_out"][0]["alias"])
|
||||
self.assertIn("账号未登录", summary["logged_out"][0]["reason"])
|
||||
self.assertEqual("alias-a", summary["login_required_accounts"][0]["alias"])
|
||||
@@ -7834,6 +7878,10 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
self.assertEqual(3, summary["done"])
|
||||
self.assertEqual(1, summary["collected"])
|
||||
self.assertEqual(2, summary["skipped"])
|
||||
self.assertEqual(
|
||||
{"alias_unmatched": 0, "login_required": 2, "other": 0},
|
||||
summary["skip_reason_counts"],
|
||||
)
|
||||
self.assertEqual("alias-a", summary["login_required_accounts"][0]["alias"])
|
||||
self.assertIn("采集中途掉登录", summary["login_required_accounts"][0]["reason"])
|
||||
self.assertEqual(["alias-a", "alias-b", "alias-a", "alias-b"], login_calls)
|
||||
@@ -7903,6 +7951,10 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
self.assertTrue(summary["ok"])
|
||||
self.assertEqual(2, summary["collected"])
|
||||
self.assertEqual(0, summary["skipped"])
|
||||
self.assertEqual(
|
||||
{"alias_unmatched": 0, "login_required": 0, "other": 0},
|
||||
summary["skip_reason_counts"],
|
||||
)
|
||||
self.assertEqual([], summary["login_required_accounts"])
|
||||
self.assertEqual(["51100639510", "51100639511"], collected_items)
|
||||
self.assertEqual(3, detect_login.call_count)
|
||||
@@ -7958,6 +8010,10 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
self.assertTrue(summary["ok"])
|
||||
self.assertEqual(1, summary["collected"])
|
||||
self.assertEqual(0, summary["skipped"])
|
||||
self.assertEqual(
|
||||
{"alias_unmatched": 0, "login_required": 0, "other": 0},
|
||||
summary["skip_reason_counts"],
|
||||
)
|
||||
self.assertEqual([], summary["login_required_accounts"])
|
||||
self.assertEqual(3, detect_login.call_count)
|
||||
self.assertEqual(2, sleep.call_count)
|
||||
@@ -8128,6 +8184,11 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
"collected": 0,
|
||||
"skipped": 2,
|
||||
"failed": 0,
|
||||
"skip_reason_counts": {
|
||||
"alias_unmatched": 1,
|
||||
"login_required": 1,
|
||||
"other": 0,
|
||||
},
|
||||
"login_required_accounts": [
|
||||
{
|
||||
"account_name": "主店",
|
||||
@@ -8152,6 +8213,9 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
self.assertIn("主店(alias-a)", message)
|
||||
self.assertIn("已自动启动账号 Chrome", message)
|
||||
self.assertIn("不会自动关闭账号 Chrome", message)
|
||||
self.assertIn("略过2:别名未匹配1,账号未登录1", message)
|
||||
self.assertIn("检查 Excel 别名", message)
|
||||
self.assertIn("④账号管理", message)
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user