feat: skip incomplete apply tasks before update

This commit is contained in:
chengma
2026-07-17 17:32:43 +08:00
parent e77b16ae2e
commit da0a42090c
6 changed files with 248 additions and 24 deletions
+81 -12
View File
@@ -253,8 +253,10 @@ class ApplyTab(QWidget):
return
update_cfg = self._shopee_update_config()
update_mode = self._current_update_mode()
content_error = self._update_content_error(tasks, update_mode)
if content_error:
content_plan = self._partition_update_content_tasks(tasks, update_mode)
executable_tasks = content_plan["executable"]
if not executable_tasks:
content_error = self._update_content_error(content_plan, update_mode)
QMessageBox.warning(self, "更新内容未生成", content_error)
self._set_status(content_error.replace("\n", " "))
return
@@ -262,7 +264,11 @@ class ApplyTab(QWidget):
answer = QMessageBox.question(
self,
"确认检查本轮更新" if dry_run else "确认开始更新",
self._confirmation_message(tasks, dry_run=dry_run),
self._confirmation_message(
executable_tasks,
dry_run=dry_run,
content_plan=content_plan,
),
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
@@ -271,7 +277,7 @@ class ApplyTab(QWidget):
return
batch_size = max(1, int(update_cfg.get("max_items_per_run", 1) or 1))
worker = ApplyWorker(
tasks,
executable_tasks,
db_path=self.db_path,
config=self.config,
dry_run=dry_run,
@@ -294,11 +300,19 @@ class ApplyTab(QWidget):
self.apply_worker = worker
self.apply_thread = thread
self._set_apply_running(True)
self._reset_run_log(tasks, dry_run=dry_run, batch_size=batch_size)
self._reset_run_log(
executable_tasks,
dry_run=dry_run,
batch_size=batch_size,
content_plan=content_plan,
)
skip_status = self._content_skip_status(content_plan)
if dry_run:
self._set_status(f"开始检查本轮更新:{len(tasks)} 条")
self._set_status(f"开始检查本轮更新:{len(executable_tasks)} 条{skip_status}")
else:
self._set_status(f"开始更新:{len(tasks)} 条,按每批最多 {batch_size} 条执行")
self._set_status(
f"开始更新:{len(executable_tasks)} 条,按每批最多 {batch_size} 条执行{skip_status}"
)
thread.start()
def stop_update(self, checked=False):
@@ -480,7 +494,7 @@ class ApplyTab(QWidget):
return task.status == "skipped"
return True
def _confirmation_message(self, tasks, dry_run=False):
def _confirmation_message(self, tasks, dry_run=False, content_plan=None):
update_cfg = self._shopee_update_config()
update_mode = self._current_update_mode()
update_mode_text = self._update_mode_label(update_mode)
@@ -501,6 +515,8 @@ class ApplyTab(QWidget):
if dry_run
else "即将按当前筛选结果分批更新蝦皮线上商品。\n\n"
)
skipped_text = self._content_skip_text(content_plan)
skipped_block = f"\n{skipped_text}\n" if skipped_text else ""
return (
intro
+ f"批次:{self._batch_filter_label()}\n"
@@ -509,7 +525,9 @@ class ApplyTab(QWidget):
+ f"状态:{self._status_label()}\n"
+ f"更新内容:{update_mode_text}\n"
+ f"任务数:{len(tasks)}\n"
+ f"预计批次:{batch_count}\n\n"
+ f"预计批次:{batch_count}\n"
+ skipped_block
+ "\n"
+ "执行设置:"
+ f"每批最大更新条数={batch_size},"
+ "成功后自动关闭程序新开编辑页,"
@@ -581,7 +599,7 @@ class ApplyTab(QWidget):
self._set_status(f"更新内容已设置为:{self._update_mode_label(update_mode)}")
return True
def _update_content_error(self, tasks, update_mode):
def _partition_update_content_tasks(self, tasks, update_mode):
missing_title = [
task for task in tasks
if appconfig.update_mode_includes_title(update_mode)
@@ -592,10 +610,24 @@ class ApplyTab(QWidget):
if appconfig.update_mode_includes_cover(update_mode)
and not str(getattr(task, "new_cover_path", "") or "").strip()
]
if not missing_title and not missing_cover:
skipped = {id(task) for task in missing_title + missing_cover}
return {
"executable": [task for task in tasks if id(task) not in skipped],
"missing_title": missing_title,
"missing_cover": missing_cover,
}
def _update_content_error(self, tasks_or_plan, update_mode):
if isinstance(tasks_or_plan, dict):
content_plan = tasks_or_plan
else:
content_plan = self._partition_update_content_tasks(tasks_or_plan, update_mode)
if content_plan["executable"]:
return None
lines = []
mode_text = self._update_mode_label(update_mode)
missing_title = content_plan["missing_title"]
missing_cover = content_plan["missing_cover"]
if missing_title:
lines.append(f"当前筛选结果中有 {len(missing_title)} 条缺少新标题,不能执行“{mode_text}”。")
lines.append("请先回到②AI生成选择“只生成标题”或“生成标题和封面”。")
@@ -608,6 +640,38 @@ class ApplyTab(QWidget):
lines.append(f"示例商品ID:{self._sample_item_ids(missing_cover)}")
return "\n".join(lines)
def _content_skip_text(self, content_plan):
if not content_plan:
return ""
missing_title = content_plan.get("missing_title", [])
missing_cover = content_plan.get("missing_cover", [])
skipped_count = len({id(task) for task in missing_title + missing_cover})
if not skipped_count:
return ""
lines = [f"本轮将跳过 {skipped_count} 条缺少所选更新内容的记录:"]
if missing_title:
lines.append(
f"缺少新标题:{len(missing_title)} 条(示例商品ID:{self._sample_item_ids(missing_title)})"
)
if missing_cover:
lines.append(
f"缺少新封面:{len(missing_cover)} 条(示例商品ID:{self._sample_item_ids(missing_cover)})"
)
return "\n".join(lines)
def _content_skip_status(self, content_plan):
if not content_plan:
return ""
missing_title = content_plan.get("missing_title", [])
missing_cover = content_plan.get("missing_cover", [])
skipped_count = len({id(task) for task in missing_title + missing_cover})
if not skipped_count:
return ""
return (
f",跳过 {skipped_count} 条(缺标题 {len(missing_title)} / "
f"缺封面 {len(missing_cover)})"
)
def _sample_item_ids(self, tasks):
values = [
str(getattr(task, "item_id", "") or "").strip()
@@ -711,13 +775,18 @@ class ApplyTab(QWidget):
def _show_current_run_log_empty(self):
self.run_log_view.setPlainText("本轮日志会在开始运行后显示")
def _reset_run_log(self, tasks, dry_run=False, batch_size=1):
def _reset_run_log(self, tasks, dry_run=False, batch_size=1, content_plan=None):
self.run_log_view.clear()
action = "检查" if dry_run else "更新"
mode_text = self._update_mode_label(self._current_update_mode())
self._append_run_log(
f"本轮{action}开始:任务 {len(tasks)} 条,更新内容:{mode_text},每批 {batch_size} 条"
)
skip_status = self._content_skip_status(content_plan)
if skip_status:
self._append_run_log(
f"本轮预检:可执行 {len(tasks)} 条{skip_status};缺失记录未进入本轮执行。"
)
def _load_latest_run_log(self):
try:
+1 -1
View File
@@ -464,7 +464,7 @@ data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新
### 6.3 应用更新(③ Tab)
- ③ 顶部筛选确定本次作用范围;点击「开始更新」后弹窗展示筛选条件、任务数量和“将提交线上”的风险提示。
- ③ 左下角提供「更新内容」下拉:`只更新标题` / `只更新封面` / `更新标题和封面`。点击「开始更新」或「检查本轮更新」时,先按当前模式检查当前筛选任务是否已经具备 `new_title` / `new_cover_path`;缺少所选内容时直接中文弹窗阻断,不创建 `ApplyWorker`、不打开 Chrome、不写失败状态。
- ③ 左下角提供「更新内容」下拉:`只更新标题` / `只更新封面` / `更新标题和封面`。点击「开始更新」或「检查本轮更新」时,先按当前模式分出具备 `new_title` / `new_cover_path` 的可执行任务与缺失记录;有可执行任务时缺失记录只在确认框和本轮日志中列为预检跳过,不创建其 `ApplyWorker` 执行项、不打开其 Chrome、不写其失败状态。仅当全部记录缺少所选内容时,才弹「更新内容未生成」并整体阻断。
- ③ 提供「检查本轮更新」按钮:只读取当前筛选结果和写运行日志,不打开 Shopee、不提交、不改任务状态;检查汇总展示总数、店铺分布、每批最大条数、预计批次数、更新内容和略过原因。
- 弹确认前先读取 `data/config.json` 的 `shopee_update` 执行参数。普通正式更新不再检查 `test_item_id` 或旧真实提交开关,当前筛选结果可以包含多个真实商品 ID。`max_items_per_run` 作为每批最大任务数,当前筛选总数超过该值时自动分批,不再按总数阻断。
- 用户在③确认弹窗点「是/确认」才开始批量更新;点「否/取消」不执行、不改库。
+5 -5
View File
@@ -449,7 +449,7 @@ main() -> int # 创建 QApplication + MainWindow
class MainWindow(QMainWindow) # QTabWidget: ①、②、③、账号管理、设置、商品套图;支持注入 db_path/config/config_path/ai_models_path 便于测试
class CollectTab(QWidget) # ① 导入采集:导入 Excel + 汇总栏 + QTableView 任务列表 + 未匹配略过标记
class GenerateTab(QWidget) # ② AI生成:提示词管理 + 筛选任务 + 生成封面图片成本开关 + 开始/停止生成 + 新旧封面预览 + AI生成运行日志
class ApplyTab(QWidget) # ③ 更新蝦皮:筛选已生成任务 + 检查本轮更新 + 缺失内容校验 + 确认后分批真实更新 + 运行日志
class ApplyTab(QWidget) # ③ 更新蝦皮:筛选已生成任务 + 缺失内容预检剔除 + 检查本轮更新 + 确认后分批真实更新 + 运行日志
class SettingsTab(QWidget) # 设置:cmhub 网关配置 + 响应式三列布局 + 角色/生成参数/路径端口 + 蝦皮更新安全 + 未保存状态追踪
class ProductSuiteTab(QWidget) # 商品套图:多任务、原图、结构配置、AI帮写、cmhub生成、历史结果
class ImageStudioTab(QWidget) # 旧AI工场兼容实现;主窗口不再创建
@@ -504,7 +504,7 @@ T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负
- 分辨率下拉固定 `512/1k/2k/4k`;普通默认 cmhub 模式下,返回超时标签只读展示实际等待口径「标题 600 秒 / 图片 900 秒」,分辨率只控制生成图片尺寸。direct 兼容路径仍使用 `resolution_timeouts[resolution]`。
- 路径与端口读写 `config.json`,并按 3 个组件一组排列:默认调试端口、调试端口范围、Chrome 就绪超时等短字段一格;Chrome 路径、账号数据根目录、图片目录、DB 路径等长字段跨整行或跨 2/3 列。保存时校验端口范围和默认端口。
- 蝦皮更新执行读写 `config.json` 的 `shopee_update` 段,并按 3 个组件一组排列:每批最大更新条数、同时更新蝦皮账号。`dry_run` 字段可保留为内部兼容,但普通用户界面不再展示 dry-run 开关,③ 使用「检查本轮更新」按钮触发检查模式;测试商品 ID 和旧封面开关仅作为历史/调试兼容字段读取,普通设置页无入口,保存后不再写回。
- 更新内容默认只更新标题。③ 左下角「更新内容」下拉选择只更新标题、只更新封面或更新标题和封面,开始前必须先通过缺失内容校验,并弹窗确认后才会创建更新 worker。
- 更新内容默认只更新标题。③ 左下角「更新内容」下拉选择只更新标题、只更新封面或更新标题和封面;开始前先按所选内容剔除缺失记录,仍有可执行记录时在确认框说明跳过项后创建更新 worker,全部缺失时才弹窗中止。
① 导入采集当前要点(T-202/T-202b):
@@ -540,14 +540,14 @@ T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负
③ 更新蝦皮当前要点(T-303b/T-401/T-402/T-403):
- `ApplyTab` 顶部筛选栏包含:批次、店铺、商品ID、状态、刷新。批次来自 `db.list_batches()`;店铺来自当前更新候选任务别名并优先显示匹配账号名;商品ID输入框按包含匹配 `item_id`,清空表示全部。
- ③ 只列出已生成或可查看的更新候选任务:`stage=generated/applied`,或已有新字段且 `status=failed/skipped` 的任务;真正开始更新前再按③「更新内容」模式校验是否缺少 `new_title` 或 `new_cover_path`。
- ③ 只列出已生成或可查看的更新候选任务:`stage=generated/applied`,或已有新字段且 `status=failed/skipped` 的任务;真正开始更新前再按③「更新内容」模式分出可执行任务与缺少 `new_title` / `new_cover_path` 的预检跳过记录。
- 状态筛选支持:已生成(默认,`stage=generated` 且 `status=success/pending`)、失败、已更新、略过、全部状态。
- 任务列表使用 `QTableView + ApplyTaskTableModel`,列为:店铺、商品ID、新标题、新封面、阶段、结果。
- 「开始更新」只读取当前筛选结果;无任务时只提示,不弹确认、不改库;该按钮是③的主操作,视觉上强于检查、停止和回写。
- 「检查本轮更新」只读取当前筛选结果并创建检查运行日志,不打开 Shopee、不调用 `editor.apply_task()`、不写任务状态、不回写 Excel;检查内容包含任务总数、店铺分布、每批最大条数、预计批次数、更新内容、会更新字段和略过原因。
- 点击「开始更新」先按③「更新内容」模式校验当前筛选任务:只更新标题必须有 `new_title`,只更新封面必须有 `new_cover_path`,更新标题和封面必须两者都有;缺失时弹「更新内容未生成」并阻断,不打开 Chrome、不写失败状态。再读取 `shopee_update` 执行参数:普通正式更新不再检查 `test_item_id` 或旧真实提交开关,当前筛选结果可包含多个真实商品 ID;`max_items_per_run` 作为每批最大更新条数,当前筛选结果超过该值时自动分批。通过后才弹窗展示批次/店铺/商品ID/状态/更新内容/任务总数、每批最大条数、预计批次数、提交线上风险和当前执行设置。
- 点击「开始更新」先按③「更新内容」模式预检当前筛选任务:只更新标题要求 `new_title`,只更新封面要求 `new_cover_path`,更新标题和封面要求两者都有。部分缺失时将这些记录从本轮 `ApplyWorker` 任务中剔除,在确认框、状态栏摘要和本轮日志列出缺标题/缺封面条数与示例商品ID;不打开其 Chrome、不写其失败状态或 Excel。只有全部记录缺失时才弹「更新内容未生成」并阻断。再读取 `shopee_update` 执行参数:普通正式更新不再检查 `test_item_id` 或旧真实提交开关,当前筛选结果可包含多个真实商品 ID;`max_items_per_run` 作为每批最大更新条数,当前可执行任务超过该值时自动分批。通过后才弹窗展示批次/店铺/商品ID/状态/更新内容/可执行任务数、预检跳过项、每批最大条数、预计批次数、提交线上风险和当前执行设置。
- 用户点否/取消时不执行、不改库;用户点是后才创建 `ApplyWorker` 做真实提交。
- `ApplyWorker` 只处理当前筛选结果里 `stage=generated`、状态为 `success/pending/failed`,且满足当前 `update_mode` 所需内容的任务;只更新标题时不替换封面,只更新封面时不改标题。已更新和略过记录仅查看,不会再次提交,除非用户先用 T-404a 的「重置更新状态」把选中记录退回可更新。
- `ApplyWorker` 只处理经过 `ApplyTab` 预检后的任务:原候选范围为 `stage=generated`、状态为 `success/pending/failed`,缺少当前 `update_mode` 所需内容的记录不传入 worker;只更新标题时不替换封面,只更新封面时不改标题。预检跳过记录保持原 stage/status/Excel,不被计入本轮 worker 的成功、略过或失败统计。已更新和略过记录仅查看,不会再次提交,除非用户先用 T-404a 的「重置更新状态」把选中记录退回可更新。
- 检查本轮更新:不做账号登录预检,不调用 `editor.apply_task()`,不写任务状态,不回写 Excel;只把每条“将更新/将略过”写入运行日志并弹汇总。
- 真实更新前先做账号就绪预检:无账号、当前筛选结果匹配账号 Chrome 未启动、CDP 端口不可访问、未登录,或本轮涉及账号调试端口冲突时,返回 `blocked=True`,GUI 弹窗汇总并跳转/引导去账号管理;预检不通过时不调用 `editor.apply_task()`、不写失败状态、不自动启动 Chrome。
- 预检通过后默认串行;若 `max_parallel_accounts>1`,按账号分组并行执行,不同账号可同时跑,同一账号内仍串行。每条执行 `db.mark_running(..., "apply")` → `editor.apply_task(account, task)` → `db.set_applied()`;成功推进 `stage=applied/status=success/committed=1`,失败保持原 stage、`status=failed/committed=0/last_error`,单条失败继续下一条。
+4 -4
View File
@@ -129,9 +129,9 @@
- 顶部**按批次 / 店铺 / 商品ID / 状态筛选**(与 ①②一致);商品ID输入框按包含匹配 `item_id`,清空表示全部;「开始更新」作用于**当前筛选结果**,是一道范围控制。
- 店铺筛选:建议**逐店铺更新**(每店铺需先启动其 Chrome 并登录)。
- 状态筛选:`已生成` 只跑未更新的;`失败` 用于**失败重试**;`已更新成功/略过` 仅查看。
- 「更新内容」下拉支持只更新标题、只更新封面、更新标题和封面;点击「检查本轮更新」或「开始更新」前先按当前模式检查 `new_title` / `new_cover_path`,缺失时中文弹窗阻断,不打开 Chrome、不改任务状态。
- 「更新内容」下拉支持只更新标题、只更新封面、更新标题和封面;点击「检查本轮更新」或「开始更新」前先按当前模式检查 `new_title` / `new_cover_path`。有合格记录时,缺失记录从本轮 worker 任务中剔除,确认框与日志列出缺标题/缺封面条数和示例商品ID;仅全部记录缺失时才中文弹窗阻断,不打开 Chrome、不改任务状态。
- 「检查本轮更新」只读取当前筛选结果并写运行日志,不打开 Shopee、不提交、不改任务状态;弹窗/日志展示总任务数、店铺分布、当前更新内容、会更新标题/封面、略过原因、每批最大条数和预计批次数。
- 缺失内容校验通过后,点击「开始更新」读取设置中的 `shopee_update` 执行设置:普通正式更新不再以测试商品 ID 或旧真实提交开关限制当前筛选结果,允许当前筛选结果包含多个真实商品 ID;`max_items_per_run` 作为**每批最大更新条数**,当前筛选总数超过该值时不阻断,而是自动分批执行。
- 完成缺失内容预检并剔除不合格记录后,点击「开始更新」读取设置中的 `shopee_update` 执行设置:普通正式更新不再以测试商品 ID 或旧真实提交开关限制当前筛选结果,允许当前筛选结果包含多个真实商品 ID;`max_items_per_run` 作为**每批最大更新条数**,当前可执行任务超过该值时不阻断,而是自动分批执行。
- 弹窗展示本次筛选条件、更新内容、任务总数、每批最大条数、预计批次数、执行设置和“将提交线上”的风险提示;用户点「是/确认」才开始,点「否/取消」不执行。
- 真实更新第一条商品前做账号就绪预检:按当前筛选结果汇总需要的账号;无账号、Chrome 未启动、CDP 端口不可访问、未登录或端口冲突时,弹窗列出具体账号/原因并中止本轮,不自动调用「启动登录」或静默打开 Chrome。
- 对确认后的**已生成(generated)任务**按当前更新内容执行:打开编辑页换标题和/或换封面 → 点页面「更新」 → 如 Shopee 弹出“確定您要更新商品嗎?”确认框(`.eds-modal__content` / `.eds-modal__box`),则只点弹窗主按钮「更新」提交,不点「立即優化」。
@@ -178,7 +178,7 @@
- 路径与端口(T-501b/T-506/T-539/T-580 已接入):组件组改为 3 个组件一组;普通设置页只显示 Chrome 路径、默认端口、端口起止、Chrome 就绪超时。T-538 后账号数据根目录、图片目录、DB 路径固定解析到 `data/` 下,普通 UI 不再提供输入框,避免用户误改后数据分裂;`config.json` 中 `user_data_root` / `image_dir` / `db_path` 字段继续作为内部兼容字段保留,手工配置值仍会被读取和保存。
- 蝦皮更新执行(T-580 已接入):组件组改为 3 个组件一组;普通设置页只保留「每批最大更新条数」和「同时更新蝦皮账号(1..5)」两个执行参数。`1` 表示逐个账号串行,`2..5` 表示按账号分组并行;“dry-run”不作为用户可见开关,改到③成为「检查本轮更新」按钮;测试商品 ID 和旧封面开关仅作为历史/调试兼容字段读取,普通设置页无入口,保存后不再写回。
- ③「更新内容」默认只更新标题,每批最大更新条数默认 1,同时更新蝦皮账号默认 1。
- ③ 点击「开始更新」会先按「更新内容」校验缺失内容,再弹确认框。
- ③ 点击「开始更新」会先按「更新内容」剔除缺失内容记录并在确认框说明;仅无可执行记录时中止。
## 商品套图
@@ -242,7 +242,7 @@
| `MainWindow(QMainWindow)` | 根窗口 | 持有 `QTabWidget`、状态栏、全局消息 |
| `CollectTab(QWidget)` | ① | 导入、任务表、采集、回写 |
| `GenerateTab(QWidget)` | ② | 左提示词管理 + 右筛选/任务列表;双击看新旧封面;开始生成/停止/进度;本轮「生成内容」下拉接入 `GenerateWorker` |
| `ApplyTab(QWidget)` | ③ | 已生成任务筛选 +「更新内容」下拉 + 缺失内容阻断 +「检查本轮更新」+ 分批开始更新确认 + 检查/真实更新运行日志 + 结果回写与结束汇总 |
| `ApplyTab(QWidget)` | ③ | 已生成任务筛选 +「更新内容」下拉 + 缺失内容预检剔除 +「检查本轮更新」+ 分批开始更新确认 + 检查/真实更新运行日志 + 结果回写与结束汇总 |
| `AccountsTab(QWidget)` | 账号管理 | 账号增删改、启动登录、检测登录、生成快捷方式;首次启动复用初始卖家中心页,启动中防重复触发,登录检测把 Shopee accounts 登录页判为未登录 |
| `SettingsTab(QWidget)` | 设置 | cmhub 网关配置 + 响应式三列设置表单 + 生成参数 + Chrome/端口配置 + 蝦皮更新执行;数据路径字段隐藏但保留配置兼容 |
| `ProductSuiteTab(QWidget)` | 商品套图 | 商品套图多任务、账号+商品ID上下文、原图导入/排序、套图分类、AI帮写、cmhub 异步生成、结果历史与删除撤销 |
+5 -2
View File
@@ -1,7 +1,7 @@
---
id: T-659
title: 更新蝦皮批次内容预检改为剔除跳过不中止整批
status: TODO
status: DONE
phase: 7
deps: []
created: 2026-07-17
@@ -80,4 +80,7 @@ git diff --check
## 执行记录
- 待实现。
- 2026-07-17:`ApplyTab` 新增按更新模式划分可执行任务、缺少新标题任务和缺少新封面任务的预检;有可执行任务时只将其交给 `ApplyWorker`,缺失记录不改 SQLite、Excel 或运行状态。
- 2026-07-17:正式更新和「检查本轮更新」共用该预检;确认框显示跳过条数、缺失类型和示例商品ID,状态栏与本轮日志显示可执行/跳过摘要。全部任务都缺失时仍沿用「更新内容未生成」弹窗中止。
- 2026-07-17:补充三种更新模式的剔除口径、正式更新部分跳过和检查模式部分跳过回归测试;同步架构、API 和界面流程文档。
- 验证通过:`py -3.10 -m unittest tests.test_gui`(190 项)、`py -3.10 -m unittest discover -s tests`(591 项)、`py -3.10 -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`git diff --check`。离屏 GUI 测试有 Qt 字体/插件告警,但无测试失败。
+152
View File
@@ -6647,6 +6647,158 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir)
def test_apply_tab_partitions_missing_content_by_update_mode(self):
with self.make_temp_dir() as temp_dir:
tab = ApplyTab(config=self.make_config(temp_dir))
self.addCleanup(tab.close)
tasks = [
SimpleNamespace(id=1, item_id="51100639510", new_title="新标题", new_cover_path="new.jpg"),
SimpleNamespace(id=2, item_id="51100639511", new_title="新标题", new_cover_path=None),
SimpleNamespace(id=3, item_id="51100639512", new_title=None, new_cover_path="new.jpg"),
SimpleNamespace(id=4, item_id="51100639513", new_title=None, new_cover_path=None),
]
title_plan = tab._partition_update_content_tasks(tasks, "title")
self.assertEqual([1, 2], [task.id for task in title_plan["executable"]])
self.assertEqual([3, 4], [task.id for task in title_plan["missing_title"]])
self.assertEqual([], title_plan["missing_cover"])
cover_plan = tab._partition_update_content_tasks(tasks, "cover")
self.assertEqual([1, 3], [task.id for task in cover_plan["executable"]])
self.assertEqual([], cover_plan["missing_title"])
self.assertEqual([2, 4], [task.id for task in cover_plan["missing_cover"]])
title_cover_plan = tab._partition_update_content_tasks(tasks, "title_cover")
self.assertEqual([1], [task.id for task in title_cover_plan["executable"]])
self.assertEqual([3, 4], [task.id for task in title_cover_plan["missing_title"]])
self.assertEqual([2, 4], [task.id for task in title_cover_plan["missing_cover"]])
self.assert_removed(temp_dir)
def test_apply_tab_skips_missing_content_but_starts_eligible_update(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",
},
{
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
"source_sheet": "商品",
"source_row": 4,
"account_name": "Excel主店",
"alias": "alias-a",
"item_id": "51100639512",
},
],
path=cfg["db_path"],
)
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
for task in tasks:
db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"])
db.set_generated(tasks[0].id, "新标题", "new.jpg", path=cfg["db_path"])
db.set_generated(tasks[1].id, "新标题", None, path=cfg["db_path"])
db.set_generated(tasks[2].id, None, "new.jpg", path=cfg["db_path"])
statuses = []
tab = ApplyTab(config=cfg, status_callback=statuses.append)
self.addCleanup(tab.close)
tab.update_mode_combo.setCurrentIndex(tab.update_mode_combo.findData("title_cover"))
thread = FakeThread()
with mock.patch("app.gui.QMessageBox.question", return_value=gui.QMessageBox.Yes) as question, \
mock.patch("app.gui.QMessageBox.warning") as warning, \
mock.patch("app.gui.run_worker", return_value=thread):
tab.start_update()
warning.assert_not_called()
message = question.call_args[0][2]
self.assertIn("任务数:1", message)
self.assertIn("本轮将跳过 2 条缺少所选更新内容的记录", message)
self.assertIn("缺少新标题:1 条(示例商品ID:51100639512)", message)
self.assertIn("缺少新封面:1 条(示例商品ID:51100639511)", message)
self.assertEqual([tasks[0].id], [task.id for task in tab.apply_worker.tasks])
self.assertTrue(thread.started)
self.assertIn("跳过 2 条(缺标题 1 / 缺封面 1)", statuses[-1])
self.assertIn("缺失记录未进入本轮执行", tab.run_log_view.toPlainText())
unchanged = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
self.assertTrue(all(task.stage == "generated" for task in unchanged))
self.assertTrue(all(task.status == "success" for task in unchanged))
self.assert_removed(temp_dir)
def test_apply_tab_preview_uses_same_missing_content_skip_rule(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"],
)
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
for task in tasks:
db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"])
db.set_generated(tasks[0].id, "新标题", "new.jpg", path=cfg["db_path"])
db.set_generated(tasks[1].id, None, "new.jpg", path=cfg["db_path"])
statuses = []
tab = ApplyTab(config=cfg, status_callback=statuses.append)
self.addCleanup(tab.close)
tab.update_mode_combo.setCurrentIndex(tab.update_mode_combo.findData("title"))
thread = FakeThread()
with mock.patch("app.gui.QMessageBox.question", return_value=gui.QMessageBox.Yes) as question, \
mock.patch("app.gui.QMessageBox.warning") as warning, \
mock.patch("app.gui.run_worker", return_value=thread):
tab.preview_update()
warning.assert_not_called()
message = question.call_args[0][2]
self.assertIn("本轮将跳过 1 条缺少所选更新内容的记录", message)
self.assertIn("缺少新标题:1 条(示例商品ID:51100639511)", message)
self.assertEqual([tasks[0].id], [task.id for task in tab.apply_worker.tasks])
self.assertTrue(tab.apply_worker.dry_run)
self.assertIn("开始检查本轮更新:1 条,跳过 1 条(缺标题 1 / 缺封面 0)", statuses[-1])
unchanged = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
self.assertTrue(all(task.stage == "generated" for task in unchanged))
self.assertTrue(all(task.status == "success" for task in unchanged))
self.assert_removed(temp_dir)
def test_apply_tab_allows_cover_only_result_but_blocks_modes_that_need_title(self):
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)