From 3bc1776d0789a5f16ec72757ea54ebe0fdbda392 Mon Sep 17 00:00:00 2001 From: chengma Date: Sat, 27 Jun 2026 17:28:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90T-403=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=E5=9B=9E=E5=86=99=E4=B8=8E=E6=B1=87=E6=80=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增excel.write_back_results,按原Excel文件、工作表和行号回写新标题、新封面图片路径与更新状态。 ③更新完成后自动用WriteBackWorker(mode=results)回写结果,并弹窗汇总成功、失败、略过数量;文件被占用时提示关闭Excel后手动重试。 补充Excel结果回写、GUI自动回写/汇总/锁文件提示和模块契约测试,同步任务看板、API、流程和当前状态文档。 --- app/excel.py | 60 ++++++++- app/gui.py | 215 +++++++++++++++++++++++++++++++-- docs/06-tasks.md | 2 +- docs/api.md | 16 ++- docs/current-state.md | 18 +-- docs/routes.md | 8 +- progress.md | 10 ++ tests/test_excel.py | 49 ++++++++ tests/test_gui.py | 115 +++++++++++++++++- tests/test_module_contracts.py | 2 +- 10 files changed, 465 insertions(+), 30 deletions(-) diff --git a/app/excel.py b/app/excel.py index 6acd8a9..acae347 100644 --- a/app/excel.py +++ b/app/excel.py @@ -32,7 +32,10 @@ OUTPUT_HEADERS = { "update_status": "更新状态", } OLD_WRITE_BACK_FIELDS = ("old_title", "old_cover_path") +RESULT_WRITE_BACK_FIELDS = ("new_title", "new_cover_path", "update_status") WRITEABLE_STAGES = {"collected", "generated", "applied"} +RESULT_WRITEABLE_STAGES = {"generated", "applied"} +RESULT_WRITEABLE_STATUSES = {"failed", "skipped", "cancelled"} LOCK_WINERRORS = {5, 32, 33} LOCK_ERRNOS = {13, 16} @@ -310,7 +313,21 @@ def _filter_tasks_by_excel_path(tasks, excel_path): return filtered +def _is_result_fields(fields) -> bool: + return any(field in RESULT_WRITE_BACK_FIELDS for field in fields) + + def _has_write_back_data(task, fields) -> bool: + if _is_result_fields(fields): + if getattr(task, "stage", None) in RESULT_WRITEABLE_STAGES: + return True + if getattr(task, "status", None) in RESULT_WRITEABLE_STATUSES: + return True + return any( + getattr(task, field, None) not in (None, "") + for field in ("new_title", "new_cover_path") + if field in fields + ) if getattr(task, "stage", None) in WRITEABLE_STAGES: return True return any(getattr(task, field, None) not in (None, "") for field in fields) @@ -367,6 +384,32 @@ def _ensure_output_columns(sheet, fields): return columns +def _update_status(task) -> str: + error = getattr(task, "last_error", None) + status = getattr(task, "status", None) + stage = getattr(task, "stage", None) + committed = bool(getattr(task, "committed", 0)) + if stage == "applied" and committed: + return "成功" + if status == "failed": + return f"失败:{error}" if error else "失败" + if status == "skipped": + return f"略过:{error}" if error else "略过" + if status == "running": + return "处理中" + if status == "cancelled": + return f"已取消:{error}" if error else "已取消" + if stage == "generated": + return "待更新" + return "" + + +def _field_value(task, field): + if field == "update_status": + return _update_status(task) + return getattr(task, field, None) or "" + + def _write_tasks_to_workbook(workbook, tasks, fields) -> int: rows = 0 missing_sheets = [] @@ -387,7 +430,7 @@ def _write_tasks_to_workbook(workbook, tasks, fields) -> int: sheet.cell( row=int(task.source_row), column=columns[field], - ).value = getattr(task, field, None) or "" + ).value = _field_value(task, field) rows += 1 if missing_sheets: @@ -450,6 +493,21 @@ def write_back(batch_id, excel_path=None, path=None) -> dict: ) +def write_back_results(batch_id, excel_path=None, path=None) -> dict: + """Write generated title/cover and update status back to the original Excel file.""" + + _require_openpyxl() + fields = RESULT_WRITE_BACK_FIELDS + tasks = _filter_tasks_by_excel_path(_batch_tasks(batch_id, path=path), excel_path) + groups = _group_by_source_file(tasks, fields) + return _write_result( + batch_id, + groups, + lambda source_path: source_path, + fields, + ) + + def _is_excel_output_path(path_value) -> bool: return _excel_suffix(path_value) in {".xlsx", ".xlsm"} diff --git a/app/gui.py b/app/gui.py index d398cfa..7197b55 100644 --- a/app/gui.py +++ b/app/gui.py @@ -966,6 +966,9 @@ if QT_IMPORT_ERROR is None: self.open_accounts_callback = open_accounts_callback self.apply_worker = None self.apply_thread = None + self.result_write_back_worker = None + self.result_write_back_thread = None + self.last_apply_summary = None self.batch_filter = QComboBox() self.batch_filter.setObjectName("applyBatchFilter") @@ -1023,6 +1026,7 @@ if QT_IMPORT_ERROR is None: self.refresh_button.clicked.connect(self.refresh_tasks) self.start_update_button.clicked.connect(self.start_update) self.stop_update_button.clicked.connect(self.stop_update) + self.write_back_button.clicked.connect(self.write_back_results) self.refresh_tasks() @@ -1060,6 +1064,7 @@ if QT_IMPORT_ERROR is None: self.summary_label.setText( f"任务 {len(filtered_tasks)}/{len(batch_tasks)} 条" ) + self._update_write_back_button() def start_update(self, checked=False): if self.apply_thread is not None: @@ -1102,6 +1107,13 @@ if QT_IMPORT_ERROR is None: self.apply_worker.cancel() self._set_status("正在停止更新...") + def write_back_results(self, checked=False): + batch_ids = self._active_batch_ids() + if not batch_ids: + self._set_status("没有可回写结果的批次") + return + self._start_result_write_back(batch_ids, auto=False) + def _is_actionable_task(self, task): return ( getattr(task, "stage", None) == "generated" @@ -1199,13 +1211,27 @@ if QT_IMPORT_ERROR is None: self.batch_filter.setEnabled(not running) self.shop_filter.setEnabled(not running) self.status_filter.setEnabled(not running) - self.write_back_button.setEnabled(False) + self._update_write_back_button() + + def _set_result_write_back_running(self, running): + self.start_update_button.setEnabled(not running) + self.refresh_button.setEnabled(not running) + self.batch_filter.setEnabled(not running) + self.shop_filter.setEnabled(not running) + self.status_filter.setEnabled(not running) + self.write_back_button.setEnabled(False if running else bool(self._active_batch_ids())) def _forget_apply_thread(self, thread): if self.apply_thread is thread: self.apply_thread = None self.apply_worker = None + def _forget_result_write_back_thread(self, thread): + if self.result_write_back_thread is thread: + self.result_write_back_thread = None + self.result_write_back_worker = None + self._update_write_back_button() + def _on_apply_progress(self, payload): self._set_status("更新进度:" + self._apply_progress_text(payload)) @@ -1221,7 +1247,19 @@ if QT_IMPORT_ERROR is None: if payload.get("blocked"): self._show_apply_blocked(payload) return - self._set_status("更新完成:" + self._apply_progress_text(payload)) + self.last_apply_summary = dict(payload) + message = "更新完成:" + self._apply_progress_text(payload) + batch_ids = payload.get("batch_ids") or self._active_batch_ids() + if payload.get("done", 0) > 0 and batch_ids: + if self._start_result_write_back( + batch_ids, + auto=True, + apply_summary=payload, + ): + self._set_status(f"{message},正在自动回写结果到 Excel...") + return + self._set_status(message) + self._show_apply_summary(payload) def _on_apply_cancelled(self, payload): self._set_apply_running(False) @@ -1277,6 +1315,121 @@ if QT_IMPORT_ERROR is None: label = f"{name}({alias})" if alias and name != alias else (name or alias) return f"{label}: {reason}" if reason else label + def _active_batch_ids(self): + selected_batch = self.batch_filter.currentData() + if selected_batch: + return [selected_batch] + batch_ids = [] + for task in self.model.tasks: + batch_id = getattr(task, "batch_id", None) + if batch_id and batch_id not in batch_ids: + batch_ids.append(batch_id) + return batch_ids + + def _update_write_back_button(self): + if getattr(self, "write_back_button", None) is None: + return + enabled = ( + self.apply_thread is None + and self.result_write_back_thread is None + and bool(self._active_batch_ids()) + ) + self.write_back_button.setEnabled(enabled) + + def _start_result_write_back(self, batch_ids, auto=False, apply_summary=None): + if self.result_write_back_thread is not None: + self._set_status("Excel 结果回写正在进行...") + return False + worker = WriteBackWorker(batch_ids, db_path=self.db_path, mode="results") + worker.failed.connect( + lambda task_id, error, auto=auto, apply_summary=apply_summary: + self._on_result_write_back_failed( + task_id, + error, + auto=auto, + apply_summary=apply_summary, + ) + ) + worker.finished.connect( + lambda payload, auto=auto, apply_summary=apply_summary: + self._on_result_write_back_finished( + payload, + auto=auto, + apply_summary=apply_summary, + ) + ) + thread = run_worker(worker, thread_name="ResultWriteBackWorker", start=False) + thread.finished.connect(lambda: self._forget_result_write_back_thread(thread)) + self.result_write_back_worker = worker + self.result_write_back_thread = thread + self._set_result_write_back_running(True) + self._set_status("正在自动回写更新结果到 Excel..." if auto else "正在回写更新结果到 Excel...") + thread.start() + return True + + def _on_result_write_back_failed(self, task_id, error, auto=False, apply_summary=None): + message = f"Excel {'自动' if auto else ''}回写更新结果失败:{error}" + if "被占用" in str(error): + message += "\n请关闭原 Excel 后点击「回写结果到 Excel」手动重试;SQLite 已保留更新结果。" + if auto and apply_summary: + message = self._apply_summary_message(apply_summary, error=message) + QMessageBox.warning(self, "回写结果到 Excel", message) + self._set_status(message.replace("\n", " ")) + + def _on_result_write_back_finished(self, payload, auto=False, apply_summary=None): + self._set_result_write_back_running(False) + self.refresh_tasks() + if payload.get("ok") is False: + error = payload.get("error") or "未知错误" + retry_hint = ",可点击「回写结果到 Excel」手动重试" if auto else "" + self._set_status(f"Excel {'自动' if auto else ''}回写更新结果失败:{error}{retry_hint}") + return + self._set_status( + "Excel {prefix}回写更新结果完成:文件{files},行{rows}".format( + prefix="自动" if auto else "", + files=payload.get("files", 0), + rows=payload.get("rows", 0), + ) + ) + if auto and apply_summary: + self._show_apply_summary(apply_summary, write_back_payload=payload) + elif not auto: + QMessageBox.information( + self, + "回写结果到 Excel", + "结果回写完成:文件{files},行{rows}".format( + files=payload.get("files", 0), + rows=payload.get("rows", 0), + ), + ) + + def _show_apply_summary(self, apply_summary, write_back_payload=None): + QMessageBox.information( + self, + "更新完成", + self._apply_summary_message(apply_summary, write_back_payload), + ) + + def _apply_summary_message(self, apply_summary, write_back_payload=None, error=None): + lines = [ + "更新完成。", + "成功:{applied},失败:{failed},略过:{skipped}".format( + applied=apply_summary.get("applied", 0), + failed=apply_summary.get("failed", 0), + skipped=apply_summary.get("skipped", 0), + ), + ] + if write_back_payload: + lines.append( + "Excel 回写:文件{files},行{rows}".format( + files=write_back_payload.get("files", 0), + rows=write_back_payload.get("rows", 0), + ) + ) + if error: + lines.append(str(error)) + return "\n".join(lines) + class CollectTab(QWidget): """Tab 1: import Excel files and list imported tasks.""" @@ -1827,6 +1980,7 @@ if QT_IMPORT_ERROR is None: if str(account.alias).strip() } eligible = [task for task in self.tasks if self._is_actionable_task(task)] + batch_ids = self._batch_ids(eligible) total = len(eligible) applied = 0 skipped = 0 @@ -1845,6 +1999,7 @@ if QT_IMPORT_ERROR is None: "applied": 0, "skipped": 0, "failed": 0, + "batch_ids": batch_ids, } ) return blocked @@ -1909,6 +2064,7 @@ if QT_IMPORT_ERROR is None: "applied": applied, "skipped": skipped, "failed": failed, + "batch_ids": batch_ids, } def _is_actionable_task(self, task): @@ -1985,6 +2141,14 @@ if QT_IMPORT_ERROR is None: reason = status.get("reason") return f"账号未登录: {reason}" if reason else "账号未登录" + def _batch_ids(self, tasks): + batch_ids = [] + for task in tasks: + batch_id = getattr(task, "batch_id", None) + if batch_id and batch_id not in batch_ids: + batch_ids.append(batch_id) + return batch_ids + class CollectWorker(BaseWorker): """Collect old title and cover for imported tasks.""" @@ -2176,20 +2340,21 @@ if QT_IMPORT_ERROR is None: class WriteBackWorker(BaseWorker): - """Write collected old fields back to Excel in a background thread.""" + """Write Excel fields back in a background thread.""" - def __init__(self, batch_id, db_path=None, excel_path=None): + def __init__(self, batch_id, db_path=None, excel_path=None, mode="old"): super().__init__() self.batch_id = batch_id self.db_path = db_path self.excel_path = excel_path + self.mode = mode def execute(self): - result = excel.write_back( - self.batch_id, - excel_path=self.excel_path, - path=self.db_path, - ) + results = [ + self._write_one(batch_id) + for batch_id in self._batch_ids() + ] + result = results[0] if len(results) == 1 else self._combined_result(results) self.progress.emit( { "done": result.get("rows", 0), @@ -2199,6 +2364,38 @@ if QT_IMPORT_ERROR is None: ) return result + def _batch_ids(self): + if isinstance(self.batch_id, (list, tuple, set)): + return list(self.batch_id) + return [self.batch_id] + + def _write_one(self, batch_id): + if self.mode == "results": + return excel.write_back_results( + batch_id, + excel_path=self.excel_path, + path=self.db_path, + ) + return excel.write_back( + batch_id, + excel_path=self.excel_path, + path=self.db_path, + ) + + def _combined_result(self, results): + written_files = [] + for result in results: + for file_path in result.get("written_files", []): + if file_path not in written_files: + written_files.append(file_path) + return { + "ok": all(result.get("ok", False) for result in results), + "batch_id": [result.get("batch_id") for result in results], + "files": sum(result.get("files", 0) for result in results), + "rows": sum(result.get("rows", 0) for result in results), + "written_files": written_files, + } + class AccountLoginCheckWorker(BaseWorker): def __init__(self, account, db_path=None, config=None, timeout=8): diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 69c92c6..9b7792e 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -71,7 +71,7 @@ | --- | --- | --- | --- | --- | | T-401 | Tab③ 列出已生成任务 + 按批次/店铺/状态筛选 + 开始更新确认弹窗 | T-303 | 顶部批次/店铺/状态筛选;「开始更新」仅作用于当前筛选结果;弹窗显示筛选条件/任务数/线上提交风险;取消不执行;状态=失败可重试;无常驻提交开关 | DONE | | T-402 | 串行执行 apply:批量确认后换标题+换封面+点更新提交,单条失败继续,立即写库 | T-401, T-001, T-104b | 确认后 worker 串行执行并逐条 set_applied;失败继续;未登录引导保护;未确认时不调用 apply | DONE | -| T-403 | 回写结果到原 Excel(新标题/新封面/更新状态)+ 结束弹窗汇总 | T-402, T-204 | 回写原文件(锁处理);弹窗 成功/失败/略过 | TODO | +| T-403 | 回写结果到原 Excel(新标题/新封面/更新状态)+ 结束弹窗汇总 | T-402, T-204 | 回写原文件(锁处理);弹窗 成功/失败/略过 | DONE | ## Phase 5 · 设置与收尾 diff --git a/docs/api.md b/docs/api.md index e75c06c..b84d571 100644 --- a/docs/api.md +++ b/docs/api.md @@ -88,7 +88,7 @@ SQLite 连接规则: - `connect()` 必须设置 `PRAGMA foreign_keys=ON`、`journal_mode=WAL`、`busy_timeout=5000`、`synchronous=NORMAL`。 - DB 写入短事务、单条提交;Excel 回写失败不回滚 DB。 -## excel 模块(`app/excel.py`,导入与旧字段回写已建;结果回写待 T-403,依赖 openpyxl) +## excel 模块(`app/excel.py`,导入与回写已建,依赖 openpyxl) ```python class ExcelError(RuntimeError): ... @@ -121,9 +121,13 @@ write_back(batch_id, excel_path=None, path=None) -> dict # 只写 stage 已到 collected/generated/applied 或已有旧字段值的任务;无可写任务时返回 rows=0 # 原文件被占用(锁) → 抛 ExcelError,调用方提示“请关闭后重试”,或改用 export_copy # -> {"ok": True, "batch_id": str, "files": int, "rows": int, "written_files": [abs_path, ...]} +write_back_results(batch_id, excel_path=None, path=None) -> dict +# 把【新标题/新封面图片路径/更新状态】批量回写到【原 Excel】 +# 更新状态:成功 / 失败:原因 / 略过:原因 / 待更新 +# 原文件被占用(锁) → 抛 ExcelError;SQLite 更新结果不回滚,GUI 提示关闭后重试 +# -> {"ok": True, "batch_id": str, "files": int, "rows": int, "written_files": [abs_path, ...]} export_copy(batch_id, out_dir_or_path, path=None) -> dict # 退路:另存带旧字段的副本,不动原文件;目录输出时生成 *_cmshopee回写.xlsx -# T-403 会在此基础上扩展新标题/新封面/更新状态回写。 ``` 列模板见 [架构 5.3](04-architecture.md);别名以“别名”列为权威,必须与 ④ 账号管理中的账号别名一致。`shopee待处理任务模板.xlsx` 是可提交的标准空模板;运营填写后的 Excel 副本属于业务数据,不提交。 @@ -296,7 +300,7 @@ class ApplyTab(QWidget) # ③ 更新shopee:筛选已生成 class CollectWorker(BaseWorker) # ① 后台采集:账号就绪预检 -> editor.collect -> db.set_collected/mark_skipped/mark_failed class GenerateWorker(BaseWorker) # ② 后台生成:ai.generate_batch -> db.set_generated/mark_failed + 进度 class ApplyWorker(BaseWorker) # ③ 后台更新:账号就绪预检 -> editor.apply_task -> db.set_applied/mark_skipped -class WriteBackWorker(BaseWorker) # ① 后台回写:excel.write_back(batch_id) 写旧标题/旧封面到原 Excel +class WriteBackWorker(BaseWorker) # ①/③ 后台回写:旧字段或更新结果写回原 Excel class TaskTableModel(QAbstractTableModel) # 任务表格模型:账号/别名/商品ID/阶段;未匹配别名显示“略过” class GenerateTaskTableModel(QAbstractTableModel) # ② 任务表格模型:店铺/商品ID/旧标题/新标题/状态 class ApplyTaskTableModel(QAbstractTableModel) # ③ 任务表格模型:店铺/商品ID/新标题/新封面/阶段/结果 @@ -343,7 +347,7 @@ TAB_STYLE: str # 顶层 Tab 栏防误点样式: - 「停止」调用 worker 的协作式 `cancel()`;未开始的 Future 取消,不记失败;已完成的 `db.set_generated()` 结果保留,可再次开始生成剩余任务。 - 进度标签显示:`标题x/n · 封面x/n · 失败z`;每条封面生成成功后立即 `db.set_generated()`,失败 `db.mark_failed(..., "generate", error)`,GUI 刷新任务表。 -③ 更新shopee当前要点(T-401/T-402): +③ 更新shopee当前要点(T-401/T-402/T-403): - `ApplyTab` 顶部筛选栏包含:批次、店铺、状态、刷新。批次来自 `db.list_batches()`;店铺来自当前更新候选任务别名并优先显示匹配账号名。 - ③ 只列出已具备新标题/新封面、可进入更新阶段的任务:`stage=generated/applied`,或已有新字段且 `status=failed/skipped` 的任务。 @@ -356,7 +360,8 @@ TAB_STYLE: str # 顶层 Tab 栏防误点样式: - 预检通过后逐条 `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`,单条失败继续下一条。 - 别名未匹配账号的任务逐条 `db.mark_skipped()`,原因 `别名未匹配账号`;「停止」调用 worker 协作式 `cancel()`,已开始单条跑到安全边界后结束。 - ③ 没有常驻提交开关;确认弹窗是提交线上前的边界。 -- 更新结果回写原 Excel 留给 T-403。 +- 更新完成后自动调用 `WriteBackWorker(mode="results")` → `excel.write_back_results()`,把新标题、新封面路径、更新状态写回原 Excel;原文件被占用时提示关闭后点击「回写结果到 Excel」手动重试,SQLite 更新结果不回滚。 +- 自动结果回写完成后弹窗汇总成功/失败/略过数量与 Excel 回写文件/行数;若没有可回写批次,也会弹出更新汇总。 ## workers 模块(`app/workers.py`,已建,PySide6) @@ -391,6 +396,7 @@ run_worker(worker: BaseWorker, thread_name=None, start=True) -> QThread - 每个 worker/线程按需创建自己的 SQLite connection,不跨线程共享连接。 - ③ 的批量确认弹窗在 GUI 主线程完成;用户确认后才创建 `ApplyWorker`。 - `ApplyWorker` 串行调用 `editor.apply_task()`,逐条 `set_applied()`,失败继续;账号未就绪时整体阻断并引导④,不进入逐条提交。 +- `WriteBackWorker` 默认 `mode="old"` 回写旧字段;③ 使用 `mode="results"` 回写新标题/新封面/更新状态,支持单批次或多批次列表。 - `execute()` 未捕获异常会发 `failed(-1, error)` 与 `finished({"ok": False, "error": ...})`;普通单行失败由业务 worker 自己发 `failed(task_id, error)` 后继续处理。 ## 启动入口 diff --git a/docs/current-state.md b/docs/current-state.md index 617f69b..5619ca6 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -6,10 +6,10 @@ ## 当前快照 - 日期:2026-06-27 -- 阶段:V0 单账号 CDP 流程已验证;V1 已完成 T-000 正式代码包结构、T-001 `app/editor.py` 模块化、T-002 `app/appconfig.py` 应用配置、T-003 SQLite 持久化地基、T-004 本地数据忽略规则、T-005 AI 模型清单后端、T-006 单元测试基座、T-101 账号 user-data-dir 工具、T-102 Chrome 启动器、T-103 登录保活与检测、T-104 PySide6 主窗口骨架、T-104b PySide6 worker 基座、T-105 Tab④ 账号管理、T-106 账号快捷方式、T-201 Excel 导入入库、T-202 Tab① 任务列表与导入按钮、T-202b Tab① 导入汇总栏、T-203 采集旧标题旧封面、T-204 回写旧字段到原 Excel、T-204b 采集完成自动回写、T-205 首次未配账号/Chrome 未启动/未登录引导保护、T-205b 采集后关闭自动新建商品页 tab、T-301 AI 生成接口、T-302 Tab② AI 生成布局与任务列表、T-302p 提示词管理、T-303 Tab② 开始生成/停止/进度、T-401 Tab③ 更新列表筛选与开始更新确认、T-402 Tab③ 确认后串行更新。 +- 阶段:V0 单账号 CDP 流程已验证;V1 已完成 T-000 正式代码包结构、T-001 `app/editor.py` 模块化、T-002 `app/appconfig.py` 应用配置、T-003 SQLite 持久化地基、T-004 本地数据忽略规则、T-005 AI 模型清单后端、T-006 单元测试基座、T-101 账号 user-data-dir 工具、T-102 Chrome 启动器、T-103 登录保活与检测、T-104 PySide6 主窗口骨架、T-104b PySide6 worker 基座、T-105 Tab④ 账号管理、T-106 账号快捷方式、T-201 Excel 导入入库、T-202 Tab① 任务列表与导入按钮、T-202b Tab① 导入汇总栏、T-203 采集旧标题旧封面、T-204 回写旧字段到原 Excel、T-204b 采集完成自动回写、T-205 首次未配账号/Chrome 未启动/未登录引导保护、T-205b 采集后关闭自动新建商品页 tab、T-301 AI 生成接口、T-302 Tab② AI 生成布局与任务列表、T-302p 提示词管理、T-303 Tab② 开始生成/停止/进度、T-401 Tab③ 更新列表筛选与开始更新确认、T-402 Tab③ 确认后串行更新、T-403 Tab③ 结果回写与结束汇总。 - 技术栈:Python 3.10+,自研 CDP(websocket-client + requests),SQLite(sqlite3)+ `config.json` + openpyxl + AI(`config/ai_models.json` 通用 HTTP,chat JSON / images_edits),GUI PySide6 5 Tab(已定)。 -- 生产代码:已建立 `app/` 包 + 根入口 `main.py`;`app/cdp.py` 为已验证 CDP 底座,已区分 `CDP.close()` 断开 WebSocket 与 `close_tab()` 关闭浏览器 target;`app/editor.py` 已封装登录状态检测、标题/封面/采集/更新按钮能力,并在采集结束后只关闭本轮自动新建的商品编辑页 tab、保留用户已有 tab;`app/appconfig.py` 已实现 `config.json` 默认值/读写/更新、AI 参数与端口读取,以及 `config/ai_models.json` 模型清单 CRUD/过滤/打码/测试连接;`app/ai.py` 已实现 `gen_title()`/`gen_cover()`/`generate_batch()`,按默认文本/图片模型发起通用 HTTP 调用,支持重试、错误脱敏、图片 URL/base64 解析、resolution resize、jpg_quality 保存、先并发标题再并发封面、逐条 `set_generated`、失败 `mark_failed` 与停止取消未开始项;`app/prompts.py` 已实现标题提示词读写、封面模板 CRUD 与变量替换;`app/db.py` 已实现 SQLite schema、连接 PRAGMA、批次/账号/任务与阶段写库函数;`app/excel.py` 已实现多 Excel 输入列解析、整文件列校验、脏行统计跳过、导入批次与任务入库、别名匹配统计、旧标题/旧封面路径回写原 Excel 与另存副本;`app/config.py` 已实现账号 slug 与 user-data-dir 创建;`app/accounts.py` 已实现账号 CRUD 服务、端口默认分配、启动登录、检测登录、生成快捷方式;`app/chrome.py` 已实现 Chrome 参数拼装、启动、CDP 端口探测、PowerShell `.lnk` 快捷方式生成;`app/gui.py` 已实现 PySide6 `MainWindow`、五 Tab、顶部 Tab 栏防误点样式、① 导入采集的 Excel 导入按钮/导入汇总栏/QTableView 任务列表/未匹配筛选与略过标记/采集旧标题旧封面 worker/采集前账号就绪预检与④引导/采集完成自动回写/旧数据回写重试按钮与 worker、② AI生成左右布局/标题与封面提示词管理/批次店铺状态筛选/任务列表/变量预览/开始生成/停止/进度/双击新旧封面预览与 `GenerateWorker`、③ 更新shopee筛选栏/任务列表/开始更新确认弹窗/确认后 `ApplyWorker` 串行更新/账号就绪预检与④引导/逐条 `set_applied`、④ 账号管理表格/弹窗/按钮/快捷方式与状态栏;`app/workers.py` 已实现 `BaseWorker`、通用 signals、取消标记和 `QThread` 启动包装。 -- 测试:`tests/` 已建立;T-006 后纯逻辑改动必须运行 `python -m unittest discover -s tests`,当前覆盖 appconfig/db/config/accounts/chrome 启动与快捷方式/editor 登录检测与商品 tab 生命周期/excel 导入/ai 标题与封面 HTTP 解析/`generate_batch` 正常、失败与停止/prompts 读写与渲染/gui ① 导入采集/gui ② AI生成布局筛选、提示词管理、生成 worker 与双击预览/gui ③ 更新shopee筛选列表、确认弹窗、`ApplyWorker` 串行更新与账号预检/gui ④ 账号管理/worker signal 与线程包装;CDP/Shopee 改动仍需测试商品手动验证。 +- 生产代码:已建立 `app/` 包 + 根入口 `main.py`;`app/cdp.py` 为已验证 CDP 底座,已区分 `CDP.close()` 断开 WebSocket 与 `close_tab()` 关闭浏览器 target;`app/editor.py` 已封装登录状态检测、标题/封面/采集/更新按钮能力,并在采集结束后只关闭本轮自动新建的商品编辑页 tab、保留用户已有 tab;`app/appconfig.py` 已实现 `config.json` 默认值/读写/更新、AI 参数与端口读取,以及 `config/ai_models.json` 模型清单 CRUD/过滤/打码/测试连接;`app/ai.py` 已实现 `gen_title()`/`gen_cover()`/`generate_batch()`,按默认文本/图片模型发起通用 HTTP 调用,支持重试、错误脱敏、图片 URL/base64 解析、resolution resize、jpg_quality 保存、先并发标题再并发封面、逐条 `set_generated`、失败 `mark_failed` 与停止取消未开始项;`app/prompts.py` 已实现标题提示词读写、封面模板 CRUD 与变量替换;`app/db.py` 已实现 SQLite schema、连接 PRAGMA、批次/账号/任务与阶段写库函数;`app/excel.py` 已实现多 Excel 输入列解析、整文件列校验、脏行统计跳过、导入批次与任务入库、别名匹配统计、旧标题/旧封面路径回写原 Excel、更新结果回写原 Excel 与另存副本;`app/config.py` 已实现账号 slug 与 user-data-dir 创建;`app/accounts.py` 已实现账号 CRUD 服务、端口默认分配、启动登录、检测登录、生成快捷方式;`app/chrome.py` 已实现 Chrome 参数拼装、启动、CDP 端口探测、PowerShell `.lnk` 快捷方式生成;`app/gui.py` 已实现 PySide6 `MainWindow`、五 Tab、顶部 Tab 栏防误点样式、① 导入采集的 Excel 导入按钮/导入汇总栏/QTableView 任务列表/未匹配筛选与略过标记/采集旧标题旧封面 worker/采集前账号就绪预检与④引导/采集完成自动回写/旧数据回写重试按钮与 worker、② AI生成左右布局/标题与封面提示词管理/批次店铺状态筛选/任务列表/变量预览/开始生成/停止/进度/双击新旧封面预览与 `GenerateWorker`、③ 更新shopee筛选栏/任务列表/开始更新确认弹窗/确认后 `ApplyWorker` 串行更新/账号就绪预检与④引导/逐条 `set_applied`/自动回写结果到 Excel/结束汇总弹窗/手动回写重试按钮、④ 账号管理表格/弹窗/按钮/快捷方式与状态栏;`app/workers.py` 已实现 `BaseWorker`、通用 signals、取消标记和 `QThread` 启动包装。 +- 测试:`tests/` 已建立;T-006 后纯逻辑改动必须运行 `python -m unittest discover -s tests`,当前覆盖 appconfig/db/config/accounts/chrome 启动与快捷方式/editor 登录检测与商品 tab 生命周期/excel 导入/旧字段与更新结果回写/ai 标题与封面 HTTP 解析/`generate_batch` 正常、失败与停止/prompts 读写与渲染/gui ① 导入采集/gui ② AI生成布局筛选、提示词管理、生成 worker 与双击预览/gui ③ 更新shopee筛选列表、确认弹窗、`ApplyWorker` 串行更新与账号预检、结果回写与汇总/gui ④ 账号管理/worker signal 与线程包装;CDP/Shopee 改动仍需测试商品手动验证。 - 数据:`config.json`、`config/ai_models.json`、`cmshopee.db`、`chrome_user_data_dir/`、`images/` 已由 `.gitignore` 排除;运营填写后的 Excel 业务文件默认忽略,标准空模板 `shopee待处理任务模板.xlsx` 可提交;`app/appconfig.py` 首次读取缺失的 `config.json` 时会在本地写默认配置,`app/db.py` 调用 `init_db()` 时会在本地创建 SQLite DB。 ## 既定设计要点(文档已定) @@ -32,7 +32,7 @@ | `prototypes/` | 已有 | 已验证原型/探查脚本(demo/set_title/set_cover/get_title/cookies/inspect_images/grab/1.py),保留作人工回归与探查参考;见 `prototypes/README.md` | | `chrome-remote-debug-lan.md` | 已有 | WSL→Windows CDP 转发排查记录 | | `app/__init__.py` / `app/__main__.py` / `main.py` | 已有 | 正式包与启动入口;`python main.py` / `python -m app` 可运行占位入口 | -| `app/gui.py` | 已有 | T-104/T-105/T-106/T-202/T-202b/T-203/T-204/T-204b/T-205/T-302/T-302p/T-303/T-401/T-402 产出:PySide6 `QMainWindow` + 五 Tab;顶部 Tab 栏防误点样式;① 导入采集导入按钮、导入汇总栏、`QTableView` 任务列表、未匹配筛选与略过标记、采集旧标题旧封面 worker、采集前账号就绪预检与④引导、采集完成自动回写与手动重试;② AI生成左右布局、提示词管理、筛选栏、任务列表、开始生成/停止/进度、双击新旧封面预览与 `GenerateWorker`;③ 更新shopee筛选栏、任务列表、开始更新确认弹窗、确认后 `ApplyWorker` 串行更新、账号预检与④引导、逐条 `set_applied`;④ 账号管理表格、账号弹窗、启动登录、检测登录、快捷方式 | +| `app/gui.py` | 已有 | T-104/T-105/T-106/T-202/T-202b/T-203/T-204/T-204b/T-205/T-302/T-302p/T-303/T-401/T-402/T-403 产出:PySide6 `QMainWindow` + 五 Tab;顶部 Tab 栏防误点样式;① 导入采集导入按钮、导入汇总栏、`QTableView` 任务列表、未匹配筛选与略过标记、采集旧标题旧封面 worker、采集前账号就绪预检与④引导、采集完成自动回写与手动重试;② AI生成左右布局、提示词管理、筛选栏、任务列表、开始生成/停止/进度、双击新旧封面预览与 `GenerateWorker`;③ 更新shopee筛选栏、任务列表、开始更新确认弹窗、确认后 `ApplyWorker` 串行更新、账号预检与④引导、逐条 `set_applied`、自动回写结果到 Excel、结束汇总弹窗与手动回写重试;④ 账号管理表格、账号弹窗、启动登录、检测登录、快捷方式 | | `app/workers.py` | 已有 | T-104b 产出:`BaseWorker` + 通用 signals + 取消标记 + `run_worker()` QThread 包装 | | `app/accounts.py` | 已有 | T-105/T-106 产出:账号 CRUD 服务、目录创建、端口分配、启动登录、检测登录、快捷方式 | | `app/editor.py` | 已有 | T-001/T-103/T-205b 产出:登录状态检测、打开商品页、读/写标题、读/下载封面、采集后关闭自动新建商品页 tab、上传拖封面、更新按钮、apply_task | @@ -42,8 +42,8 @@ | `app/db.py` | 已有 | T-003 产出:batches/accounts/tasks schema;WAL/busy_timeout/foreign_keys;账号/批次/任务与 set_* 阶段写库 | | `app/config.py` | 已有 | T-101 产出:别名→稳定 slug;创建并返回绝对 user-data-dir | | `app/chrome.py` | 已有 | T-102/T-106 产出:Chrome 启动参数、`subprocess.Popen` 启动、`/json/version` 端口探测、PowerShell `.lnk` 快捷方式 | -| `tests/` | 已有 | T-006/T-201/T-202/T-202b/T-203/T-204/T-204b/T-205/T-301/T-302/T-302p/T-303/T-401/T-402 产出:stdlib unittest 基座;覆盖 appconfig/db/config/accounts/chrome/editor/excel/ai/prompts/gui/workers | -| `app/excel.py` | 已有 | T-201/T-204 产出:多文件 Excel 输入列解析、必需列整文件拒绝、脏行逐行跳过、批次/任务入库、匹配统计;按源文件/工作表/行号回写旧标题与旧封面路径;支持原文件被占用时另存副本 | +| `tests/` | 已有 | T-006/T-201/T-202/T-202b/T-203/T-204/T-204b/T-205/T-301/T-302/T-302p/T-303/T-401/T-402/T-403 产出:stdlib unittest 基座;覆盖 appconfig/db/config/accounts/chrome/editor/excel/ai/prompts/gui/workers | +| `app/excel.py` | 已有 | T-201/T-204/T-403 产出:多文件 Excel 输入列解析、必需列整文件拒绝、脏行逐行跳过、批次/任务入库、匹配统计;按源文件/工作表/行号回写旧标题与旧封面路径;按源文件/工作表/行号回写新标题、新封面路径、更新状态;支持原文件被占用时另存副本 | | `shopee待处理任务模板.xlsx` | 已有,已提交 | 标准空 Excel 模板;单工作表 `待处理任务`,表头 `账号名 | 别名 | 商品id | 旧标题 | 旧封面图片路径 | 新标题 | 新封面图片路径 | 更新状态`;运营复制后填写,填写副本不提交 | | `config.json` / `config/ai_models.json` / `cmshopee.db` / `chrome_user_data_dir/` / `images/` | 本地存在或按需生成,已忽略 | 含配置、密钥、业务、登录态、图片,不提交版本库 | @@ -60,15 +60,15 @@ 任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史记录见 [`../progress.md`](../progress.md)。 -- 已完成:T-000(正式代码包结构)、T-001(`app/editor.py` 模块化)、T-002(`app/appconfig.py` + `config.json`)、T-003(SQLite 建表)、T-004(本地数据 gitignore)、T-005(AI 模型清单后端)、T-006(单元测试基座)、T-101(账号 slug/user-data-dir)、T-102(Chrome 启动器)、T-103(登录保活与检测)、T-104(PySide6 五 Tab 主窗口骨架)、T-104b(PySide6 worker 基座)、T-105(Tab④ 账号管理)、T-106(账号快捷方式)、T-201(Excel 导入:解析多文件输入列入库)、T-202(Tab① 任务列表 + 导入按钮 + 别名匹配标记)、T-202b(Tab① 导入汇总栏)、T-203(采集旧标题+旧封面)、T-204(回写旧字段到原 Excel)、T-204b(采集完成自动回写旧字段)、T-205(首次未配账号 / Chrome 未启动 / 未登录引导保护)、T-205b(采集后关闭自动新建商品页 tab)、T-301(AI 生成接口)、T-302(Tab② 左右布局与任务列表)、T-302p(提示词管理)、T-303(Tab② 开始生成 + 停止 + 进度)、T-401(Tab③ 更新列表筛选 + 开始更新确认弹窗)、T-402(Tab③ 确认后串行更新)。 +- 已完成:T-000(正式代码包结构)、T-001(`app/editor.py` 模块化)、T-002(`app/appconfig.py` + `config.json`)、T-003(SQLite 建表)、T-004(本地数据 gitignore)、T-005(AI 模型清单后端)、T-006(单元测试基座)、T-101(账号 slug/user-data-dir)、T-102(Chrome 启动器)、T-103(登录保活与检测)、T-104(PySide6 五 Tab 主窗口骨架)、T-104b(PySide6 worker 基座)、T-105(Tab④ 账号管理)、T-106(账号快捷方式)、T-201(Excel 导入:解析多文件输入列入库)、T-202(Tab① 任务列表 + 导入按钮 + 别名匹配标记)、T-202b(Tab① 导入汇总栏)、T-203(采集旧标题+旧封面)、T-204(回写旧字段到原 Excel)、T-204b(采集完成自动回写旧字段)、T-205(首次未配账号 / Chrome 未启动 / 未登录引导保护)、T-205b(采集后关闭自动新建商品页 tab)、T-301(AI 生成接口)、T-302(Tab② 左右布局与任务列表)、T-302p(提示词管理)、T-303(Tab② 开始生成 + 停止 + 进度)、T-401(Tab③ 更新列表筛选 + 开始更新确认弹窗)、T-402(Tab③ 确认后串行更新)、T-403(Tab③ 结果回写与结束汇总)。 - 正在进行:无。 -- 下一个可领取任务:**T-403(回写结果到原 Excel + 结束弹窗汇总)**。 +- 下一个可领取任务:**T-501(Tab⑤ 设置 · AI 模型管理 UI)**。 ## 当前已知限制 - ① 采集依赖对应账号 Chrome 已用专属 user-data-dir 和 CDP 端口启动并登录;T-205 已在采集前拦截未配置账号、Chrome 未启动、未登录,并引导去④账号管理,但不会无提示批量启动所有账号 Chrome。 - T-301/T-303 已完成通用 HTTP AI 接口、批量生成编排和 GUI 接入 mock 单测;真实 AI 生成还需要在 `config/ai_models.json` 填入可用 url/model/api_key 后做一次成本可控的小样本实测。 -- T-402 已完成③确认后串行更新与逐条写库;更新结果回写原 Excel、结束弹窗成功/失败/略过汇总留给 T-403。 +- T-403 已完成③更新结果回写与结束汇总;真实 Shopee 更新仍需在测试商品上做谨慎端到端冒烟。 ## 当前可运行内容 diff --git a/docs/routes.md b/docs/routes.md index 040149b..8a911d5 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -106,7 +106,8 @@ - 对确认后的**已生成(generated)任务**执行:打开编辑页换标题+换封面 → 点「更新」提交。 - 串行、单条失败继续;每条立即写回 SQLite(committed/状态/error),失败不阻塞后续任务。 - 更新前做账号就绪预检:无账号、对应账号 Chrome 未启动或未登录时整体阻断并引导去④账号管理,不进入逐条提交。 -- 更新结果回写原 Excel 与结束弹窗汇总留给 T-403。 +- 更新完成后自动回写原 Excel:写入新标题、新封面图片路径、更新状态;原文件被锁时提示关闭后点击「回写结果到 Excel」手动重试。 +- 自动回写完成后弹窗汇总成功/失败/略过数量与 Excel 回写文件/行数。 ## ④ 账号管理 @@ -136,7 +137,7 @@ │ ② AI生成:提示词 → 生成新标题/新封面(无逐条审核) │ -③ 更新shopee:对已生成任务点击开始更新 → 弹窗确认 → 换标题+封面 → 点「更新」提交 → T-403 回写结果 +③ 更新shopee:对已生成任务点击开始更新 → 弹窗确认 → 换标题+封面 → 点「更新」提交 → 回写结果 → 弹窗汇总 ``` - 未配账号 / Chrome 未启动 / 未登录:① ③ 的执行按钮禁用或执行前提示去 ④。 @@ -150,11 +151,12 @@ | `MainWindow(QMainWindow)` | 根窗口 | 持有 `QTabWidget`、状态栏、全局消息 | | `CollectTab(QWidget)` | ① | 导入、任务表、采集、回写 | | `GenerateTab(QWidget)` | ② | 左提示词管理 + 右筛选/任务列表;双击看新旧封面;开始生成/停止/进度已接入 `GenerateWorker` | -| `ApplyTab(QWidget)` | ③ | 已生成任务筛选 + 开始更新确认 + 确认后串行更新已接入;回写由 T-403 接入 | +| `ApplyTab(QWidget)` | ③ | 已生成任务筛选 + 开始更新确认 + 确认后串行更新 + 结果回写与结束汇总 | | `AccountsTab(QWidget)` | ④ | 账号增删改、启动登录、检测登录、生成快捷方式 | | `SettingsTab(QWidget)` | ⑤ | AI/目录/Chrome 配置 | | `TaskTableModel(QAbstractTableModel)` | ①②③ | 任务表格数据模型,供 `QTableView` 使用 | | `BaseWorker(QObject)` | 后台 | 定义 `progress/log/row_updated/failed/finished/cancelled` signals | | `ApplyWorker(BaseWorker)` | ③ | 账号就绪预检、串行调用 `editor.apply_task()`、逐条 `set_applied()`,失败继续 | +| `WriteBackWorker(BaseWorker)` | ①③ | ①回写旧字段;③回写新标题/新封面/更新状态 | > 采集、生成、更新都是耗时操作,使用 `QObject` worker + `QThread`。Worker 不直接操作 QWidget,只通过 signal 通知主线程刷新 UI。 diff --git a/progress.md b/progress.md index 92453c1..d7a868c 100644 --- a/progress.md +++ b/progress.md @@ -538,3 +538,13 @@ - 测试:`tests/test_gui.py` 调整 T-401 确认测试为“取消不启动”和“确认启动 ApplyWorker”;新增 `ApplyWorker` 成功/失败/略过串行写库、Chrome 未启动预检阻断、③阻断弹窗引导④的覆盖。 - 文档:`docs/06-tasks.md` 将 T-402 标为 DONE;同步 `docs/api.md`、`docs/routes.md`、`docs/current-state.md`,下一个可领取任务更新为 T-403。 - 验证:`python -m unittest discover -s tests -p test_gui.py` 通过(28 tests);`python -m compileall app main.py tests` 通过;`python -m unittest discover -s tests` 通过(83 tests)。 + +## 【2026-06-27】T-403 Tab③ 结果回写与结束汇总 + +- 状态:DONE +- 变更:`app/excel.py` 新增 `write_back_results()`,按 `source_file_abs/source_sheet/source_row` 把新标题、新封面图片路径、更新状态写回原 Excel;更新状态输出为“成功 / 失败:原因 / 略过:原因 / 待更新”。`write_back()` 仍保持旧标题/旧封面回写给①使用。 +- GUI:`ApplyTab` 在 `ApplyWorker` 完成后自动启动 `WriteBackWorker(mode="results")` 回写结果;成功后弹窗汇总成功/失败/略过数量与 Excel 回写文件/行数;文件被占用时提示关闭原 Excel 后点击「回写结果到 Excel」手动重试,SQLite 更新结果不回滚。③的「回写结果到 Excel」按钮作为手动重试入口。 +- Worker:`WriteBackWorker` 增加 `mode`,默认 `old` 兼容①旧字段回写,`results` 调用 `excel.write_back_results()`;支持单批次或批次列表。 +- 测试:`tests/test_excel.py` 覆盖结果字段与三种更新状态写回;`tests/test_gui.py` 覆盖③自动结果回写、结束汇总弹窗、文件锁手动重试提示、`WriteBackWorker(mode="results")` 调用;模块契约加入 `write_back_results`。 +- 文档:`docs/06-tasks.md` 将 T-403 标为 DONE;同步 `docs/api.md`、`docs/routes.md`、`docs/current-state.md`,下一个可领取任务更新为 T-501。 +- 验证:`python -m unittest discover -s tests -p test_excel.py` 通过(8 tests);`python -m unittest discover -s tests -p test_gui.py` 通过(32 tests);`python -m compileall app main.py tests` 通过;`python -m unittest discover -s tests` 通过(88 tests)。 diff --git a/tests/test_excel.py b/tests/test_excel.py index 6215458..2e10959 100644 --- a/tests/test_excel.py +++ b/tests/test_excel.py @@ -252,6 +252,55 @@ class ExcelImportTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_write_back_results_writes_new_fields_and_update_status(self): + with self.make_temp_dir() as temp_dir: + db_path = os.path.join(temp_dir, "cmshopee.db") + excel_path = os.path.join(temp_dir, "input.xlsx") + self.save_workbook( + excel_path, + [ + ( + "待处理任务", + [ + ["账号名", "别名", "商品id"], + ["主店", "alias-a", "51100639510"], + ["副店", "alias-b", "51100639511"], + ["未知", "missing", "51100639512"], + ], + ) + ], + ) + result = excel.import_tasks([excel_path], path=db_path) + tasks = db.list_tasks(batch_id=result["batch_id"], path=db_path) + for task in tasks: + db.set_collected(task.id, "旧标题", "old.jpg", path=db_path) + db.set_generated( + task.id, + "新标题" + task.item_id[-2:], + rf"D:\images\{task.item_id}_new.jpg", + path=db_path, + ) + refreshed = db.list_tasks(batch_id=result["batch_id"], path=db_path) + db.set_applied(refreshed[0].id, True, path=db_path) + db.set_applied(refreshed[1].id, False, "UPDATE_DISABLED", path=db_path) + db.mark_skipped(refreshed[2].id, "别名未匹配账号", path=db_path) + + summary = excel.write_back_results(result["batch_id"], path=db_path) + + self.assertEqual(True, summary["ok"]) + self.assertEqual(1, summary["files"]) + self.assertEqual(3, summary["rows"]) + first = self.row_values_by_header(excel_path, row_number=2) + second = self.row_values_by_header(excel_path, row_number=3) + third = self.row_values_by_header(excel_path, row_number=4) + self.assertEqual("新标题10", first["新标题"]) + self.assertEqual(r"D:\images\51100639510_new.jpg", first["新封面图片路径"]) + self.assertEqual("成功", first["更新状态"]) + self.assertEqual("失败:UPDATE_DISABLED", second["更新状态"]) + self.assertEqual("略过:别名未匹配账号", third["更新状态"]) + + self.assert_removed(temp_dir) + def test_export_copy_writes_copy_without_touching_original(self): with self.make_temp_dir() as temp_dir: db_path = os.path.join(temp_dir, "cmshopee.db") diff --git a/tests/test_gui.py b/tests/test_gui.py index 2598c7f..b0f049a 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -515,7 +515,15 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertEqual(["alias-a", "alias-b"], applied_aliases) self.assertEqual( - {"ok": False, "total": 3, "done": 3, "applied": 1, "skipped": 1, "failed": 1}, + { + "ok": False, + "total": 3, + "done": 3, + "applied": 1, + "skipped": 1, + "failed": 1, + "batch_ids": [batch_id], + }, summary, ) self.assertEqual( @@ -538,6 +546,97 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_apply_tab_auto_starts_result_write_back_and_shows_summary(self): + with self.make_temp_dir() as temp_dir: + cfg = self.make_config(temp_dir) + db.init_db(cfg["db_path"]) + 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", + } + ], + path=cfg["db_path"], + ) + task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0] + db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"]) + db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"]) + db.set_applied(task.id, True, path=cfg["db_path"]) + statuses = [] + tab = ApplyTab(config=cfg, status_callback=statuses.append) + self.addCleanup(tab.close) + payload = { + "done": 1, + "total": 1, + "applied": 1, + "failed": 0, + "skipped": 0, + "batch_ids": [batch_id], + } + + with mock.patch.object(tab, "_start_result_write_back", return_value=True) as start_write_back: + tab._on_apply_finished(payload) + + start_write_back.assert_called_once_with( + [batch_id], + auto=True, + apply_summary=payload, + ) + self.assertIn("正在自动回写结果到 Excel", statuses[-1]) + + self.assert_removed(temp_dir) + + def test_apply_tab_result_write_back_finished_shows_completion_popup(self): + with self.make_temp_dir() as temp_dir: + statuses = [] + tab = ApplyTab(config=self.make_config(temp_dir), status_callback=statuses.append) + self.addCleanup(tab.close) + summary = {"applied": 2, "failed": 1, "skipped": 1} + write_back_payload = {"ok": True, "files": 1, "rows": 4} + + with mock.patch("app.gui.QMessageBox.information") as info: + tab._on_result_write_back_finished( + write_back_payload, + auto=True, + apply_summary=summary, + ) + + message = info.call_args[0][2] + self.assertIn("成功:2,失败:1,略过:1", message) + self.assertIn("Excel 回写:文件1,行4", message) + self.assertIn("Excel 自动回写更新结果完成", statuses[-1]) + + self.assert_removed(temp_dir) + + def test_apply_tab_result_write_back_locked_file_message_points_to_manual_retry(self): + with self.make_temp_dir() as temp_dir: + statuses = [] + tab = ApplyTab(config=self.make_config(temp_dir), status_callback=statuses.append) + self.addCleanup(tab.close) + summary = {"applied": 1, "failed": 0, "skipped": 0} + + with mock.patch("app.gui.QMessageBox.warning") as warning: + tab._on_result_write_back_failed( + -1, + "Excel 文件被占用,请关闭后重试: input.xlsx", + auto=True, + apply_summary=summary, + ) + + message = warning.call_args[0][2] + self.assertIn("成功:1,失败:0,略过:0", message) + self.assertIn("点击「回写结果到 Excel」手动重试", message) + self.assertIn("手动重试", statuses[-1]) + + self.assert_removed(temp_dir) + def test_apply_worker_preflight_blocks_when_chrome_not_running(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) @@ -1246,6 +1345,20 @@ class GuiTests(TempDirMixin, unittest.TestCase): path="db.sqlite", ) + def test_write_back_worker_calls_result_write_back(self): + with mock.patch( + "app.gui.excel.write_back_results", + return_value={"ok": True, "batch_id": "batch-1", "files": 1, "rows": 2}, + ) as write_back_results: + summary = WriteBackWorker("batch-1", db_path="db.sqlite", mode="results").execute() + + self.assertEqual({"ok": True, "batch_id": "batch-1", "files": 1, "rows": 2}, summary) + write_back_results.assert_called_once_with( + "batch-1", + excel_path=None, + path="db.sqlite", + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_module_contracts.py b/tests/test_module_contracts.py index 7c6c530..aade256 100644 --- a/tests/test_module_contracts.py +++ b/tests/test_module_contracts.py @@ -21,7 +21,7 @@ def import_or_skip(module_name): class PendingModuleContractTests(unittest.TestCase): def test_excel_contract_when_module_exists(self): excel = import_or_skip("app.excel") - for name in ("import_tasks", "match_summary", "write_back", "export_copy"): + for name in ("import_tasks", "match_summary", "write_back", "write_back_results", "export_copy"): self.assertTrue(callable(getattr(excel, name, None)), name) def test_prompts_contract_when_module_exists(self):