feat: 完成T-204旧字段回写
实现 Excel 旧标题与旧封面路径回写原文件,按 source_file_abs/source_sheet/source_row 定位行,支持输出列自动追加、xlsm 保留 VBA、文件占用中文错误提示与 export_copy 另存副本。 Tab① 增加回写旧数据按钮与 WriteBackWorker,后台调用 excel.write_back,锁文件时提示关闭后重试;补充 Excel 与 GUI 单测覆盖回写、锁文件、另存副本和 worker 调用。 同步 harness 文档:T-204 标记完成,新增 T-204b 记录采集完成自动回写缺口,扩展 T-205 覆盖 Chrome 未启动/未登录引导保护,并更新 current-state、routes、api、architecture、requirements 与 progress。
This commit is contained in:
+214
-5
@@ -24,6 +24,17 @@ HEADER_ALIASES = {
|
||||
"alias": {"别名", "账号别名", "alias"},
|
||||
"item_id": {"商品id", "商品编号", "商品id号", "itemid", "item"},
|
||||
}
|
||||
OUTPUT_HEADERS = {
|
||||
"old_title": "旧标题",
|
||||
"old_cover_path": "旧封面图片路径",
|
||||
"new_title": "新标题",
|
||||
"new_cover_path": "新封面图片路径",
|
||||
"update_status": "更新状态",
|
||||
}
|
||||
OLD_WRITE_BACK_FIELDS = ("old_title", "old_cover_path")
|
||||
WRITEABLE_STAGES = {"collected", "generated", "applied"}
|
||||
LOCK_WINERRORS = {5, 32, 33}
|
||||
LOCK_ERRNOS = {13, 16}
|
||||
|
||||
|
||||
class ExcelError(RuntimeError):
|
||||
@@ -276,13 +287,211 @@ def match_summary(rows: list[dict], accounts: list) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def write_back(batch_id, excel_path=None, path=None) -> dict:
|
||||
"""Placeholder for Excel result write-back, implemented in later tasks."""
|
||||
def _batch_tasks(batch_id, path=None):
|
||||
if not batch_id:
|
||||
raise ExcelError("缺少批次 ID,无法回写 Excel")
|
||||
db.init_db(path)
|
||||
tasks = db.list_tasks(batch_id=batch_id, path=path)
|
||||
if not tasks:
|
||||
raise ExcelError(f"批次没有可回写任务: {batch_id}")
|
||||
return tasks
|
||||
|
||||
raise ExcelError("Excel 回写将在 T-204/T-403 实现")
|
||||
|
||||
def _filter_tasks_by_excel_path(tasks, excel_path):
|
||||
if excel_path is None:
|
||||
return list(tasks)
|
||||
target = os.path.abspath(str(excel_path))
|
||||
filtered = [
|
||||
task for task in tasks
|
||||
if os.path.abspath(task.source_file_abs) == target
|
||||
]
|
||||
if not filtered:
|
||||
raise ExcelError(f"批次中没有来自该 Excel 的任务: {target}")
|
||||
return filtered
|
||||
|
||||
|
||||
def _has_write_back_data(task, fields) -> bool:
|
||||
if getattr(task, "stage", None) in WRITEABLE_STAGES:
|
||||
return True
|
||||
return any(getattr(task, field, None) not in (None, "") for field in fields)
|
||||
|
||||
|
||||
def _group_by_source_file(tasks, fields):
|
||||
groups = {}
|
||||
for task in tasks:
|
||||
if not _has_write_back_data(task, fields):
|
||||
continue
|
||||
source_file_abs = os.path.abspath(task.source_file_abs)
|
||||
groups.setdefault(source_file_abs, []).append(task)
|
||||
return groups
|
||||
|
||||
|
||||
def _is_locked_error(exc) -> bool:
|
||||
return (
|
||||
isinstance(exc, PermissionError)
|
||||
or getattr(exc, "winerror", None) in LOCK_WINERRORS
|
||||
or getattr(exc, "errno", None) in LOCK_ERRNOS
|
||||
)
|
||||
|
||||
|
||||
def _excel_suffix(path_value) -> str:
|
||||
return os.path.splitext(str(path_value))[1].lower()
|
||||
|
||||
|
||||
def _load_write_workbook(source_path):
|
||||
kwargs = {}
|
||||
if _excel_suffix(source_path) == ".xlsm":
|
||||
kwargs["keep_vba"] = True
|
||||
return load_workbook(source_path, **kwargs)
|
||||
|
||||
|
||||
def _ensure_output_columns(sheet, fields):
|
||||
header_by_normalized = {}
|
||||
for column in range(1, sheet.max_column + 1):
|
||||
header = _text(sheet.cell(row=1, column=column).value)
|
||||
if header:
|
||||
header_by_normalized.setdefault(_normalize_header(header), column)
|
||||
|
||||
columns = {}
|
||||
next_column = sheet.max_column + 1
|
||||
for field in fields:
|
||||
header = OUTPUT_HEADERS[field]
|
||||
normalized = _normalize_header(header)
|
||||
column = header_by_normalized.get(normalized)
|
||||
if column is None:
|
||||
column = next_column
|
||||
sheet.cell(row=1, column=column).value = header
|
||||
header_by_normalized[normalized] = column
|
||||
next_column += 1
|
||||
columns[field] = column
|
||||
return columns
|
||||
|
||||
|
||||
def _write_tasks_to_workbook(workbook, tasks, fields) -> int:
|
||||
rows = 0
|
||||
missing_sheets = []
|
||||
by_sheet = {}
|
||||
for task in tasks:
|
||||
by_sheet.setdefault(task.source_sheet, []).append(task)
|
||||
|
||||
for sheet_name, sheet_tasks in by_sheet.items():
|
||||
if sheet_name not in workbook.sheetnames:
|
||||
missing_sheets.append(sheet_name)
|
||||
continue
|
||||
sheet = workbook[sheet_name]
|
||||
columns = _ensure_output_columns(sheet, fields)
|
||||
for task in sheet_tasks:
|
||||
if not _has_write_back_data(task, fields):
|
||||
continue
|
||||
for field in fields:
|
||||
sheet.cell(
|
||||
row=int(task.source_row),
|
||||
column=columns[field],
|
||||
).value = getattr(task, field, None) or ""
|
||||
rows += 1
|
||||
|
||||
if missing_sheets:
|
||||
names = ", ".join(sorted(set(missing_sheets)))
|
||||
raise ExcelError(f"原 Excel 缺少导入时的工作表: {names}")
|
||||
return rows
|
||||
|
||||
|
||||
def _write_source_to_path(source_path, tasks, target_path, fields):
|
||||
workbook = None
|
||||
try:
|
||||
workbook = _load_write_workbook(source_path)
|
||||
except Exception as exc:
|
||||
if _is_locked_error(exc):
|
||||
raise ExcelError(f"Excel 文件被占用,请关闭后重试: {source_path}") from exc
|
||||
raise ExcelError(f"读取 Excel 失败: {source_path}: {exc}") from exc
|
||||
|
||||
try:
|
||||
rows = _write_tasks_to_workbook(workbook, tasks, fields)
|
||||
try:
|
||||
workbook.save(target_path)
|
||||
except Exception as exc:
|
||||
if _is_locked_error(exc):
|
||||
raise ExcelError(f"Excel 文件被占用,请关闭后重试: {target_path}") from exc
|
||||
raise ExcelError(f"保存 Excel 失败: {target_path}: {exc}") from exc
|
||||
return rows
|
||||
finally:
|
||||
if workbook is not None:
|
||||
workbook.close()
|
||||
|
||||
|
||||
def _write_result(batch_id, groups, target_for_source, fields):
|
||||
rows = 0
|
||||
written_files = []
|
||||
for source_path, tasks in groups.items():
|
||||
target_path = target_for_source(source_path)
|
||||
rows += _write_source_to_path(source_path, tasks, target_path, fields)
|
||||
written_files.append(os.path.abspath(target_path))
|
||||
return {
|
||||
"ok": True,
|
||||
"batch_id": batch_id,
|
||||
"files": len(written_files),
|
||||
"rows": rows,
|
||||
"written_files": written_files,
|
||||
}
|
||||
|
||||
|
||||
def write_back(batch_id, excel_path=None, path=None) -> dict:
|
||||
"""Write collected old-title/old-cover fields back to the original Excel file."""
|
||||
|
||||
_require_openpyxl()
|
||||
fields = OLD_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"}
|
||||
|
||||
|
||||
def _unique_copy_path(out_dir, source_path):
|
||||
base_name = os.path.basename(source_path)
|
||||
stem, suffix = os.path.splitext(base_name)
|
||||
candidate = os.path.join(out_dir, f"{stem}_cmshopee回写{suffix}")
|
||||
index = 1
|
||||
while os.path.exists(candidate):
|
||||
candidate = os.path.join(out_dir, f"{stem}_cmshopee回写_{index}{suffix}")
|
||||
index += 1
|
||||
return candidate
|
||||
|
||||
|
||||
def export_copy(batch_id, out_dir_or_path, path=None) -> dict:
|
||||
"""Placeholder for exporting a copy when the original Excel is locked."""
|
||||
"""Export a write-back copy without touching the original Excel file."""
|
||||
|
||||
raise ExcelError("Excel 另存副本将在 T-204/T-403 实现")
|
||||
_require_openpyxl()
|
||||
if not out_dir_or_path:
|
||||
raise ExcelError("缺少另存路径,无法导出 Excel 副本")
|
||||
|
||||
fields = OLD_WRITE_BACK_FIELDS
|
||||
tasks = _batch_tasks(batch_id, path=path)
|
||||
groups = _group_by_source_file(tasks, fields)
|
||||
output_path = os.path.abspath(str(out_dir_or_path))
|
||||
output_is_file = _is_excel_output_path(output_path)
|
||||
if output_is_file and len(groups) > 1:
|
||||
raise ExcelError("多个源 Excel 另存副本时,目标必须是目录")
|
||||
if output_is_file:
|
||||
output_dir = os.path.dirname(output_path)
|
||||
if output_dir:
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
def target_for_source(source_path):
|
||||
if os.path.abspath(source_path) == output_path:
|
||||
raise ExcelError("另存副本路径不能与原 Excel 相同")
|
||||
return output_path
|
||||
else:
|
||||
os.makedirs(output_path, exist_ok=True)
|
||||
|
||||
def target_for_source(source_path):
|
||||
return _unique_copy_path(output_path, source_path)
|
||||
|
||||
return _write_result(batch_id, groups, target_for_source, fields)
|
||||
|
||||
Reference in New Issue
Block a user