diff --git a/client/src/pdd_ui.py b/client/src/pdd_ui.py index 0542ed3..5d41b85 100644 --- a/client/src/pdd_ui.py +++ b/client/src/pdd_ui.py @@ -56,6 +56,18 @@ from qfluentwidgets import ( PAGE_SIZE = 50 CHECK_COLUMN = 0 +TASK_TYPE_COLUMN = 1 +GOODS_ID_COLUMN = 2 +TITLE_COLUMN = 3 +SHOP_COLUMN = 4 +COLOR_COLUMN = 5 +SIZE_COLUMN = 6 +PRICE_COLUMN = 7 +QUANTITY_COLUMN = 8 +STATUS_COLUMN = 9 +ORDER_NO_COLUMN = 10 +DURATION_COLUMN = 11 +UPDATED_AT_COLUMN = 12 REMOTE_TASK_ID_ROLE = Qt.UserRole + 1 @@ -76,6 +88,7 @@ class TaskRow: status: str = "待执行" latest_run_status: str = "" duration_seconds: Optional[int] = None + order_no: str = "" updated_at: str = "" @@ -88,6 +101,7 @@ class TaskTableModel(QAbstractTableModel): HEADERS = ( "选择", "任务类型", + "PDD商品ID", "商品标题", "店铺名", "颜色", @@ -95,6 +109,7 @@ class TaskTableModel(QAbstractTableModel): "价格", "数量", "状态", + "订单号", "用时", "更新时间", ) @@ -138,15 +153,24 @@ class TaskTableModel(QAbstractTableModel): return self._display_value(task, column) if role == Qt.TextAlignmentRole: - if column in (0, 1, 8, 9): + if column in ( + CHECK_COLUMN, + TASK_TYPE_COLUMN, + STATUS_COLUMN, + DURATION_COLUMN, + ): return Qt.AlignCenter - if column in (6, 7): + if column in (PRICE_COLUMN, QUANTITY_COLUMN): return Qt.AlignRight | Qt.AlignVCenter return Qt.AlignLeft | Qt.AlignVCenter if role == Qt.ToolTipRole: - if column == 2: + if column == GOODS_ID_COLUMN: + return task.goods_id or "尚无PDD商品ID" + if column == TITLE_COLUMN: return task.title or "尚未获取标题" + if column == ORDER_NO_COLUMN: + return task.order_no or "尚无订单号" if role == REMOTE_TASK_ID_ROLE: return task.remote_task_id @@ -367,32 +391,36 @@ class TaskTableModel(QAbstractTableModel): def _display_value(task: TaskRow, column: int) -> str: if column == CHECK_COLUMN: return "" - if column == 1: + if column == TASK_TYPE_COLUMN: return task.task_type - if column == 2: + if column == GOODS_ID_COLUMN: + return task.goods_id or "—" + if column == TITLE_COLUMN: return task.title or "尚未获取标题" - if column == 3: + if column == SHOP_COLUMN: return task.shop_name or "—" - if column == 4: + if column == COLOR_COLUMN: return task.color or "—" - if column == 5: + if column == SIZE_COLUMN: return task.size or "—" - if column == 6: + if column == PRICE_COLUMN: if task.price_cents is None: return "—" suffix = " 起" if task.price_is_starting else "" return f"¥{task.price_cents / 100:.2f}{suffix}" - if column == 7: + if column == QUANTITY_COLUMN: return "—" if task.quantity is None else str(task.quantity) - if column == 8: + if column == STATUS_COLUMN: return task.status - if column == 9: + if column == ORDER_NO_COLUMN: + return task.order_no or "—" + if column == DURATION_COLUMN: if task.latest_run_status == "running": return "进行中" if task.duration_seconds is None: return "—" return f"{max(0, task.duration_seconds)} 秒" - if column == 10: + if column == UPDATED_AT_COLUMN: return task.updated_at or "—" return "" @@ -575,18 +603,20 @@ class PDDTaskPage(QWidget): header = self.taskTable.horizontalHeader() header.setSectionResizeMode(QHeaderView.Interactive) - header.setSectionResizeMode(2, QHeaderView.Stretch) + header.setSectionResizeMode(TITLE_COLUMN, QHeaderView.Stretch) header.setMinimumSectionSize(64) - self.taskTable.setColumnWidth(0, 64) - self.taskTable.setColumnWidth(1, 82) - self.taskTable.setColumnWidth(3, 108) - self.taskTable.setColumnWidth(4, 108) - self.taskTable.setColumnWidth(5, 82) - self.taskTable.setColumnWidth(6, 96) - self.taskTable.setColumnWidth(7, 70) - self.taskTable.setColumnWidth(8, 116) - self.taskTable.setColumnWidth(9, 82) - self.taskTable.setColumnWidth(10, 156) + self.taskTable.setColumnWidth(CHECK_COLUMN, 64) + self.taskTable.setColumnWidth(TASK_TYPE_COLUMN, 82) + self.taskTable.setColumnWidth(GOODS_ID_COLUMN, 135) + self.taskTable.setColumnWidth(SHOP_COLUMN, 108) + self.taskTable.setColumnWidth(COLOR_COLUMN, 108) + self.taskTable.setColumnWidth(SIZE_COLUMN, 82) + self.taskTable.setColumnWidth(PRICE_COLUMN, 96) + self.taskTable.setColumnWidth(QUANTITY_COLUMN, 70) + self.taskTable.setColumnWidth(STATUS_COLUMN, 116) + self.taskTable.setColumnWidth(ORDER_NO_COLUMN, 190) + self.taskTable.setColumnWidth(DURATION_COLUMN, 82) + self.taskTable.setColumnWidth(UPDATED_AT_COLUMN, 156) self.taskTable.viewport().installEventFilter(self) self.emptyStateCard = CardWidget(self) diff --git a/client/src/pdd_ui_event.py b/client/src/pdd_ui_event.py index a4c2d00..2a0d953 100644 --- a/client/src/pdd_ui_event.py +++ b/client/src/pdd_ui_event.py @@ -1766,5 +1766,6 @@ def summary_to_row(summary: TaskSummary) -> TaskRow: summary.latest_run_status.value if summary.latest_run_status else "" ), duration_seconds=summary.duration_seconds, + order_no=summary.order_no or "", updated_at=summary.updated_at, ) diff --git a/client/src/task_detail_view.py b/client/src/task_detail_view.py index 0e7575e..fe0b1dc 100644 --- a/client/src/task_detail_view.py +++ b/client/src/task_detail_view.py @@ -5,7 +5,7 @@ """ from dataclasses import dataclass -from typing import Any, Dict, Iterable, List, Mapping +from typing import Any, Dict, Iterable, List, Mapping, Optional from PyQt5.QtCore import Qt, QTimer, pyqtSignal from PyQt5.QtGui import QCloseEvent, QKeySequence @@ -148,6 +148,21 @@ class ColorPriceView: price_inconsistent: bool = False +@dataclass(frozen=True) +class PurchaseResultView: + """采购任务的下单与核单结果。""" + + mode: str + confirmed_spec: str + quantity: str + unit_price: str + total_price: str + order_no: str + ordered_at: str + match_status: str + payment_status: str + + @dataclass(frozen=True) class TaskDetailViewData: """详情窗口直接使用的展示数据。""" @@ -169,6 +184,7 @@ class TaskDetailViewData: last_error: str colors: List[ColorPriceView] sizes: List[str] + purchase: Optional[PurchaseResultView] = None def _non_empty_text(value: Any) -> str: @@ -228,6 +244,72 @@ def _format_metric(value: Any) -> str: return "未采集" +def _format_cent(value: Any) -> str: + if isinstance(value, int) and not isinstance(value, bool) and value >= 0: + return f"¥{value / 100:.2f}" + return "—" + + +def _purchase_result_view( + task_type: TaskType, pdd_data: Mapping[str, Any] +) -> Optional[PurchaseResultView]: + """整理采购结果;非采购任务不显示采购信息卡片。""" + + if task_type is not TaskType.PURCHASE: + return None + purchase = pdd_data.get("purchase") + if not isinstance(purchase, Mapping): + purchase = {} + confirmed = purchase.get("confirmed") + if not isinstance(confirmed, Mapping): + confirmed = {} + options = confirmed.get("options") + if not isinstance(options, Mapping): + options = {} + confirmed_spec = " / ".join( + _non_empty_text(value) for value in options.values() + if _non_empty_text(value) + ) or "—" + mode_text = {"live": "真实采购", "dry_run": "采购演练"} + match_text = { + "matched": "已匹配", + "not_submitted": "未提交", + "not_found": "未找到", + "ambiguous": "多个候选", + "unknown": "结果不确定", + } + payment_text = { + "unpaid": "待付款", + "paid": "已支付", + "other": "其他状态", + "unknown": "未知", + } + quantity = confirmed.get("quantity") + return PurchaseResultView( + mode=mode_text.get(_non_empty_text(purchase.get("mode")), "—"), + confirmed_spec=confirmed_spec, + quantity=( + str(quantity) + if isinstance(quantity, int) and not isinstance(quantity, bool) + else "—" + ), + unit_price=_format_cent(confirmed.get("unit_price_cent")), + total_price=_format_cent(confirmed.get("total_price_cent")), + order_no=_non_empty_text(purchase.get("order_no")) or "—", + ordered_at=( + _non_empty_text(purchase.get("ordered_at_raw")) + or _non_empty_text(purchase.get("ordered_at")) + or "—" + ), + match_status=match_text.get( + _non_empty_text(purchase.get("match_status")), "—" + ), + payment_status=payment_text.get( + _non_empty_text(purchase.get("payment_status")), "—" + ), + ) + + def build_task_detail_view_data(detail: TaskDetail) -> TaskDetailViewData: """从完整任务记录生成详情展示数据。 @@ -310,6 +392,7 @@ def build_task_detail_view_data(detail: TaskDetail) -> TaskDetailViewData: last_error=last_error, colors=colors, sizes=sizes, + purchase=_purchase_result_view(detail.task_type, pdd_data), ) @@ -401,6 +484,8 @@ class TaskDetailWindow(QWidget): ) ) content_layout.addWidget(self._build_basic_card(content)) + if self.data.purchase is not None: + content_layout.addWidget(self._build_purchase_card(content)) content_layout.addWidget( self._build_spec_card("颜色", self.data.colors, content) ) @@ -508,6 +593,37 @@ class TaskDetailWindow(QWidget): layout.setColumnStretch(1, 1) return card + def _build_purchase_card(self, parent: QWidget) -> CardWidget: + """显示采购任务已经保存在本地的下单和核单结果。""" + + purchase = self.data.purchase + assert purchase is not None + card = CardWidget(parent) + card.setObjectName("purchaseResultCard") + layout = QGridLayout(card) + layout.setContentsMargins(16, 14, 16, 14) + layout.setHorizontalSpacing(18) + layout.setVerticalSpacing(8) + values = ( + ("采购模式", purchase.mode), + ("确认规格", purchase.confirmed_spec), + ("确认数量", purchase.quantity), + ("商品单价", purchase.unit_price), + ("订单总价", purchase.total_price), + ("订单号", purchase.order_no), + ("下单时间", purchase.ordered_at), + ("订单匹配", purchase.match_status), + ("付款状态", purchase.payment_status), + ) + layout.addWidget(SubtitleLabel("采购结果", card), 0, 0, 1, 2) + for row, (label, value) in enumerate(values, start=1): + layout.addWidget(CaptionLabel(label, card), row, 0) + text = self._copyable_label(value, label, card) + text.setWordWrap(True) + layout.addWidget(text, row, 1) + layout.setColumnStretch(1, 1) + return card + def _copyable_label( self, value: str, field_name: str, parent: QWidget ) -> CopyableBodyLabel: diff --git a/client/src/task_models.py b/client/src/task_models.py index 9bd447f..0ad6482 100644 --- a/client/src/task_models.py +++ b/client/src/task_models.py @@ -141,6 +141,7 @@ class TaskSummary: shop_name: Optional[str] = None latest_run_status: Optional[RunStatus] = None duration_seconds: Optional[int] = None + order_no: Optional[str] = None @dataclass(frozen=True) diff --git a/client/src/task_repository.py b/client/src/task_repository.py index 895c38d..e064e09 100644 --- a/client/src/task_repository.py +++ b/client/src/task_repository.py @@ -136,7 +136,12 @@ class TaskRepository: " WHEN latest_run.started_at IS NOT NULL AND latest_run.finished_at IS NOT NULL" " THEN MAX(0, CAST(strftime('%s', latest_run.finished_at)" " - strftime('%s', latest_run.started_at) AS INTEGER))" - " ELSE NULL END AS duration_seconds" + " ELSE NULL END AS duration_seconds," + " CASE WHEN pdd_tasks.pdd_data IS NOT NULL" + " AND json_valid(pdd_tasks.pdd_data)" + " THEN CAST(json_extract(pdd_tasks.pdd_data," + " '$.purchase.order_no') AS TEXT)" + " ELSE NULL END AS order_no" " FROM pdd_tasks" " LEFT JOIN task_runs AS latest_run ON latest_run.id = (" " SELECT id FROM task_runs WHERE task_id = pdd_tasks.id" @@ -1830,9 +1835,13 @@ class TaskRepository: clauses.append( "(remote_task_id LIKE ? ESCAPE '\\'" " OR COALESCE(goods_id, '') LIKE ? ESCAPE '\\'" - " OR COALESCE(title, '') LIKE ? ESCAPE '\\')" + " OR COALESCE(title, '') LIKE ? ESCAPE '\\'" + " OR COALESCE(CASE WHEN pdd_data IS NOT NULL" + " AND json_valid(pdd_data)" + " THEN json_extract(pdd_data, '$.purchase.order_no')" + " END, '') LIKE ? ESCAPE '\\')" ) - parameters.extend((pattern, pattern, pattern)) + parameters.extend((pattern, pattern, pattern, pattern)) return (" WHERE " + " AND ".join(clauses) if clauses else "", parameters) @staticmethod @@ -1859,6 +1868,7 @@ class TaskRepository: else None ), duration_seconds=row["duration_seconds"], + order_no=row["order_no"], updated_at=row["updated_at"], ) diff --git a/client/test/test_pdd_ui.py b/client/test/test_pdd_ui.py index 65c7db5..79f34fd 100644 --- a/client/test/test_pdd_ui.py +++ b/client/test/test_pdd_ui.py @@ -8,7 +8,15 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") from PyQt5.QtCore import Qt from PyQt5.QtWidgets import QApplication -from src.pdd_ui import CHECK_COLUMN, PDDTaskPage, TaskRow, TaskTableModel +from src.pdd_ui import ( + CHECK_COLUMN, + GOODS_ID_COLUMN, + ORDER_NO_COLUMN, + TITLE_COLUMN, + PDDTaskPage, + TaskRow, + TaskTableModel, +) class TaskTableModelCheckTests(unittest.TestCase): @@ -166,7 +174,9 @@ class TaskTableModelCheckTests(unittest.TestCase): page.detailRequested.connect(received.append) self.assertNotIn("操作", page.taskModel.HEADERS) - self.assertEqual(page.taskModel.columnCount(), 11) + self.assertEqual(page.taskModel.columnCount(), 13) + self.assertEqual(page.taskModel.HEADERS[GOODS_ID_COLUMN], "PDD商品ID") + self.assertEqual(page.taskModel.HEADERS[ORDER_NO_COLUMN], "订单号") page._on_table_activated(page.taskModel.index(0, CHECK_COLUMN)) page._on_table_activated(page.taskModel.index(0, 2)) @@ -174,6 +184,35 @@ class TaskTableModelCheckTests(unittest.TestCase): self.assertIn("Enter", page.taskTable.accessibleDescription()) page.deleteLater() + def test_goods_id_order_no_and_full_value_tooltips_are_visible(self): + model = TaskTableModel() + model.set_tasks( + [ + TaskRow( + "PUR-1", + "采购", + goods_id="897186799891", + title="很长的商品标题", + order_no="ORDER-20260811", + ) + ] + ) + + self.assertEqual( + model.data(model.index(0, GOODS_ID_COLUMN)), "897186799891" + ) + self.assertEqual( + model.data(model.index(0, ORDER_NO_COLUMN)), "ORDER-20260811" + ) + self.assertEqual( + model.data(model.index(0, TITLE_COLUMN), Qt.ToolTipRole), + "很长的商品标题", + ) + self.assertEqual( + model.data(model.index(0, ORDER_NO_COLUMN), Qt.ToolTipRole), + "ORDER-20260811", + ) + def test_remove_emits_stable_checked_task_ids(self): page = PDDTaskPage() page.set_tasks( diff --git a/client/test/test_pdd_ui_event.py b/client/test/test_pdd_ui_event.py index 057e490..61046fe 100644 --- a/client/test/test_pdd_ui_event.py +++ b/client/test/test_pdd_ui_event.py @@ -1086,6 +1086,7 @@ class PDDTaskPageEventTest(unittest.TestCase): quantity=2, status=TaskStatus.MANUAL_REVIEW, updated_at="2026-08-06T08:00:00Z", + order_no="ORDER-001", ) row = summary_to_row(summary) @@ -1093,6 +1094,7 @@ class PDDTaskPageEventTest(unittest.TestCase): self.assertEqual(row.task_type, "采购") self.assertEqual(row.status, "需要人工处理") self.assertEqual(row.goods_id, "") + self.assertEqual(row.order_no, "ORDER-001") self.assertFalse(hasattr(row, "pdd_data")) def test_summary_to_row_uses_task_specific_running_text(self): diff --git a/client/test/test_task_detail_view.py b/client/test/test_task_detail_view.py index 8f0a776..ac2d7ab 100644 --- a/client/test/test_task_detail_view.py +++ b/client/test/test_task_detail_view.py @@ -8,7 +8,7 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") from PyQt5.QtCore import Qt from PyQt5.QtTest import QTest -from PyQt5.QtWidgets import QApplication +from PyQt5.QtWidgets import QApplication, QWidget from src.task_detail_view import ( CopyableBodyLabel, @@ -145,6 +145,57 @@ class TaskDetailViewTest(unittest.TestCase): expected, ) + def test_purchase_result_shows_order_and_prefers_raw_order_time(self): + purchase_data = { + "purchase": { + "mode": "live", + "confirmed": { + "options": {"color": "黑色", "size": "L"}, + "quantity": 2, + "unit_price_cent": 3990, + "total_price_cent": 7980, + }, + "order_no": "ORDER-20260811", + "ordered_at": "2026-08-11T08:00:00+08:00", + "ordered_at_raw": "2026-08-11 08:00:00", + "match_status": "matched", + "payment_status": "unpaid", + } + } + detail = replace( + make_detail(purchase_data), + remote_task_id="PUR-001", + task_type=TaskType.PURCHASE, + ) + + data = build_task_detail_view_data(detail) + + self.assertEqual(data.purchase.mode, "真实采购") + self.assertEqual(data.purchase.confirmed_spec, "黑色 / L") + self.assertEqual(data.purchase.order_no, "ORDER-20260811") + self.assertEqual(data.purchase.ordered_at, "2026-08-11 08:00:00") + self.assertEqual(data.purchase.match_status, "已匹配") + self.assertEqual(data.purchase.payment_status, "待付款") + + window = TaskDetailWindow(detail) + self.assertIsNotNone(window.findChild(QWidget, "purchaseResultCard")) + self.assertTrue( + any( + label.text() == "ORDER-20260811" + for label in window.findChildren(CopyableBodyLabel) + ) + ) + window.close() + self.app.processEvents() + + def test_collect_detail_does_not_show_purchase_result_card(self): + window = TaskDetailWindow(make_detail(collected_data())) + + self.assertIsNone(window.findChild(QWidget, "purchaseResultCard")) + + window.close() + self.app.processEvents() + def test_window_builds_color_section_before_size_section(self): window = TaskDetailWindow(make_detail(collected_data())) diff --git a/client/test/test_task_repository.py b/client/test/test_task_repository.py index 22196db..88b7830 100644 --- a/client/test/test_task_repository.py +++ b/client/test/test_task_repository.py @@ -228,6 +228,44 @@ class TaskRepositoryTests(unittest.TestCase): self.assertEqual([task.remote_task_id for task in tasks], ["COLLECT-BLACK"]) self.assertEqual(self.repository.count_tasks(filters), 1) + def test_list_and_keyword_search_extract_purchase_order_no_safely(self) -> None: + self.repository.add_claimed_task( + self._task("PURCHASE-ORDER", TaskType.PURCHASE) + ) + self.repository.add_claimed_task(self._task("COLLECT-BROKEN")) + connection = open_database(self.db_path) + try: + with connection: + connection.execute( + "UPDATE pdd_tasks SET pdd_data = ? WHERE remote_task_id = ?", + ( + '{"purchase":{"order_no":"ORDER-20260811"}}', + "PURCHASE-ORDER", + ), + ) + connection.execute( + "UPDATE pdd_tasks SET pdd_data = ? WHERE remote_task_id = ?", + ("不是有效JSON", "COLLECT-BROKEN"), + ) + finally: + connection.close() + + tasks = self.repository.list_tasks() + by_id = {task.remote_task_id: task for task in tasks} + searched = self.repository.list_tasks( + TaskFilters(keyword="20260811") + ) + + self.assertEqual(by_id["PURCHASE-ORDER"].order_no, "ORDER-20260811") + self.assertIsNone(by_id["COLLECT-BROKEN"].order_no) + self.assertEqual( + [task.remote_task_id for task in searched], ["PURCHASE-ORDER"] + ) + self.assertEqual( + self.repository.count_tasks(TaskFilters(keyword="ORDER-20260811")), + 1, + ) + def test_keyword_treats_percent_as_normal_text(self) -> None: self.repository.add_claimed_task(self._task("TASK-100%", title="百分号")) self.repository.add_claimed_task(self._task("TASK-OTHER", title="普通商品"))