feat(collect): support soft deletion of one task
This commit is contained in:
@@ -161,6 +161,8 @@ class Task:
|
||||
generated_at: Optional[str]
|
||||
applied_at: Optional[str]
|
||||
updated_at: str
|
||||
deleted_at: Optional[str]
|
||||
deleted_reason: Optional[str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -261,6 +263,8 @@ CREATE TABLE IF NOT EXISTS tasks (
|
||||
generated_at TEXT,
|
||||
applied_at TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
deleted_at TEXT,
|
||||
deleted_reason TEXT,
|
||||
UNIQUE(batch_id, source_file_abs, source_sheet, source_row)
|
||||
);
|
||||
|
||||
@@ -495,6 +499,7 @@ def init_db(path=None, conn=None) -> None:
|
||||
_ensure_task_image_task_columns(database)
|
||||
_ensure_task_cover_reset_columns(database)
|
||||
_ensure_task_product_status_columns(database)
|
||||
_ensure_task_delete_columns(database)
|
||||
_migrate_legacy_product_status_defaults(database)
|
||||
_ensure_image_studio_project_suite_columns(database)
|
||||
_ensure_image_studio_project_draft_columns(database)
|
||||
@@ -537,6 +542,15 @@ def _ensure_task_product_status_columns(database):
|
||||
database.execute("ALTER TABLE tasks ADD COLUMN product_status_at TEXT")
|
||||
|
||||
|
||||
def _ensure_task_delete_columns(database):
|
||||
columns = {row["name"] for row in database.execute("PRAGMA table_info(tasks)").fetchall()}
|
||||
if "deleted_at" not in columns:
|
||||
database.execute("ALTER TABLE tasks ADD COLUMN deleted_at TEXT")
|
||||
if "deleted_reason" not in columns:
|
||||
database.execute("ALTER TABLE tasks ADD COLUMN deleted_reason TEXT")
|
||||
database.execute("CREATE INDEX IF NOT EXISTS idx_tasks_deleted_at ON tasks(deleted_at)")
|
||||
|
||||
|
||||
def _migrate_legacy_product_status_defaults(database):
|
||||
"""Treat pre-status-feature active batches as historically on-shelf once."""
|
||||
|
||||
@@ -548,6 +562,7 @@ def _migrate_legacy_product_status_defaults(database):
|
||||
product_status_at = NULL,
|
||||
updated_at = ?
|
||||
WHERE (product_status IS NULL OR TRIM(product_status) = '')
|
||||
AND deleted_at IS NULL
|
||||
AND batch_id IN (
|
||||
SELECT id
|
||||
FROM batches
|
||||
@@ -880,6 +895,7 @@ def list_tasks(
|
||||
params.append(value)
|
||||
if not include_deleted:
|
||||
clauses.append("b.deleted_at IS NULL")
|
||||
clauses.append("t.deleted_at IS NULL")
|
||||
sql = "SELECT t.* FROM tasks t JOIN batches b ON b.id = t.batch_id"
|
||||
if clauses:
|
||||
sql += " WHERE " + " AND ".join(clauses)
|
||||
@@ -920,11 +936,44 @@ def delete_batch(batch_id, reason=None, path=None, conn=None) -> dict:
|
||||
"image_paths": image_paths,
|
||||
}
|
||||
|
||||
|
||||
def delete_task(task_id, reason=None, path=None, conn=None) -> dict:
|
||||
"""Soft delete one inactive task without touching its source files or images."""
|
||||
|
||||
task_id = int(task_id)
|
||||
with _connection(conn, path) as database:
|
||||
task = get_task(task_id, conn=database)
|
||||
if task is None:
|
||||
raise DbError(f"任务不存在或已删除: {task_id}")
|
||||
if task.status == "running":
|
||||
raise DbError("任务正在处理,不能删除")
|
||||
now = _now()
|
||||
with database:
|
||||
cursor = database.execute(
|
||||
"""
|
||||
UPDATE tasks
|
||||
SET deleted_at = ?, deleted_reason = ?, updated_at = ?
|
||||
WHERE id = ? AND deleted_at IS NULL AND status <> 'running'
|
||||
""",
|
||||
(now, str(reason or ""), now, task_id),
|
||||
)
|
||||
if cursor.rowcount != 1:
|
||||
raise DbError("任务已删除或正在处理,不能删除")
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"batch_id": task.batch_id,
|
||||
"item_id": task.item_id,
|
||||
"committed": int(task.committed or 0),
|
||||
"deleted_at": now,
|
||||
}
|
||||
|
||||
|
||||
def get_task(task_id, path=None, conn=None, include_deleted=False):
|
||||
clauses = ["t.id = ?"]
|
||||
params = [int(task_id)]
|
||||
if not include_deleted:
|
||||
clauses.append("b.deleted_at IS NULL")
|
||||
clauses.append("t.deleted_at IS NULL")
|
||||
sql = "SELECT t.* FROM tasks t JOIN batches b ON b.id = t.batch_id"
|
||||
sql += " WHERE " + " AND ".join(clauses)
|
||||
with _connection(conn, path) as database:
|
||||
|
||||
@@ -178,6 +178,7 @@ class CollectTab(QWidget):
|
||||
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||||
self.table.setSelectionMode(QAbstractItemView.SingleSelection)
|
||||
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
||||
self.table.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
|
||||
for column in (3, 4):
|
||||
self.table.horizontalHeader().setSectionResizeMode(column, QHeaderView.ResizeToContents)
|
||||
@@ -219,6 +220,7 @@ class CollectTab(QWidget):
|
||||
self.product_status_filter.currentIndexChanged.connect(self.refresh_tasks)
|
||||
self.status_filter.currentIndexChanged.connect(self.refresh_tasks)
|
||||
self.delete_batch_button.clicked.connect(self.delete_current_batch)
|
||||
self.table.customContextMenuRequested.connect(self._show_task_context_menu)
|
||||
self.collect_button.clicked.connect(self.collect_old_data)
|
||||
self.stop_collect_button.clicked.connect(self.stop_collect)
|
||||
self.write_back_button.clicked.connect(self.write_back_old_data)
|
||||
@@ -682,6 +684,85 @@ class CollectTab(QWidget):
|
||||
running = bool(self.collect_thread or self.write_back_thread)
|
||||
self.delete_batch_button.setEnabled((not running) and bool(self._selected_batch_id()))
|
||||
|
||||
def _show_task_context_menu(self, position):
|
||||
index = self.table.indexAt(position)
|
||||
if not index.isValid():
|
||||
return
|
||||
self.table.selectRow(index.row())
|
||||
self.table.setCurrentIndex(index)
|
||||
task = self.model.task_at(index.row())
|
||||
if task is None:
|
||||
return
|
||||
menu = QMenu(self.table)
|
||||
delete_action = menu.addAction("删除本条记录")
|
||||
delete_action.setEnabled(
|
||||
not bool(self.collect_thread or self.write_back_thread)
|
||||
and getattr(task, "status", "") != "running"
|
||||
)
|
||||
delete_action.triggered.connect(self.delete_selected_task)
|
||||
menu.exec(self.table.viewport().mapToGlobal(position))
|
||||
|
||||
def delete_selected_task(self, checked=False):
|
||||
if self.collect_thread or self.write_back_thread:
|
||||
self._set_status("采集或回写正在进行,不能删除记录", level="warning")
|
||||
return
|
||||
index = self.table.currentIndex()
|
||||
task = self.model.task_at(index.row()) if index.isValid() else None
|
||||
if task is None:
|
||||
self._set_status("请选择要删除的记录", level="warning")
|
||||
return
|
||||
if getattr(task, "status", "") == "running":
|
||||
self._set_status("任务正在处理,不能删除", level="warning")
|
||||
return
|
||||
batch = db.get_batch(task.batch_id, path=self.db_path)
|
||||
batch_text = self._batch_label(batch) if batch is not None else task.batch_id
|
||||
lines = [
|
||||
"确定要删除本条本地记录吗?",
|
||||
f"商品ID:{task.item_id}",
|
||||
f"店铺:{task.account_name or task.alias}({task.alias})",
|
||||
f"所属批次:{batch_text}",
|
||||
]
|
||||
if int(getattr(task, "committed", 0) or 0) == 1:
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"该商品已提交过蝦皮。本次仅删除本地记录,不会回滚蝦皮线上商品。",
|
||||
]
|
||||
)
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"删除后,该记录不会出现在①、②、③的列表、筛选、采集、生成、更新或回写范围。",
|
||||
"不会删除原 Excel、本地图片或账号 Chrome 数据。",
|
||||
]
|
||||
)
|
||||
answer = QMessageBox.question(
|
||||
self,
|
||||
"删除本条记录",
|
||||
"\n".join(lines),
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.No,
|
||||
)
|
||||
if answer != QMessageBox.Yes:
|
||||
self._set_status("已取消删除本条记录")
|
||||
return
|
||||
try:
|
||||
result = db.delete_task(
|
||||
task.id,
|
||||
reason="用户在导入采集页删除单条记录",
|
||||
path=self.db_path,
|
||||
)
|
||||
except Exception as exc:
|
||||
QMessageBox.warning(self, "删除本条记录", str(exc))
|
||||
self._set_status(f"删除本条记录失败:{exc}", level="danger")
|
||||
return
|
||||
self.refresh_tasks()
|
||||
if self.refresh_workflow_callback is not None:
|
||||
self.refresh_workflow_callback()
|
||||
message = f"已删除本地记录:商品 {result['item_id']}"
|
||||
self._set_status(message, level="success")
|
||||
QMessageBox.information(self, "删除本条记录", message)
|
||||
|
||||
def delete_current_batch(self, checked=False):
|
||||
batch_id = self._selected_batch_id()
|
||||
if not batch_id:
|
||||
|
||||
@@ -292,12 +292,15 @@ CREATE TABLE tasks (
|
||||
generated_at TEXT,
|
||||
applied_at TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
deleted_at TEXT, -- T-670 单条任务软删除时间;默认业务查询排除
|
||||
deleted_reason TEXT, -- 软删除原因/来源
|
||||
UNIQUE(batch_id, source_file_abs, source_sheet, source_row)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_tasks_batch_stage_status ON tasks(batch_id, stage, status);
|
||||
CREATE INDEX idx_tasks_alias ON tasks(alias);
|
||||
CREATE INDEX idx_tasks_item ON tasks(item_id);
|
||||
CREATE INDEX idx_tasks_deleted_at ON tasks(deleted_at);
|
||||
```
|
||||
|
||||
运行日志(T-504):
|
||||
@@ -595,6 +598,6 @@ cmshopee/
|
||||
- 别名是账号↔任务唯一关联键。
|
||||
- 密码与 AI Key 本地明文保存,保存/变更时弹窗提示;UI 打码、不外传、不写日志/导出;不自动登录。
|
||||
- AI 生成内容直接进入 ③ 更新候选;③ 批量确认后提交,新图本地留档 + 回写 Excel 以备追溯。
|
||||
- 批次删除是软删除:写 `batches.deleted_at/deleted_reason`,不物理删除 `batches/tasks`;默认 `list_batches/list_tasks/get_task` 与 ①/②/③ 页面和执行入口都排除已删除批次。
|
||||
- 批次删除是软删除:写 `batches.deleted_at/deleted_reason`,不物理删除 `batches/tasks`;默认 `list_batches/list_tasks/get_task` 与 ①/②/③ 页面和执行入口都排除已删除批次。T-670 后①还可对单条非运行任务写 `tasks.deleted_at/deleted_reason`;默认 `list_tasks/get_task` 和所有工作流候选同样排除该任务,不删除 Excel、图片或线上商品。
|
||||
- 高风险模块先单独验证,再接入流水线。
|
||||
- GUI 只通过 signal/slot 接收 worker 进度;禁止后台线程直接操作 Qt widget 或共享 SQLite connection。
|
||||
|
||||
+3
-1
@@ -102,10 +102,12 @@ delete_account(alias) -> None
|
||||
insert_tasks(batch_id, rows, path=None) -> int # 写输入列;rows 含 source_file_abs/source_sheet/source_row/row_key
|
||||
list_tasks(batch_id=None, stage=None, status=None, alias=None, path=None, include_deleted=False) -> list[Task]
|
||||
get_task(task_id, path=None, include_deleted=False) -> Task | None
|
||||
# 默认排除已软删除批次;include_deleted 仅供内部诊断/测试使用
|
||||
# 默认排除已软删除批次和任务;include_deleted 仅供内部诊断/测试使用
|
||||
|
||||
delete_batch(batch_id, reason=None, path=None) -> dict
|
||||
# T-206 软删除:写 batches.deleted_at/deleted_reason,不物理删除 batches/tasks;默认业务列表和执行流程不可见/不可调用;返回任务数、committed 数和关联图片路径
|
||||
delete_task(task_id, reason=None, path=None) -> dict
|
||||
# T-670 软删除单条非 running 任务:写 tasks.deleted_at/deleted_reason;不删除 Excel、图片或线上商品,默认业务查询和执行入口不可见/不可调用
|
||||
mark_running(task_id, phase) -> None
|
||||
mark_failed(task_id, phase, error) -> None # status=failed,stage 不前进,对应 attempts+1
|
||||
mark_skipped(task_id, reason) -> None # status=skipped,stage 不前进
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
- 别名未匹配账号 → 该行 skipped 并记原因,不为未匹配别名启动 Chrome。账号预检或采集中途明确进入登录页 → 该账号剩余任务整组 skipped 并记原因,结束汇总列出需补登录的账号;`NO_SESSION_COOKIE` 等不确定检测结果只写日志和诊断信息,不触发整组 skipped。`T-207` 接入后,① 采集会像③更新一样写 `run_logs/run_log_events`,并把完整脱敏 traceback 写入本地 `data/logs/`,用于定位失败卡在哪个步骤。
|
||||
|
||||
- 「删除批次」位于①批次筛选旁,只能对当前选中的具体批次执行,不能在“全部批次”下执行;运行中禁用。删除是软删除:写本地批次删除标记,不物理删除 DB 记录,不删除原 Excel,不回滚蝦皮。删除后该批次不再出现在①/②/③任何批次下拉、任务列表、筛选、采集、生成、更新、回写入口中。确认框必须显示任务数、已上线任务数,并提示软删除只隐藏本地批次、不会回滚线上修改。
|
||||
- ①任务表右键先选中命中行并打开菜单,第一版仅提供「删除本条记录」;右键本身不弹确认框。点击后才确认商品 ID、店铺和批次;已提交线上时明确提示仅删除本地记录、不回滚蝦皮。单条删除写 `tasks.deleted_at/deleted_reason`,运行中禁用且数据库拒绝 `running` 任务;删除后该记录不再进入①/②/③列表、筛选、采集、生成、更新、回写和默认统计,不删除 Excel、本地图片或账号 Chrome 数据。
|
||||
## ② AI生成
|
||||
|
||||
左右布局:左侧约 1/4 放提示词,右侧放筛选 + 任务列表。
|
||||
|
||||
+10
-7
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: T-670
|
||||
title: 导入采集单条任务软删除
|
||||
status: TODO
|
||||
status: DONE
|
||||
phase: 7
|
||||
deps: []
|
||||
created: 2026-07-20
|
||||
@@ -23,11 +23,11 @@ created: 2026-07-20
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 右键任务行只弹菜单,点击「删除本条记录」后才弹确认框;取消不改变任何数据。
|
||||
- [ ] 确认后任务被软删除,立即不再出现在①、②、③列表、筛选、采集、生成、更新、回写和默认统计中;其他同批次任务保持不变。
|
||||
- [ ] 已提交线上任务的确认框说明本地删除不回滚线上;原 Excel、本地图片与任务历史字段均未被物理删除。
|
||||
- [ ] 已删除或正在处理任务不能被重复删除,相关 API 事务安全且不产生半删除状态。
|
||||
- [ ] `tests/test_db.py` 覆盖 schema 迁移、默认排除、单条读取和重复/运行中保护;`tests/test_gui.py` 覆盖右键选中、确认/取消、已提交提示和删除后刷新。
|
||||
- [x] 右键任务行只弹菜单,点击「删除本条记录」后才弹确认框;取消不改变任何数据。
|
||||
- [x] 确认后任务被软删除,立即不再出现在①、②、③列表、筛选、采集、生成、更新、回写和默认统计中;其他同批次任务保持不变。
|
||||
- [x] 已提交线上任务的确认框说明本地删除不回滚线上;原 Excel、本地图片与任务历史字段均未被物理删除。
|
||||
- [x] 已删除或正在处理任务不能被重复删除,相关 API 事务安全且不产生半删除状态。
|
||||
- [x] `tests/test_db.py` 覆盖 schema 迁移、默认排除、单条读取和重复/运行中保护;`tests/test_gui.py` 覆盖右键选中、确认/取消、已提交提示和删除后刷新。
|
||||
|
||||
## 验证
|
||||
|
||||
@@ -48,4 +48,7 @@ git diff --check
|
||||
|
||||
## 执行记录
|
||||
|
||||
- 待实现。
|
||||
- 为 `tasks` 增加 `deleted_at/deleted_reason` 兼容迁移和索引,新增事务化 `db.delete_task()`;默认 `list_tasks/get_task` 同时排除已删除批次和已删除任务,历史商品状态迁移也跳过已删除任务。
|
||||
- ①任务表新增右键「删除本条记录」菜单和确认框,已提交线上任务显示不回滚提示;成功后刷新①并通过既有回调刷新②/③。运行中入口禁用,数据库拒绝 `running` 或已删除任务。
|
||||
- 更新架构、API 和路由文档,明确单条软删除不删除 Excel、图片、账号 Chrome 数据或线上商品。
|
||||
- 验证通过:`py -3.10 -m unittest discover -s tests`(623 项)、`py -3.10 -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`git diff --check`。
|
||||
|
||||
+76
-1
@@ -119,6 +119,8 @@ class DbTests(TempDirMixin, unittest.TestCase):
|
||||
self.assertIn("product_status", columns)
|
||||
self.assertIn("product_status_note", columns)
|
||||
self.assertIn("product_status_at", columns)
|
||||
self.assertIn("deleted_at", columns)
|
||||
self.assertIn("deleted_reason", columns)
|
||||
task = db.get_task(1, conn=conn)
|
||||
self.assertEqual(0, task.cover_reset_count)
|
||||
self.assertIsNone(task.cover_reset_at)
|
||||
@@ -146,6 +148,7 @@ class DbTests(TempDirMixin, unittest.TestCase):
|
||||
"boundary",
|
||||
"after",
|
||||
"deleted",
|
||||
"task-deleted",
|
||||
),
|
||||
start=1,
|
||||
):
|
||||
@@ -170,13 +173,14 @@ class DbTests(TempDirMixin, unittest.TestCase):
|
||||
try:
|
||||
with conn:
|
||||
conn.execute(
|
||||
"UPDATE batches SET created_at = ? WHERE id IN (?, ?, ?, ?)",
|
||||
"UPDATE batches SET created_at = ? WHERE id IN (?, ?, ?, ?, ?)",
|
||||
(
|
||||
"2026-07-18T16:34:36",
|
||||
batch_ids["legacy-null"],
|
||||
batch_ids["legacy-blank"],
|
||||
batch_ids["legacy-unknown"],
|
||||
batch_ids["deleted"],
|
||||
batch_ids["task-deleted"],
|
||||
),
|
||||
)
|
||||
conn.execute(
|
||||
@@ -195,6 +199,10 @@ class DbTests(TempDirMixin, unittest.TestCase):
|
||||
batch_id=batch_ids["legacy-unknown"],
|
||||
conn=conn,
|
||||
)[0]
|
||||
task_deleted = db.list_tasks(
|
||||
batch_id=batch_ids["task-deleted"],
|
||||
conn=conn,
|
||||
)[0]
|
||||
conn.execute(
|
||||
"UPDATE tasks SET product_status = ' ' WHERE id = ?",
|
||||
(blank_task.id,),
|
||||
@@ -207,6 +215,10 @@ class DbTests(TempDirMixin, unittest.TestCase):
|
||||
"UPDATE batches SET deleted_at = ? WHERE id = ?",
|
||||
("2026-07-19T00:00:00", batch_ids["deleted"]),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE tasks SET deleted_at = ? WHERE id = ?",
|
||||
("2026-07-19T00:00:00", task_deleted.id),
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -221,6 +233,11 @@ class DbTests(TempDirMixin, unittest.TestCase):
|
||||
path=db_path,
|
||||
include_deleted=True,
|
||||
)[0]
|
||||
task_deleted = db.list_tasks(
|
||||
batch_id=batch_ids["task-deleted"],
|
||||
path=db_path,
|
||||
include_deleted=True,
|
||||
)[0]
|
||||
|
||||
for task in (legacy_null, legacy_blank):
|
||||
self.assertEqual("normal", task.product_status)
|
||||
@@ -231,6 +248,7 @@ class DbTests(TempDirMixin, unittest.TestCase):
|
||||
self.assertIsNone(boundary.product_status)
|
||||
self.assertIsNone(after.product_status)
|
||||
self.assertIsNone(deleted.product_status)
|
||||
self.assertIsNone(task_deleted.product_status)
|
||||
|
||||
first_updated_at = legacy_null.updated_at
|
||||
db.init_db(db_path)
|
||||
@@ -1005,6 +1023,63 @@ class DbTests(TempDirMixin, unittest.TestCase):
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_delete_task_soft_hides_only_one_inactive_task(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
db_path = os.path.join(temp_dir, "cmshopee.db")
|
||||
db.init_db(db_path)
|
||||
batch_id = db.create_batch(["input.xlsx"], path=db_path)
|
||||
db.insert_tasks(
|
||||
batch_id,
|
||||
[
|
||||
{
|
||||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||||
"source_sheet": "Sheet1",
|
||||
"source_row": 2,
|
||||
"account_name": "shop",
|
||||
"alias": "alias",
|
||||
"item_id": "51100639510",
|
||||
},
|
||||
{
|
||||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||||
"source_sheet": "Sheet1",
|
||||
"source_row": 3,
|
||||
"account_name": "shop",
|
||||
"alias": "alias",
|
||||
"item_id": "51100639511",
|
||||
},
|
||||
],
|
||||
path=db_path,
|
||||
)
|
||||
first, second = db.list_tasks(batch_id=batch_id, path=db_path)
|
||||
db.set_collected(first.id, "旧标题", "old.jpg", path=db_path)
|
||||
db.set_generated(first.id, "新标题", "new.jpg", path=db_path)
|
||||
db.set_applied(first.id, True, path=db_path)
|
||||
|
||||
result = db.delete_task(first.id, reason="导入了错误商品", path=db_path)
|
||||
|
||||
self.assertEqual(first.id, result["task_id"])
|
||||
self.assertEqual(batch_id, result["batch_id"])
|
||||
self.assertEqual("51100639510", result["item_id"])
|
||||
self.assertEqual(1, result["committed"])
|
||||
self.assertEqual([second.id], [task.id for task in db.list_tasks(batch_id=batch_id, path=db_path)])
|
||||
self.assertIsNone(db.get_task(first.id, path=db_path))
|
||||
deleted = db.get_task(first.id, path=db_path, include_deleted=True)
|
||||
self.assertIsNotNone(deleted.deleted_at)
|
||||
self.assertEqual("导入了错误商品", deleted.deleted_reason)
|
||||
self.assertEqual("旧标题", deleted.old_title)
|
||||
self.assertEqual("新标题", deleted.new_title)
|
||||
self.assertEqual("new.jpg", deleted.new_cover_path)
|
||||
self.assertEqual(1, deleted.committed)
|
||||
self.assertEqual([batch_id], [batch.id for batch in db.list_batches(path=db_path)])
|
||||
|
||||
with self.assertRaisesRegex(db.DbError, "不存在或已删除"):
|
||||
db.delete_task(first.id, path=db_path)
|
||||
db.mark_running(second.id, "collect", path=db_path)
|
||||
with self.assertRaisesRegex(db.DbError, "正在处理"):
|
||||
db.delete_task(second.id, path=db_path)
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_duplicate_task_and_invalid_update_raise_clear_errors(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
db_path = os.path.join(temp_dir, "cmshopee.db")
|
||||
|
||||
@@ -8827,6 +8827,115 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_collect_tab_soft_deletes_selected_task_from_context_menu(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg = self.make_config(temp_dir)
|
||||
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||||
db.insert_tasks(
|
||||
batch_id,
|
||||
[
|
||||
{
|
||||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||||
"source_sheet": "商品",
|
||||
"source_row": 2,
|
||||
"account_name": "Excel主店",
|
||||
"alias": "alias-a",
|
||||
"item_id": "51100639510",
|
||||
},
|
||||
{
|
||||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||||
"source_sheet": "商品",
|
||||
"source_row": 3,
|
||||
"account_name": "Excel主店",
|
||||
"alias": "alias-a",
|
||||
"item_id": "51100639511",
|
||||
},
|
||||
],
|
||||
path=cfg["db_path"],
|
||||
)
|
||||
first, second = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||||
db.set_collected(first.id, "旧标题", "old.jpg", path=cfg["db_path"])
|
||||
db.set_generated(first.id, "新标题", "new.jpg", path=cfg["db_path"])
|
||||
db.set_applied(first.id, True, path=cfg["db_path"])
|
||||
db.set_collected(second.id, "旧标题2", "old2.jpg", path=cfg["db_path"])
|
||||
db.set_generated(second.id, "新标题2", "new2.jpg", path=cfg["db_path"])
|
||||
statuses = []
|
||||
refresh_calls = []
|
||||
generate_tab = GenerateTab(config=cfg)
|
||||
apply_tab = ApplyTab(config=cfg)
|
||||
apply_tab.status_filter.setCurrentIndex(apply_tab.status_filter.findData("all"))
|
||||
|
||||
def refresh_workflow():
|
||||
refresh_calls.append(True)
|
||||
generate_tab.refresh_tasks()
|
||||
apply_tab.refresh_tasks()
|
||||
|
||||
collect_tab = CollectTab(
|
||||
config=cfg,
|
||||
status_callback=statuses.append,
|
||||
refresh_workflow_callback=refresh_workflow,
|
||||
)
|
||||
self.addCleanup(collect_tab.close)
|
||||
self.addCleanup(generate_tab.close)
|
||||
self.addCleanup(apply_tab.close)
|
||||
collect_tab.resize(1000, 600)
|
||||
collect_tab.show()
|
||||
QApplication.processEvents()
|
||||
first_index = collect_tab.model.index(0, 0)
|
||||
first_rect = collect_tab.table.visualRect(first_index)
|
||||
self.assertTrue(first_rect.isValid())
|
||||
|
||||
class FakeMenu:
|
||||
def __init__(self, parent=None):
|
||||
self.actions = []
|
||||
|
||||
def addAction(self, text):
|
||||
action = SimpleNamespace(
|
||||
text=text,
|
||||
enabled=True,
|
||||
triggered=DummySignal(),
|
||||
)
|
||||
action.setEnabled = lambda enabled: setattr(action, "enabled", bool(enabled))
|
||||
self.actions.append(action)
|
||||
return action
|
||||
|
||||
def exec(self, position):
|
||||
return None
|
||||
|
||||
with mock.patch("app.gui.tabs.collect.QMenu", FakeMenu):
|
||||
collect_tab._show_task_context_menu(first_rect.center())
|
||||
self.assertEqual(0, collect_tab.table.currentIndex().row())
|
||||
|
||||
with mock.patch(
|
||||
"app.gui.QMessageBox.question",
|
||||
return_value=gui.QMessageBox.No,
|
||||
) as question:
|
||||
collect_tab.delete_selected_task()
|
||||
self.assertIsNotNone(db.get_task(first.id, path=cfg["db_path"]))
|
||||
self.assertIn("已取消删除本条记录", statuses[-1])
|
||||
self.assertIn("不会回滚蝦皮线上商品", question.call_args[0][2])
|
||||
|
||||
with mock.patch(
|
||||
"app.gui.QMessageBox.question",
|
||||
return_value=gui.QMessageBox.Yes,
|
||||
), mock.patch("app.gui.QMessageBox.information") as information:
|
||||
collect_tab.delete_selected_task()
|
||||
|
||||
self.assertIsNone(db.get_task(first.id, path=cfg["db_path"]))
|
||||
self.assertEqual([second.id], [task.id for task in db.list_tasks(batch_id=batch_id, path=cfg["db_path"])])
|
||||
self.assertTrue(all(task.id != first.id for task in generate_tab.model.tasks))
|
||||
self.assertTrue(all(task.id != first.id for task in apply_tab.model.tasks))
|
||||
self.assertEqual([True], refresh_calls)
|
||||
self.assertIn("已删除本地记录:商品 51100639510", statuses[-1])
|
||||
self.assertIn("已删除本地记录:商品 51100639510", information.call_args[0][2])
|
||||
|
||||
collect_tab.collect_thread = object()
|
||||
collect_tab.delete_selected_task()
|
||||
self.assertIn("采集或回写正在进行", statuses[-1])
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_collect_tab_can_filter_unmatched_tasks_from_summary_bar(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg = self.make_config(temp_dir)
|
||||
|
||||
Reference in New Issue
Block a user