feat: 展示PDD商品和采购订单信息 (#164)

This commit is contained in:
chengma
2026-08-11 16:46:49 +08:00
parent 24497c55fd
commit 1f48d3a857
9 changed files with 319 additions and 31 deletions
+54 -24
View File
@@ -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)
+1
View File
@@ -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,
)
+117 -1
View File
@@ -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:
+1
View File
@@ -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)
+13 -3
View File
@@ -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"],
)
+41 -2
View File
@@ -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(
+2
View File
@@ -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):
+52 -1
View File
@@ -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()))
+38
View File
@@ -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="普通商品"))