Files
cmshoppe/app/excel.py
T
chengma 3bc1776d07 feat: 完成T-403结果回写与汇总
新增excel.write_back_results,按原Excel文件、工作表和行号回写新标题、新封面图片路径与更新状态。

③更新完成后自动用WriteBackWorker(mode=results)回写结果,并弹窗汇总成功、失败、略过数量;文件被占用时提示关闭Excel后手动重试。

补充Excel结果回写、GUI自动回写/汇总/锁文件提示和模块契约测试,同步任务看板、API、流程和当前状态文档。
2026-06-27 17:28:33 +08:00

556 lines
17 KiB
Python

"""Excel import and write-back helpers."""
from __future__ import annotations
import os
import re
from typing import Iterable, Optional
from . import db
try:
from openpyxl import load_workbook
OPENPYXL_IMPORT_ERROR = None
except ModuleNotFoundError as exc:
load_workbook = None
OPENPYXL_IMPORT_ERROR = exc
REQUIRED_FIELDS = ("alias", "item_id")
HEADER_ALIASES = {
"account_name": {"账号名", "账号名称", "店铺", "店铺名", "shop", "accountname"},
"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")
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}
class ExcelError(RuntimeError):
"""Raised when Excel import or export cannot complete."""
def _require_openpyxl() -> None:
if OPENPYXL_IMPORT_ERROR is not None:
raise ExcelError("openpyxl 未安装,无法读取 Excel 文件")
def _text(value) -> str:
if value is None:
return ""
if isinstance(value, float) and value.is_integer():
return str(int(value))
return str(value).strip()
def _normalize_header(value) -> str:
return re.sub(r"[\s_]+", "", _text(value).lower())
def _field_for_header(value) -> Optional[str]:
normalized = _normalize_header(value)
for field, names in HEADER_ALIASES.items():
if normalized in {_normalize_header(name) for name in names}:
return field
return None
def _normalize_item_id(value) -> str:
text = _text(value)
if re.fullmatch(r"\d+\.0", text):
text = text[:-2]
return text
def _valid_item_id(value) -> bool:
return bool(re.fullmatch(r"\d+", value or ""))
def _row_key(batch_id, source_file_abs, source_sheet, source_row) -> str:
return f"{batch_id}:{source_file_abs}:{source_sheet}:{source_row}"
def _empty_row(values) -> bool:
return all(_text(value) == "" for value in values)
def _headers_for_sheet(sheet):
first_row = next(sheet.iter_rows(min_row=1, max_row=1, values_only=True), ())
if _empty_row(first_row):
return None
headers = {}
for index, value in enumerate(first_row):
field = _field_for_header(value)
if field and field not in headers:
headers[field] = index
return headers
def _stats():
return {
"files": 0,
"total": 0,
"valid": 0,
"invalid": 0,
"inserted": 0,
"file_errors": [],
"row_errors": [],
}
def _file_error(file_path, message, sheet=None, missing_columns=None):
error = {
"file": file_path,
"error": message,
}
if sheet is not None:
error["sheet"] = sheet
if missing_columns:
error["missing_columns"] = list(missing_columns)
return error
def _row_error(file_path, sheet, row_number, message):
return {
"file": file_path,
"sheet": sheet,
"row": row_number,
"error": message,
}
def _read_file(file_path, stats):
source_file = str(file_path)
source_file_abs = os.path.abspath(source_file)
try:
workbook = load_workbook(source_file_abs, read_only=True, data_only=True)
except Exception as exc:
stats["file_errors"].append(_file_error(source_file, f"读取失败: {exc}"))
return []
try:
usable_sheets = []
sheet_errors = []
for sheet in workbook.worksheets:
headers = _headers_for_sheet(sheet)
if headers is None:
continue
missing = [
"别名" if field == "alias" else "商品id"
for field in REQUIRED_FIELDS
if field not in headers
]
if missing:
sheet_errors.append(
_file_error(
source_file,
"缺少必需列",
sheet=sheet.title,
missing_columns=missing,
)
)
else:
usable_sheets.append((sheet, headers))
if sheet_errors:
stats["file_errors"].extend(sheet_errors)
return []
if not usable_sheets:
stats["file_errors"].append(_file_error(source_file, "没有可解析工作表"))
return []
rows = []
for sheet, headers in usable_sheets:
max_column = max(headers.values()) + 1
for row_number, values in enumerate(
sheet.iter_rows(
min_row=2,
max_col=max_column,
values_only=True,
),
start=2,
):
if _empty_row(values):
continue
stats["total"] += 1
account_name = _text(values[headers["account_name"]]) if "account_name" in headers else ""
alias = _text(values[headers["alias"]])
item_id = _normalize_item_id(values[headers["item_id"]])
if not alias:
stats["invalid"] += 1
stats["row_errors"].append(
_row_error(source_file, sheet.title, row_number, "别名不能为空")
)
continue
if not item_id:
stats["invalid"] += 1
stats["row_errors"].append(
_row_error(source_file, sheet.title, row_number, "商品id不能为空")
)
continue
if not _valid_item_id(item_id):
stats["invalid"] += 1
stats["row_errors"].append(
_row_error(source_file, sheet.title, row_number, "商品id必须是数字")
)
continue
rows.append(
{
"source_file": source_file,
"source_file_abs": source_file_abs,
"source_sheet": sheet.title,
"source_row": row_number,
"account_name": account_name or None,
"alias": alias,
"item_id": item_id,
}
)
stats["valid"] += 1
return rows
finally:
workbook.close()
def import_tasks(file_paths: Iterable[str], path=None, note=None, write_db=True) -> dict:
"""Parse input Excel files and optionally insert rows into SQLite."""
_require_openpyxl()
if isinstance(file_paths, (str, os.PathLike)):
paths = [str(file_paths)]
else:
paths = [str(file_path) for file_path in file_paths]
stats = _stats()
stats["files"] = len(paths)
rows = []
for file_path in paths:
rows.extend(_read_file(file_path, stats))
batch_id = None
if write_db and rows:
db.init_db(path)
batch_id = db.create_batch(paths, note=note, path=path)
for row in rows:
row["row_key"] = _row_key(
batch_id,
row["source_file_abs"],
row["source_sheet"],
row["source_row"],
)
stats["inserted"] = db.insert_tasks(batch_id, rows, path=path)
return {
"batch_id": batch_id,
"rows": rows,
"stats": stats,
}
def _account_alias(account) -> str:
if isinstance(account, dict):
return str(account.get("alias") or "").strip()
return str(getattr(account, "alias", "") or "").strip()
def match_summary(rows: list[dict], accounts: list) -> dict:
"""Summarize whether imported rows match configured account aliases."""
known_aliases = {_account_alias(account) for account in accounts if _account_alias(account)}
by_account = {}
unmatched_aliases = []
matched = 0
unmatched = 0
for row in rows:
alias = str(row.get("alias") or "").strip()
if alias in known_aliases:
matched += 1
by_account[alias] = by_account.get(alias, 0) + 1
else:
unmatched += 1
if alias and alias not in unmatched_aliases:
unmatched_aliases.append(alias)
return {
"matched": matched,
"unmatched": unmatched,
"by_account": by_account,
"unmatched_aliases": unmatched_aliases,
}
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
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 _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)
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 _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 = []
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 = _field_value(task, field)
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 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"}
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:
"""Export a write-back copy without touching the original Excel file."""
_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)