From 0a3a6b99a1a0708917d12fb6fae2d38bc5ee1da8 Mon Sep 17 00:00:00 2001 From: chengma Date: Mon, 13 Jul 2026 14:31:58 +0800 Subject: [PATCH] feat(collect): show skipped reason summaries --- app/collect_skip.py | 70 ++++++++++++++++++++++++++++++++++++ app/gui/models.py | 22 +++++++++--- app/gui/tabs/collect.py | 25 +++++++++++-- app/gui/workers.py | 11 +++++- docs/tasks/T-620.md | 7 ++-- tests/test_collect_skip.py | 47 +++++++++++++++++++++++++ tests/test_gui.py | 72 +++++++++++++++++++++++++++++++++++--- 7 files changed, 239 insertions(+), 15 deletions(-) create mode 100644 app/collect_skip.py create mode 100644 tests/test_collect_skip.py diff --git a/app/collect_skip.py b/app/collect_skip.py new file mode 100644 index 0000000..4f3ed04 --- /dev/null +++ b/app/collect_skip.py @@ -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) diff --git a/app/gui/models.py b/app/gui/models.py index bfedf4c..857aa83 100644 --- a/app/gui/models.py +++ b/app/gui/models.py @@ -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": diff --git a/app/gui/tabs/collect.py b/app/gui/tabs/collect.py index 5ae1d95..e65b852 100644 --- a/app/gui/tabs/collect.py +++ b/app/gui/tabs/collect.py @@ -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) diff --git a/app/gui/workers.py b/app/gui/workers.py index 3d82463..a23c2f8 100644 --- a/app/gui/workers.py +++ b/app/gui/workers.py @@ -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) diff --git a/docs/tasks/T-620.md b/docs/tasks/T-620.md index ebbe86a..a305895 100644 --- a/docs/tasks/T-620.md +++ b/docs/tasks/T-620.md @@ -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`,结果全部通过。 diff --git a/tests/test_collect_skip.py b/tests/test_collect_skip.py new file mode 100644 index 0000000..f2fbc64 --- /dev/null +++ b/tests/test_collect_skip.py @@ -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() diff --git a/tests/test_gui.py b/tests/test_gui.py index acedb23..96bd69b 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -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)