feat: 任务列表显示店铺名和执行用时 (#136)

This commit is contained in:
chengma
2026-08-11 11:17:44 +08:00
parent 6d30115c7e
commit e354dddad6
10 changed files with 163 additions and 28 deletions
+13 -1
View File
@@ -4,7 +4,7 @@
``MIGRATIONS`` 末尾增加版本,不能修改已经发布的迁移。
"""
SCHEMA_VERSION = 4
SCHEMA_VERSION = 5
MIGRATION_1 = (
@@ -152,10 +152,22 @@ MIGRATION_4 = (
""",
)
MIGRATION_5 = (
"ALTER TABLE pdd_tasks ADD COLUMN shop_name TEXT",
"""
UPDATE pdd_tasks
SET shop_name = TRIM(json_extract(pdd_data, '$.shop_name'))
WHERE pdd_data IS NOT NULL AND json_valid(pdd_data)
AND json_type(pdd_data, '$.shop_name') = 'text'
AND TRIM(json_extract(pdd_data, '$.shop_name')) <> ''
""",
)
MIGRATIONS = {
1: MIGRATION_1,
2: MIGRATION_2,
3: MIGRATION_3,
4: MIGRATION_4,
5: MIGRATION_5,
}
+27 -12
View File
@@ -67,12 +67,15 @@ class TaskRow:
task_type: str
goods_id: str = ""
title: str = ""
shop_name: str = ""
color: str = ""
size: str = ""
price_cents: Optional[int] = None
price_is_starting: bool = False
quantity: Optional[int] = None
status: str = "待执行"
latest_run_status: str = ""
duration_seconds: Optional[int] = None
updated_at: str = ""
@@ -86,11 +89,13 @@ class TaskTableModel(QAbstractTableModel):
"选择",
"任务类型",
"商品标题",
"店铺名",
"颜色",
"尺码",
"价格",
"数量",
"状态",
"用时",
"更新时间",
)
@@ -133,9 +138,9 @@ class TaskTableModel(QAbstractTableModel):
return self._display_value(task, column)
if role == Qt.TextAlignmentRole:
if column in (0, 1, 7):
if column in (0, 1, 8, 9):
return Qt.AlignCenter
if column in (5, 6):
if column in (6, 7):
return Qt.AlignRight | Qt.AlignVCenter
return Qt.AlignLeft | Qt.AlignVCenter
@@ -367,19 +372,27 @@ class TaskTableModel(QAbstractTableModel):
if column == 2:
return task.title or "尚未获取标题"
if column == 3:
return task.color or "—"
return task.shop_name or "—"
if column == 4:
return task.size or "—"
return task.color or "—"
if column == 5:
return task.size or "—"
if column == 6:
if task.price_cents is None:
return "—"
suffix = " 起" if task.price_is_starting else ""
return f"¥{task.price_cents / 100:.2f}{suffix}"
if column == 6:
return "—" if task.quantity is None else str(task.quantity)
if column == 7:
return task.status
return "—" if task.quantity is None else str(task.quantity)
if column == 8:
return task.status
if column == 9:
if task.latest_run_status == "running":
return "进行中"
if task.duration_seconds is None:
return "—"
return f"{max(0, task.duration_seconds)} 秒"
if column == 10:
return task.updated_at or "—"
return ""
@@ -567,11 +580,13 @@ class PDDTaskPage(QWidget):
self.taskTable.setColumnWidth(0, 64)
self.taskTable.setColumnWidth(1, 82)
self.taskTable.setColumnWidth(3, 108)
self.taskTable.setColumnWidth(4, 82)
self.taskTable.setColumnWidth(5, 96)
self.taskTable.setColumnWidth(6, 70)
self.taskTable.setColumnWidth(7, 116)
self.taskTable.setColumnWidth(8, 156)
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.viewport().installEventFilter(self)
self.emptyStateCard = CardWidget(self)
+5
View File
@@ -1709,10 +1709,15 @@ def summary_to_row(summary: TaskSummary) -> TaskRow:
task_type=TASK_TYPE_TEXT[summary.task_type],
goods_id=summary.goods_id or "",
title=summary.title or "",
shop_name=summary.shop_name or "",
color=summary.target_color or "",
size=summary.target_size or "",
price_cents=summary.price_cent,
quantity=summary.quantity,
status=TASK_STATUS_TEXT[summary.status],
latest_run_status=(
summary.latest_run_status.value if summary.latest_run_status else ""
),
duration_seconds=summary.duration_seconds,
updated_at=summary.updated_at,
)
+3
View File
@@ -138,6 +138,9 @@ class TaskSummary:
quantity: Optional[int]
status: TaskStatus
updated_at: str
shop_name: Optional[str] = None
latest_run_status: Optional[RunStatus] = None
duration_seconds: Optional[int] = None
@dataclass(frozen=True)
+36 -6
View File
@@ -129,11 +129,20 @@ class TaskRepository:
connection = open_database(self._db_path)
try:
rows = connection.execute(
"SELECT id, remote_task_id, task_type, goods_id, title,"
" target_color, target_size, price_cent, quantity, status, updated_at"
"SELECT pdd_tasks.id, remote_task_id, task_type, goods_id, title,"
" shop_name, target_color, target_size, price_cent, quantity,"
" status, pdd_tasks.updated_at, latest_run.run_status AS latest_run_status,"
" CASE WHEN latest_run.run_status = 'running' THEN NULL"
" 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"
" 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"
" ORDER BY attempt_no DESC LIMIT 1)"
f"{where_sql}"
" ORDER BY updated_at DESC, id DESC"
" ORDER BY pdd_tasks.updated_at DESC, pdd_tasks.id DESC"
" LIMIT ? OFFSET ?",
parameters,
).fetchall()
@@ -1098,13 +1107,14 @@ class TaskRepository:
task_cursor = connection.execute(
"UPDATE pdd_tasks SET status = 'result_pending',"
" current_step = 'purchase_order_matched_pending_report',"
" pdd_data = ?,"
" pdd_data = ?, shop_name = ?,"
" price_cent = ?, last_error_code = NULL,"
" last_error_message = NULL, finished_at = ?, updated_at = ?"
" WHERE id = ? AND status = 'manual_review'"
" AND current_step = 'reconcile_purchase'",
(
result_json,
self._summary_shop_name(pdd_data),
self._purchase_result_price(pdd_data),
now,
now,
@@ -1188,11 +1198,12 @@ class TaskRepository:
)
connection.execute(
"UPDATE pdd_tasks SET status = 'result_pending',"
" current_step = 'submit_result', pdd_data = ?,"
" current_step = 'submit_result', pdd_data = ?, shop_name = ?,"
" price_cent = ?, finished_at = ?, updated_at = ?"
" WHERE id = ?",
(
result_json,
self._summary_shop_name(pdd_data),
self._purchase_result_price(pdd_data),
now,
now,
@@ -1373,11 +1384,13 @@ class TaskRepository:
connection.execute(
"UPDATE pdd_tasks SET status = 'result_pending',"
" current_step = 'submit_result', pdd_data = ?, goods_id = ?,"
" title = ?, price_cent = ?, finished_at = ?, updated_at = ?"
" title = ?, shop_name = ?, price_cent = ?,"
" finished_at = ?, updated_at = ?"
" WHERE id = ?",
(
result_json,
pdd_data.get("goods_id"), pdd_data.get("title"),
self._summary_shop_name(pdd_data),
self._summary_price(pdd_data), now, now, task["id"],
),
)
@@ -1834,14 +1847,31 @@ class TaskRepository:
task_type=TaskType(row["task_type"]),
goods_id=row["goods_id"],
title=row["title"],
shop_name=row["shop_name"],
target_color=row["target_color"],
target_size=row["target_size"],
price_cent=row["price_cent"],
quantity=row["quantity"],
status=TaskStatus(row["status"]),
latest_run_status=(
RunStatus(row["latest_run_status"])
if row["latest_run_status"] is not None
else None
),
duration_seconds=row["duration_seconds"],
updated_at=row["updated_at"],
)
@staticmethod
def _summary_shop_name(pdd_data: Dict[str, object]) -> Optional[str]:
"""提取列表需要的店铺名,空值不写入摘要列。"""
value = pdd_data.get("shop_name")
if not isinstance(value, str):
return None
value = value.strip()
return value or None
@staticmethod
def _to_detail(row: sqlite3.Row) -> TaskDetail:
admin_payload = TaskRepository._load_json_object(row["admin_payload"])
+38 -5
View File
@@ -6,7 +6,7 @@ import unittest
from pathlib import Path
from src.db import DatabaseVersionError, initialize_database, open_database
from src.db_schema import MIGRATION_1, MIGRATION_2, MIGRATION_3
from src.db_schema import MIGRATION_1, MIGRATION_2, MIGRATION_3, MIGRATION_4
EXPECTED_TABLES = {
@@ -64,7 +64,7 @@ class DatabaseInitializationTests(unittest.TestCase):
self.assertTrue(EXPECTED_TABLES.issubset(tables))
self.assertTrue(EXPECTED_INDEXES.issubset(indexes))
self.assertEqual(version, 4)
self.assertEqual(version, 5)
def test_v1_database_is_upgraded_without_losing_task_runs(self) -> None:
connection = open_database(self.db_path)
@@ -107,7 +107,7 @@ class DatabaseInitializationTests(unittest.TestCase):
connection.close()
self.assertIn("result_data", columns)
self.assertEqual(attempt_id, "ATTEMPT-OLD")
self.assertEqual(version, 4)
self.assertEqual(version, 5)
def test_initialize_can_run_twice_without_losing_data(self) -> None:
initialize_database(self.db_path)
@@ -170,7 +170,7 @@ class DatabaseInitializationTests(unittest.TestCase):
finally:
connection.close()
self.assertEqual(row[0], "dry_run")
self.assertEqual(version, 4)
self.assertEqual(version, 5)
def test_v3_database_adds_soft_remove_column_without_losing_tasks(self) -> None:
connection = open_database(self.db_path)
@@ -212,7 +212,40 @@ class DatabaseInitializationTests(unittest.TestCase):
connection.close()
self.assertIn("removed_at", columns)
self.assertEqual(tuple(row), ("COL-V3", None))
self.assertEqual(version, 4)
self.assertEqual(version, 5)
def test_v4_database_backfills_shop_name_from_valid_pdd_data(self) -> None:
connection = open_database(self.db_path)
try:
with connection:
for statement in MIGRATION_1 + MIGRATION_2 + MIGRATION_3 + MIGRATION_4:
connection.execute(statement)
connection.execute("PRAGMA user_version = 4")
connection.execute(
"INSERT INTO pdd_tasks"
" (remote_task_id, task_type, goods_url, status, pdd_data,"
" received_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
"COL-V4", "collect", "https://example.test/goods",
"succeeded", '{"shop_name":" 测试店铺 "}',
"2026-08-10T00:00:00Z", "2026-08-10T00:00:00Z",
"2026-08-10T00:00:00Z",
),
)
finally:
connection.close()
initialize_database(self.db_path)
connection = open_database(self.db_path)
try:
row = connection.execute(
"SELECT shop_name FROM pdd_tasks WHERE remote_task_id = 'COL-V4'"
).fetchone()
version = connection.execute("PRAGMA user_version").fetchone()[0]
finally:
connection.close()
self.assertEqual(row[0], "测试店铺")
self.assertEqual(version, 5)
def test_new_connection_uses_required_pragmas(self) -> None:
initialize_database(self.db_path)
+1 -1
View File
@@ -166,7 +166,7 @@ class TaskTableModelCheckTests(unittest.TestCase):
page.detailRequested.connect(received.append)
self.assertNotIn("操作", page.taskModel.HEADERS)
self.assertEqual(page.taskModel.columnCount(), 9)
self.assertEqual(page.taskModel.columnCount(), 11)
page._on_table_activated(page.taskModel.index(0, CHECK_COLUMN))
page._on_table_activated(page.taskModel.index(0, 2))
+27
View File
@@ -258,6 +258,7 @@ class TaskRepositoryTests(unittest.TestCase):
{
"goods_id": "10001",
"title": "测试商品",
"shop_name": "测试店铺",
"price_granularity": "color",
"dimensions": [],
"skus": [{"price_cent": 990}],
@@ -269,12 +270,38 @@ class TaskRepositoryTests(unittest.TestCase):
TaskStatus.RESULT_PENDING,
)
self.assertEqual(event.status, OutboxStatus.PENDING)
summary = self.repository.list_tasks()[0]
self.assertEqual(summary.shop_name, "测试店铺")
self.assertEqual(summary.latest_run_status, RunStatus.SUCCEEDED)
self.repository.mark_outbox_sent(event.id)
self.assertEqual(
self.repository.get_task("TASK-RUN").status,
TaskStatus.SUCCEEDED,
)
def test_list_uses_latest_run_duration_and_clamps_negative_value(self):
self.repository.add_claimed_task(self._task("TASK-DURATION"))
started = self.repository.start_collect_run("TASK-DURATION", "USB-001")
connection = open_database(self.db_path)
try:
with connection:
connection.execute(
"UPDATE task_runs SET run_status = 'failed',"
" started_at = ?, finished_at = ? WHERE attempt_id = ?",
(
"2026-08-11T08:00:10Z",
"2026-08-11T08:00:05Z",
started.attempt_id,
),
)
finally:
connection.close()
summary = self.repository.list_tasks()[0]
self.assertEqual(summary.latest_run_status, RunStatus.FAILED)
self.assertEqual(summary.duration_seconds, 0)
def test_latest_result_outbox_excludes_newer_failure_event(self):
self.repository.add_claimed_task(self._task("TASK-REPORT"))
first = self.repository.start_collect_run("TASK-REPORT", "USB-001")