feat: add soft delete for import batches
This commit is contained in:
@@ -16,7 +16,13 @@ from .config import make_slug
|
||||
|
||||
|
||||
DEFAULT_BUSY_TIMEOUT_MS = 5000
|
||||
VALID_BATCH_FIELDS = {"source_files_json", "status", "note"}
|
||||
VALID_BATCH_FIELDS = {
|
||||
"source_files_json",
|
||||
"status",
|
||||
"note",
|
||||
"deleted_at",
|
||||
"deleted_reason",
|
||||
}
|
||||
VALID_ACCOUNT_FIELDS = {
|
||||
"account_name",
|
||||
"alias",
|
||||
@@ -61,6 +67,8 @@ class Batch:
|
||||
note: Optional[str]
|
||||
created_at: str
|
||||
updated_at: str
|
||||
deleted_at: Optional[str]
|
||||
deleted_reason: Optional[str]
|
||||
|
||||
@property
|
||||
def source_files(self) -> list[str]:
|
||||
@@ -157,7 +165,9 @@ CREATE TABLE IF NOT EXISTS batches (
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
note TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
updated_at TEXT NOT NULL,
|
||||
deleted_at TEXT,
|
||||
deleted_reason TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
@@ -313,8 +323,16 @@ def init_db(path=None, conn=None) -> None:
|
||||
with _connection(conn, path) as database:
|
||||
with database:
|
||||
database.executescript(SCHEMA_SQL)
|
||||
_ensure_batch_delete_columns(database)
|
||||
|
||||
|
||||
def _ensure_batch_delete_columns(database):
|
||||
columns = {row["name"] for row in database.execute("PRAGMA table_info(batches)").fetchall()}
|
||||
if "deleted_at" not in columns:
|
||||
database.execute("ALTER TABLE batches ADD COLUMN deleted_at TEXT")
|
||||
if "deleted_reason" not in columns:
|
||||
database.execute("ALTER TABLE batches ADD COLUMN deleted_reason TEXT")
|
||||
|
||||
def create_batch(file_paths: Iterable[str], note=None, path=None, conn=None) -> str:
|
||||
batch_id = datetime.now().strftime("%Y%m%d_%H%M%S_") + uuid.uuid4().hex[:8]
|
||||
files = [os.path.abspath(file_path) for file_path in file_paths]
|
||||
@@ -332,22 +350,26 @@ def create_batch(file_paths: Iterable[str], note=None, path=None, conn=None) ->
|
||||
return batch_id
|
||||
|
||||
|
||||
def get_batch(batch_id, path=None, conn=None):
|
||||
def get_batch(batch_id, path=None, conn=None, include_deleted=False):
|
||||
sql = "SELECT * FROM batches WHERE id = ?"
|
||||
params = [batch_id]
|
||||
if not include_deleted:
|
||||
sql += " AND deleted_at IS NULL"
|
||||
with _connection(conn, path) as database:
|
||||
return _fetch_one(
|
||||
database,
|
||||
"SELECT * FROM batches WHERE id = ?",
|
||||
(batch_id,),
|
||||
Batch,
|
||||
)
|
||||
return _fetch_one(database, sql, params, Batch)
|
||||
|
||||
|
||||
def list_batches(status=None, path=None, conn=None):
|
||||
sql = "SELECT * FROM batches"
|
||||
def list_batches(status=None, path=None, conn=None, include_deleted=False):
|
||||
clauses = []
|
||||
params = []
|
||||
if status is not None:
|
||||
sql += " WHERE status = ?"
|
||||
clauses.append("status = ?")
|
||||
params.append(status)
|
||||
if not include_deleted:
|
||||
clauses.append("deleted_at IS NULL")
|
||||
sql = "SELECT * FROM batches"
|
||||
if clauses:
|
||||
sql += " WHERE " + " AND ".join(clauses)
|
||||
sql += " ORDER BY created_at DESC, id DESC"
|
||||
with _connection(conn, path) as database:
|
||||
return _fetch_all(database, sql, params, Batch)
|
||||
@@ -497,35 +519,78 @@ def insert_tasks(batch_id, rows, path=None, conn=None) -> int:
|
||||
return len(values)
|
||||
|
||||
|
||||
def list_tasks(batch_id=None, stage=None, status=None, alias=None, path=None, conn=None):
|
||||
def list_tasks(
|
||||
batch_id=None,
|
||||
stage=None,
|
||||
status=None,
|
||||
alias=None,
|
||||
path=None,
|
||||
conn=None,
|
||||
include_deleted=False,
|
||||
):
|
||||
clauses = []
|
||||
params = []
|
||||
filters = {
|
||||
"batch_id": batch_id,
|
||||
"stage": stage,
|
||||
"status": status,
|
||||
"alias": alias,
|
||||
"t.batch_id": batch_id,
|
||||
"t.stage": stage,
|
||||
"t.status": status,
|
||||
"t.alias": alias,
|
||||
}
|
||||
for field, value in filters.items():
|
||||
if value is not None:
|
||||
clauses.append(f"{field} = ?")
|
||||
params.append(value)
|
||||
sql = "SELECT * FROM tasks"
|
||||
if not include_deleted:
|
||||
clauses.append("b.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)
|
||||
sql += " ORDER BY id"
|
||||
sql += " ORDER BY t.id"
|
||||
with _connection(conn, path) as database:
|
||||
return _fetch_all(database, sql, params, Task)
|
||||
|
||||
|
||||
def get_task(task_id, path=None, conn=None):
|
||||
def delete_batch(batch_id, reason=None, path=None, conn=None) -> dict:
|
||||
"""Soft delete a batch so it disappears from normal UI and workflows."""
|
||||
|
||||
with _connection(conn, path) as database:
|
||||
return _fetch_one(
|
||||
database,
|
||||
"SELECT * FROM tasks WHERE id = ?",
|
||||
(int(task_id),),
|
||||
Task,
|
||||
)
|
||||
batch = get_batch(batch_id, conn=database)
|
||||
if batch is None:
|
||||
raise DbError(f"批次不存在或已删除: {batch_id}")
|
||||
tasks = list_tasks(batch_id=batch_id, conn=database)
|
||||
image_paths = []
|
||||
for task in tasks:
|
||||
for image_path in (task.old_cover_path, task.new_cover_path):
|
||||
if image_path and image_path not in image_paths:
|
||||
image_paths.append(image_path)
|
||||
committed_count = sum(1 for task in tasks if int(task.committed or 0) == 1)
|
||||
now = _now()
|
||||
with database:
|
||||
database.execute(
|
||||
"""
|
||||
UPDATE batches
|
||||
SET deleted_at = ?, deleted_reason = ?, updated_at = ?
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
""",
|
||||
(now, str(reason or ""), now, batch_id),
|
||||
)
|
||||
return {
|
||||
"batch_id": batch_id,
|
||||
"deleted_at": now,
|
||||
"task_count": len(tasks),
|
||||
"committed_count": committed_count,
|
||||
"image_paths": image_paths,
|
||||
}
|
||||
|
||||
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")
|
||||
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:
|
||||
return _fetch_one(database, sql, params, Task)
|
||||
|
||||
|
||||
def mark_running(task_id, phase, path=None, conn=None) -> None:
|
||||
|
||||
+105
-4
@@ -1820,12 +1820,14 @@ if QT_IMPORT_ERROR is None:
|
||||
config=None,
|
||||
status_callback=None,
|
||||
open_accounts_callback=None,
|
||||
refresh_workflow_callback=None,
|
||||
):
|
||||
super().__init__(parent)
|
||||
self.config = appconfig.load_config() if config is None else config
|
||||
self.db_path = _database_path(db_path, self.config)
|
||||
self.status_callback = status_callback
|
||||
self.open_accounts_callback = open_accounts_callback
|
||||
self.refresh_workflow_callback = refresh_workflow_callback
|
||||
self.current_batch_id = None
|
||||
self.has_import_result = False
|
||||
self.last_import_stats = None
|
||||
@@ -1841,10 +1843,18 @@ if QT_IMPORT_ERROR is None:
|
||||
self.stop_collect_button = QPushButton("停止")
|
||||
self.write_back_button = QPushButton("回写旧数据到 Excel")
|
||||
self.stop_collect_button.setEnabled(False)
|
||||
self.batch_filter = QComboBox()
|
||||
self.batch_filter.setObjectName("collectBatchFilter")
|
||||
self.delete_batch_button = QPushButton("删除批次")
|
||||
self.delete_batch_button.setObjectName("deleteBatchButton")
|
||||
self.delete_batch_button.setEnabled(False)
|
||||
|
||||
toolbar = QHBoxLayout()
|
||||
toolbar.addWidget(self.import_button)
|
||||
toolbar.addWidget(self.refresh_button)
|
||||
toolbar.addWidget(QLabel("批次"))
|
||||
toolbar.addWidget(self.batch_filter, 2)
|
||||
toolbar.addWidget(self.delete_batch_button)
|
||||
toolbar.addWidget(self.collect_button)
|
||||
toolbar.addWidget(self.stop_collect_button)
|
||||
toolbar.addWidget(self.write_back_button)
|
||||
@@ -1890,6 +1900,8 @@ if QT_IMPORT_ERROR is None:
|
||||
|
||||
self.import_button.clicked.connect(self.import_excel)
|
||||
self.refresh_button.clicked.connect(self.refresh_tasks)
|
||||
self.batch_filter.currentIndexChanged.connect(self.refresh_tasks)
|
||||
self.delete_batch_button.clicked.connect(self.delete_current_batch)
|
||||
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)
|
||||
@@ -1981,10 +1993,14 @@ if QT_IMPORT_ERROR is None:
|
||||
def refresh_tasks(self, checked=False):
|
||||
try:
|
||||
db.init_db(self.db_path)
|
||||
if self.current_batch_id is None and self.has_import_result:
|
||||
task_rows = []
|
||||
else:
|
||||
task_rows = db.list_tasks(batch_id=self.current_batch_id, path=self.db_path)
|
||||
batches = db.list_batches(path=self.db_path)
|
||||
selected_batch = self.batch_filter.currentData()
|
||||
if self.current_batch_id and self.batch_filter.findData(self.current_batch_id) < 0:
|
||||
selected_batch = self.current_batch_id
|
||||
self._populate_batch_filter(batches, selected_batch)
|
||||
selected_batch = self.batch_filter.currentData()
|
||||
self.current_batch_id = selected_batch
|
||||
task_rows = db.list_tasks(batch_id=selected_batch, path=self.db_path)
|
||||
account_rows = accounts.list_accounts(path=self.db_path, config=self.config)
|
||||
except Exception as exc:
|
||||
self.model.set_tasks([], [])
|
||||
@@ -1994,6 +2010,80 @@ if QT_IMPORT_ERROR is None:
|
||||
self.model.set_tasks(task_rows, account_rows)
|
||||
self._update_summary(task_rows, account_rows)
|
||||
self._update_empty_label(len(task_rows))
|
||||
self._update_delete_batch_button()
|
||||
|
||||
def _populate_batch_filter(self, batches, selected_batch):
|
||||
batch_ids = {batch.id for batch in batches}
|
||||
previous = selected_batch if selected_batch in batch_ids else None
|
||||
self.batch_filter.blockSignals(True)
|
||||
self.batch_filter.clear()
|
||||
self.batch_filter.addItem("全部批次", None)
|
||||
for batch in batches:
|
||||
self.batch_filter.addItem(self._batch_label(batch), batch.id)
|
||||
index = self.batch_filter.findData(previous)
|
||||
self.batch_filter.setCurrentIndex(index if index >= 0 else 0)
|
||||
self.batch_filter.blockSignals(False)
|
||||
|
||||
def _batch_label(self, batch):
|
||||
source_files = batch.source_files
|
||||
first_file = os.path.basename(source_files[0]) if source_files else batch.id
|
||||
return f"{batch.created_at} · {first_file}"
|
||||
|
||||
def _selected_batch_id(self):
|
||||
return self.batch_filter.currentData()
|
||||
|
||||
def _update_delete_batch_button(self):
|
||||
running = bool(self.collect_thread or self.write_back_thread)
|
||||
self.delete_batch_button.setEnabled((not running) and bool(self._selected_batch_id()))
|
||||
|
||||
def delete_current_batch(self, checked=False):
|
||||
batch_id = self._selected_batch_id()
|
||||
if not batch_id:
|
||||
self._set_status("请先选择一个具体批次")
|
||||
return
|
||||
batch = db.get_batch(batch_id, path=self.db_path)
|
||||
if batch is None:
|
||||
self._set_status("批次不存在或已删除")
|
||||
self.current_batch_id = None
|
||||
self.refresh_tasks()
|
||||
return
|
||||
tasks = db.list_tasks(batch_id=batch_id, path=self.db_path)
|
||||
committed_count = sum(1 for task in tasks if int(getattr(task, "committed", 0) or 0) == 1)
|
||||
lines = [
|
||||
f"确定要软删除批次 {self._batch_label(batch)} 吗?",
|
||||
f"任务数:{len(tasks)}",
|
||||
f"已提交线上:{committed_count}",
|
||||
"",
|
||||
"软删除后,该批次不会再出现在①/②/③页面、筛选、采集、生成、更新或回写入口中。",
|
||||
"软删除只隐藏本地批次,不会回滚 Shopee 线上修改,不删除原始 Excel,也不删除本地图片。",
|
||||
]
|
||||
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_batch(batch_id, reason="用户在导入采集页软删除", path=self.db_path)
|
||||
except Exception as exc:
|
||||
QMessageBox.warning(self, "删除批次", str(exc))
|
||||
self._set_status(f"删除批次失败:{exc}")
|
||||
return
|
||||
self.current_batch_id = None
|
||||
self.has_import_result = False
|
||||
self.refresh_tasks()
|
||||
if self.refresh_workflow_callback is not None:
|
||||
self.refresh_workflow_callback()
|
||||
message = "已软删除批次:任务{task_count},已提交线上{committed_count}".format(
|
||||
task_count=result.get("task_count", 0),
|
||||
committed_count=result.get("committed_count", 0),
|
||||
)
|
||||
self._set_status(message)
|
||||
QMessageBox.information(self, "删除批次", message)
|
||||
|
||||
def collect_old_data(self, checked=False):
|
||||
tasks = list(self.model.all_tasks)
|
||||
@@ -2077,12 +2167,16 @@ if QT_IMPORT_ERROR is None:
|
||||
self.collect_button.setEnabled(not running)
|
||||
self.write_back_button.setEnabled(not running)
|
||||
self.stop_collect_button.setEnabled(running)
|
||||
self.batch_filter.setEnabled(not running)
|
||||
self._update_delete_batch_button()
|
||||
|
||||
def _set_write_back_running(self, running):
|
||||
self.import_button.setEnabled(not running)
|
||||
self.refresh_button.setEnabled(not running)
|
||||
self.collect_button.setEnabled(not running)
|
||||
self.write_back_button.setEnabled(not running)
|
||||
self.batch_filter.setEnabled(not running)
|
||||
self._update_delete_batch_button()
|
||||
|
||||
def _forget_collect_thread(self, thread):
|
||||
if self.collect_thread is thread:
|
||||
@@ -4629,6 +4723,7 @@ if QT_IMPORT_ERROR is None:
|
||||
config=self.config,
|
||||
status_callback=self.statusBar().showMessage,
|
||||
open_accounts_callback=lambda: self.open_accounts_tab(),
|
||||
refresh_workflow_callback=lambda: self.refresh_task_tabs(),
|
||||
)
|
||||
if title == "② AI生成":
|
||||
return GenerateTab(
|
||||
@@ -4656,6 +4751,12 @@ if QT_IMPORT_ERROR is None:
|
||||
status_callback=self.statusBar().showMessage,
|
||||
)
|
||||
|
||||
def refresh_task_tabs(self):
|
||||
for index in range(self.tabs.count()):
|
||||
widget = self.tabs.widget(index)
|
||||
if hasattr(widget, "refresh_tasks"):
|
||||
widget.refresh_tasks()
|
||||
|
||||
def _on_tab_changed(self, index):
|
||||
self.statusBar().showMessage(f"当前:{self.tabs.tabText(index)}")
|
||||
|
||||
|
||||
@@ -185,7 +185,9 @@ CREATE TABLE batches (
|
||||
status TEXT NOT NULL DEFAULT 'active', -- active/done/partial/failed
|
||||
note TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
updated_at TEXT NOT NULL,
|
||||
deleted_at TEXT, -- T-206 软删除时间;默认业务查询排除
|
||||
deleted_reason TEXT -- 软删除原因/来源
|
||||
);
|
||||
|
||||
-- 账号(④ 账号管理)
|
||||
@@ -470,5 +472,6 @@ cmshopee/
|
||||
- 别名是账号↔任务唯一关联键。
|
||||
- 密码与 AI Key 本地明文保存,保存/变更时弹窗提示;UI 打码、不外传、不写日志/导出;不自动登录。
|
||||
- AI 生成内容直接进入 ③ 更新候选;③ 批量确认后提交,新图本地留档 + 回写 Excel 以备追溯。
|
||||
- 批次删除是软删除:写 `batches.deleted_at/deleted_reason`,不物理删除 `batches/tasks`;默认 `list_batches/list_tasks/get_task` 与 ①/②/③ 页面和执行入口都排除已删除批次。
|
||||
- 高风险模块先单独验证,再接入流水线。
|
||||
- GUI 只通过 signal/slot 接收 worker 进度;禁止后台线程直接操作 Qt widget 或共享 SQLite connection。
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@
|
||||
| T-204b | 采集完成后自动回写旧字段到 Excel | T-204 | `CollectWorker` 完成后自动触发 `excel.write_back()` 回写当前批次旧字段;成功时状态栏/日志提示“已回写”;原文件被锁时不影响 SQLite,提示关闭后点「回写旧数据到 Excel」手动重试或另存副本 | DONE |
|
||||
| T-205 | 首次未配账号 / Chrome 未启动 / 未登录的引导保护 | T-105, T-203 | 无账号、匹配账号未启动 CDP 端口或未登录时,① 执行按钮禁用或采集前汇总提示,并引导去④;可提供“打开账号管理/启动登录”入口,但不无提示批量启动所有账号 Chrome | DONE |
|
||||
| T-205b | 采集后关闭程序自动新建的商品编辑页 tab | T-203 | `open_product` 区分复用旧 tab 与本次新建 tab;`CDP.close()` 仍只断开连接;采集完成后只关闭本次自动新建的商品页,不关闭用户原本打开的 tab;失败/异常也执行清理;③ 更新流程暂不自动关页,失败时保留现场便于排查 | DONE |
|
||||
| T-206 | Tab① 删除指定批次(硬删除) | T-202, T-104, T-404 | 现状:导入后批次只增不减,下拉框无限膨胀、导错文件/测试导入无法清理。方案:`db.delete_batch(batch_id)` 在一个事务里删 `tasks` 再删 `batches`,返回已删任务数/其中已上线(`committed=1`)任务数/关联封面图片绝对路径;Tab① 批次筛选旁加「删除批次」按钮,仅在选中具体批次(非「全部批次」)时可用,运行中禁用。二次确认弹窗显示批次标签、任务数、已上线任务数并提示「删除本地记录不会回滚 Shopee 线上修改」,含「同时删除已下载/生成的封面图片」勾选项(默认不勾);确认后删库、按勾选清理孤儿图片、刷新①与③列表 | TODO |
|
||||
| T-206 | Tab① 删除指定批次(软删除) | T-202, T-104, T-404 | 现状:导入后批次只增不减,下拉框无限膨胀、导错文件/测试导入无法清理。方案:`db.delete_batch(batch_id)` 改为软删除,在事务中给 `batches.deleted_at/deleted_reason` 写值,不物理删除 `batches/tasks`;默认 `list_batches/list_tasks` 以及①/②/③页面、筛选、采集、生成、更新、回写等业务入口都排除已删除批次,删除后用户不能在软件页面查看、筛选或再次调用该批任务。函数返回任务数、其中已上线(`committed=1`)任务数、关联封面图片绝对路径;Tab① 批次筛选旁加「删除批次」按钮,仅在选中具体批次(非「全部批次」)时可用,运行中禁用。二次确认弹窗显示批次标签、任务数、已上线任务数并提示「软删除只隐藏本地批次,不会回滚 Shopee 线上修改」;图片默认保留,不做自动清理,后续若需要清理图片另做独立工具。确认后软删除、刷新①/②/③列表和批次筛选 | DONE |
|
||||
| T-207 | ① 采集诊断日志(run_logs + 本地 log) | T-203, T-503, T-504 | 问题:采集少量商品时单条失败只能看到 `tasks.last_error` 和状态栏计数,无法定位卡在打开商品页/页面就绪/读标题/读封面/下载图片/写库/回写哪一步。方案:`CollectWorker` 创建 `run_type=collect` 的运行日志,逐任务写 `run_log_events`(含 `task_id/alias/item_id` 和 `step=preflight/open_product/wait_ready/read_title/read_cover/download_cover/db_write/excel_write_back`);同时新增 gitignore 的 `logs/` 本地滚动日志,写脱敏 traceback 与耗时;GUI 至少能查看最近一次采集运行日志;失败仍按现有语义写 `status=failed/last_error`,不影响其他任务继续 | DONE |
|
||||
|
||||
## Phase 3 · AI 生成(②)
|
||||
|
||||
+6
-1
@@ -81,7 +81,12 @@ update_account(alias, **fields) -> None # 支持账号展示字段、端
|
||||
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) -> list[Task]
|
||||
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 仅供内部诊断/测试使用
|
||||
|
||||
delete_batch(batch_id, reason=None, path=None) -> dict
|
||||
# T-206 软删除:写 batches.deleted_at/deleted_reason,不物理删除 batches/tasks;默认业务列表和执行流程不可见/不可调用;返回任务数、committed 数和关联图片路径
|
||||
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 不前进
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -57,6 +57,7 @@
|
||||
- 回写:采集完成后自动把旧标题/旧封面路径批量回写原 Excel;保留「回写旧数据到 Excel」作为手动重试入口(原文件被锁→提示关闭后重试/另存)。
|
||||
- 别名未匹配账号 / 账号未登录 → 该行 skipped 并记原因。`T-207` 接入后,① 采集会像③更新一样写 `run_logs/run_log_events`,并把完整脱敏 traceback 写入本地 `logs/`,用于定位失败卡在哪个步骤。
|
||||
|
||||
- 「删除批次」位于①批次筛选旁,只能对当前选中的具体批次执行,不能在“全部批次”下执行;运行中禁用。删除是软删除:写本地批次删除标记,不物理删除 DB 记录,不删除原 Excel,不回滚 Shopee。删除后该批次不再出现在①/②/③任何批次下拉、任务列表、筛选、采集、生成、更新、回写入口中。确认框必须显示任务数、已上线任务数,并提示软删除只隐藏本地批次、不会回滚线上修改。
|
||||
## ② AI生成
|
||||
|
||||
左右布局:左侧约 1/4 放提示词,右侧放筛选 + 任务列表。
|
||||
|
||||
+9
-1
@@ -901,4 +901,12 @@
|
||||
- 验收事实:用户手动导入包含 5 个商品 ID 的 Excel,完成 AI 标题和图片生成,并通过 Tab③「开始更新」流程更新到 Shopee。
|
||||
- 覆盖链路:Excel 导入 → AI 生成标题/图片 → Tab③ 安全确认与账号预检 → 打开真实商品详情页 → 更新标题/封面 → Shopee 站点侧确认框 → 提交到线上 → 本地更新结果链路。
|
||||
- 结论:该实测已超过 T-404 原本“真实 Shopee 单条更新冒烟验收”的范围,T-404 标记为 DONE。
|
||||
- 后续:可以开始 T-206「Tab① 删除指定批次」;实现时仍需保留已上线记录提示,默认不删除本地图片,只有用户勾选时才清理关联封面文件。
|
||||
- 后续:可以开始 T-206「Tab① 删除指定批次」;实现时仍需保留已上线记录提示,默认不删除本地图片,只有用户勾选时才清理关联封面文件。
|
||||
## 【2026-07-01】T-206 完成 · Tab① 指定批次软删除
|
||||
|
||||
- 产品决策:删除指定批次改为软删除,不物理删除 `batches/tasks`,也不删除原 Excel 或本地图片;删除后该批次默认不能在软件页面查看、筛选、采集、生成、更新或回写。
|
||||
- 数据库:`batches` 增加 `deleted_at/deleted_reason`,`init_db()` 自动补旧库字段;`delete_batch()` 只写删除标记并返回任务数、已提交线上数、关联图片路径;`list_batches/list_tasks/get_task` 默认排除已软删除批次,`include_deleted=True` 仅供诊断/测试。
|
||||
- GUI:① 导入采集页新增批次下拉和「删除批次」按钮;只能删除具体批次,运行中禁用;确认框显示任务数、已提交线上数,并提示不会回滚 Shopee。确认后刷新①自身和②/③批次筛选与任务列表。
|
||||
- 测试:新增 DB 软删除默认过滤测试;新增 GUI 软删除回归测试,覆盖删除后①/②/③下拉和任务列表都不再包含该批次,且默认 `get_task` 也不能绕过软删除。
|
||||
- 验证:`python -m py_compile app\db.py app\gui.py tests\test_db.py tests\test_gui.py` 通过;`python -m unittest discover -s tests -p "test_db.py"` 通过(6 tests);`python -m unittest discover -s tests -p "test_gui.py" -k soft_deletes` 通过(1 test);`python -m compileall app main.py` 通过;`python -m unittest discover -s tests` 通过(143 tests);`git diff --check` 无空白错误(仅 LF/CRLF 提示)。
|
||||
- 下一步:T-505 全流程诊断日志扩展。
|
||||
@@ -167,6 +167,61 @@ class DbTests(TempDirMixin, unittest.TestCase):
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
|
||||
def test_delete_batch_soft_hides_batch_and_tasks(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,
|
||||
)
|
||||
tasks = db.list_tasks(batch_id=batch_id, path=db_path)
|
||||
old_cover = os.path.join(temp_dir, "old.jpg")
|
||||
new_cover = os.path.join(temp_dir, "new.jpg")
|
||||
db.set_collected(tasks[0].id, "旧标题", old_cover, path=db_path)
|
||||
db.set_generated(tasks[0].id, "新标题", new_cover, path=db_path)
|
||||
db.set_applied(tasks[0].id, True, path=db_path)
|
||||
|
||||
result = db.delete_batch(batch_id, reason="导错文件", path=db_path)
|
||||
|
||||
self.assertEqual(batch_id, result["batch_id"])
|
||||
self.assertEqual(2, result["task_count"])
|
||||
self.assertEqual(1, result["committed_count"])
|
||||
self.assertEqual([old_cover, new_cover], result["image_paths"])
|
||||
self.assertEqual([], db.list_batches(path=db_path))
|
||||
self.assertEqual([], db.list_tasks(path=db_path))
|
||||
self.assertIsNone(db.get_batch(batch_id, path=db_path))
|
||||
self.assertIsNone(db.get_task(tasks[0].id, path=db_path))
|
||||
deleted_task = db.get_task(tasks[0].id, path=db_path, include_deleted=True)
|
||||
self.assertEqual(tasks[0].id, deleted_task.id)
|
||||
deleted_batch = db.get_batch(batch_id, path=db_path, include_deleted=True)
|
||||
self.assertIsNotNone(deleted_batch.deleted_at)
|
||||
self.assertEqual("导错文件", deleted_batch.deleted_reason)
|
||||
deleted_tasks = db.list_tasks(batch_id=batch_id, path=db_path, include_deleted=True)
|
||||
self.assertEqual(2, len(deleted_tasks))
|
||||
|
||||
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")
|
||||
|
||||
@@ -2084,6 +2084,88 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_collect_tab_soft_deletes_batch_and_refreshes_workflow_tabs(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_a = db.create_batch(["input-a.xlsx"], path=cfg["db_path"])
|
||||
batch_b = db.create_batch(["input-b.xlsx"], path=cfg["db_path"])
|
||||
db.insert_tasks(
|
||||
batch_a,
|
||||
[
|
||||
{
|
||||
"source_file_abs": os.path.join(temp_dir, "input-a.xlsx"),
|
||||
"source_sheet": "商品",
|
||||
"source_row": 2,
|
||||
"account_name": "Excel主店",
|
||||
"alias": "alias-a",
|
||||
"item_id": "51100639510",
|
||||
}
|
||||
],
|
||||
path=cfg["db_path"],
|
||||
)
|
||||
db.insert_tasks(
|
||||
batch_b,
|
||||
[
|
||||
{
|
||||
"source_file_abs": os.path.join(temp_dir, "input-b.xlsx"),
|
||||
"source_sheet": "商品",
|
||||
"source_row": 2,
|
||||
"account_name": "Excel主店",
|
||||
"alias": "alias-a",
|
||||
"item_id": "51100639511",
|
||||
}
|
||||
],
|
||||
path=cfg["db_path"],
|
||||
)
|
||||
deleted_task = db.list_tasks(batch_id=batch_a, path=cfg["db_path"])[0]
|
||||
db.set_collected(deleted_task.id, "旧标题", "old.jpg", path=cfg["db_path"])
|
||||
db.set_generated(deleted_task.id, "新标题", "new.jpg", path=cfg["db_path"])
|
||||
db.set_applied(deleted_task.id, True, path=cfg["db_path"])
|
||||
statuses = []
|
||||
refresh_calls = []
|
||||
generate_tab = GenerateTab(config=cfg)
|
||||
apply_tab = ApplyTab(config=cfg)
|
||||
|
||||
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.batch_filter.setCurrentIndex(collect_tab.batch_filter.findData(batch_a))
|
||||
|
||||
with mock.patch(
|
||||
"app.gui.QMessageBox.question",
|
||||
return_value=gui.QMessageBox.Yes,
|
||||
) as question, mock.patch("app.gui.QMessageBox.information") as info:
|
||||
collect_tab.delete_current_batch()
|
||||
|
||||
deleted_batch = db.get_batch(batch_a, path=cfg["db_path"], include_deleted=True)
|
||||
self.assertIsNotNone(deleted_batch.deleted_at)
|
||||
self.assertIsNone(db.get_batch(batch_a, path=cfg["db_path"]))
|
||||
self.assertEqual([], db.list_tasks(batch_id=batch_a, path=cfg["db_path"]))
|
||||
self.assertEqual(1, len(db.list_tasks(batch_id=batch_a, path=cfg["db_path"], include_deleted=True)))
|
||||
self.assertEqual(-1, collect_tab.batch_filter.findData(batch_a))
|
||||
self.assertEqual(-1, generate_tab.batch_filter.findData(batch_a))
|
||||
self.assertEqual(-1, apply_tab.batch_filter.findData(batch_a))
|
||||
self.assertTrue(all(task.batch_id != batch_a for task in collect_tab.model.all_tasks))
|
||||
self.assertTrue(all(task.batch_id != batch_a for task in generate_tab.model.tasks))
|
||||
self.assertTrue(all(task.batch_id != batch_a for task in apply_tab.model.tasks))
|
||||
self.assertEqual([True], refresh_calls)
|
||||
self.assertIn("不会回滚 Shopee", question.call_args[0][2])
|
||||
self.assertIn("已软删除批次", info.call_args[0][2])
|
||||
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