diff --git a/client/src/db_schema.py b/client/src/db_schema.py index 22073ac..4e0dcbf 100644 --- a/client/src/db_schema.py +++ b/client/src/db_schema.py @@ -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, } diff --git a/client/src/pdd_ui.py b/client/src/pdd_ui.py index 21cf2ac..0542ed3 100644 --- a/client/src/pdd_ui.py +++ b/client/src/pdd_ui.py @@ -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) diff --git a/client/src/pdd_ui_event.py b/client/src/pdd_ui_event.py index e015bfb..9a13ad9 100644 --- a/client/src/pdd_ui_event.py +++ b/client/src/pdd_ui_event.py @@ -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, ) diff --git a/client/src/task_models.py b/client/src/task_models.py index 24e3797..9bd447f 100644 --- a/client/src/task_models.py +++ b/client/src/task_models.py @@ -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) diff --git a/client/src/task_repository.py b/client/src/task_repository.py index 720181a..895c38d 100644 --- a/client/src/task_repository.py +++ b/client/src/task_repository.py @@ -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"]) diff --git a/client/test/test_db.py b/client/test/test_db.py index 6007733..68c8c95 100644 --- a/client/test/test_db.py +++ b/client/test/test_db.py @@ -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) diff --git a/client/test/test_pdd_ui.py b/client/test/test_pdd_ui.py index d24a73c..65c7db5 100644 --- a/client/test/test_pdd_ui.py +++ b/client/test/test_pdd_ui.py @@ -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)) diff --git a/client/test/test_task_repository.py b/client/test/test_task_repository.py index a2f6be3..22196db 100644 --- a/client/test/test_task_repository.py +++ b/client/test/test_task_repository.py @@ -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") diff --git a/docs/client/03-data-model.md b/docs/client/03-data-model.md index 238e665..0b63698 100644 --- a/docs/client/03-data-model.md +++ b/docs/client/03-data-model.md @@ -130,6 +130,7 @@ CREATE TABLE pdd_tasks ( goods_id TEXT, goods_url TEXT NOT NULL, title TEXT, + shop_name TEXT, target_color TEXT, target_size TEXT, price_cent INTEGER CHECK (price_cent IS NULL OR price_cent >= 0), @@ -175,6 +176,7 @@ CREATE INDEX idx_pdd_tasks_visible_list - `remote_task_id`:跨 Admin、Client、日志、提交和归档使用的稳定任务编号。Mock 任务也必须分配稳定编号。 - `execution_mode`:Admin 领取时确定的不可变执行模式;历史记录默认为 `dry_run`,Client 不得把任务自行提升为 `live`。 - `price_cent`:列表摘要价格;采集任务通常为最低可用 SKU 价格,详细价格以 `pdd_data.skus` 为准。 +- `shop_name`:列表使用的店铺名摘要;保存结果时从 `pdd_data.shop_name` 同步,避免列表查询解析完整 JSON。 - `status`:**本机执行状态**,只由本机的执行流程和 Outbox 提交响应驱动。Admin 那边把任务标成什么,本地不知道也不需要知道(见 §3.1)。 - `current_step`:当前安全步骤,例如 `open_goods`、`collect_skus`、`select_options`、`placing_order`、`reconcile_order`。 - `admin_payload`:`claim` 时收到的原始 Admin 任务 JSON,用于审计和向前兼容。 @@ -212,15 +214,21 @@ CREATE INDEX idx_pdd_tasks_visible_list 列表默认查询: ```sql -SELECT id, remote_task_id, task_type, title, target_color, target_size, - price_cent, quantity, status, updated_at +SELECT pdd_tasks.id, remote_task_id, task_type, title, shop_name, + target_color, target_size, price_cent, quantity, status, + latest_run.run_status, latest_run.started_at, latest_run.finished_at, + pdd_tasks.updated_at FROM pdd_tasks +LEFT JOIN task_runs AS latest_run ON /* 每个任务 attempt_no 最大的一条执行记录 */ WHERE removed_at IS NULL AND /* 本地搜索与筛选条件 */ ORDER BY updated_at DESC, id DESC LIMIT :limit OFFSET :offset; ``` +列表必须在同一条 SQL 中读取最新执行记录,不能逐行查询。已结束执行的用时按 +`finished_at - started_at` 取整秒,异常负数按 0 秒显示;正在执行显示“进行中”。 + 批量软删除必须在一个事务内先校验全部勾选任务,再统一写入 `removed_at`。 只允许 `succeeded`、`failed`、`cancelled`,且所有 Outbox 都已发送、所有执行记录的 `irreversible_action_at` 都为空。任一任务不满足时整体失败,不能只隐藏其中一部分。 diff --git a/docs/client/05-ui-specification.md b/docs/client/05-ui-specification.md index c16d8b0..398e673 100644 --- a/docs/client/05-ui-specification.md +++ b/docs/client/05-ui-specification.md @@ -41,7 +41,7 @@ │ PDD 任务 │ │ [自动获取] [类型▼] [状态▼] [关键词...] [搜索] [刷新] [全选] [反选] [已选0条] [重新上报] [重新采集] [重新采购] [删除] │ ├─────────────────────────────────────────────────────────────┤ -│ 选择 │ 类型 │ 商品标题 │ 颜色 │ 尺码 │ 价格 │ 数量 │ 状态 │ 更新时间 │ +│ 选择 │ 类型 │ 商品标题 │ 店铺名 │ 颜色 │ 尺码 │ 价格 │ 数量 │ 状态 │ 用时 │ 更新时间 │ │ │ │ 任务数据表格 │ │ │ @@ -127,11 +127,13 @@ | 选择 | 居中 | 行首复选框;占位空行不可勾选,身份使用 `remote_task_id` | | 任务类型 | 居中 | “采集”或“采购”,颜色只作辅助 | | 商品标题 | 左对齐、可伸展 | 空值显示“尚未获取标题”,截断时提供完整工具提示 | +| 店铺名 | 左对齐 | 采集到的店铺名;无值显示 `—` | | 颜色 | 左对齐 | 无值显示 `—` | | 尺码 | 左对齐 | 无值显示 `—` | | 价格 | 右对齐 | 格式为 `¥39.90`;采集摘要可显示 `¥39.90 起` | | 数量 | 右对齐 | 采购数量;采集显示 `—` | | 状态 | 居中 | 中文状态文字和状态图标/标记 | +| 用时 | 居中 | 已结束显示整数秒,执行中显示“进行中”,尚未执行显示 `—` | | 更新时间 | 左对齐 | 本地时区,精确到秒或按产品确认格式 | ### 5.2 数据行为