"""节点 9/9:商品上传模板导出(template_export)——在 seed_shot 之后。 把最终结果导入模板: - SPU货号 / SKU货号 = 设计货号(oss_code,前缀+3位计数) - 商品名称 = cn_title(多模态标题生成) - 英文名称 = en_title - 商品轮播图1:SKU 行按颜色路由(该颜色三合一链接),SPU 行随机一张 - 详情图文(SPU 行):全部三合一主图链接 + 种草图链接,| 分割 需在 oss_upload / seed_shot 之后运行(图床链接与货号已生成)。 """ import time from pathlib import Path from typing import Any, Dict, List from graph.paths import project_root, runtime_root from graph.validate import with_fallback def _template_out_path(prod_dir: Path, tpl_name: str) -> Path: """模板输出路径:默认 {tpl_name}_已填写.xlsx;已存在/被占用则自动换名加序号(同款号多产品不互相覆盖)。""" base = prod_dir / f"{tpl_name}_已填写.xlsx" try: with open(base, "ab"): pass except OSError: pass else: if not base.exists(): return base for i in range(2, 100): cand = prod_dir / f"{tpl_name}_已填写_{i}.xlsx" if not cand.exists(): return cand return prod_dir / f"{tpl_name}_已填写_{int(time.time())}.xlsx" @with_fallback("template_export") def template_export_node(state: Dict[str, Any]) -> Dict[str, Any]: products: List[Dict[str, Any]] = state.get("product") or [] config = state["config"] or {} pcfg = config.get("product") or {} output_dir = Path(state["output_dir"]) errors = list(state.get("errors") or []) stats = dict(state.get("stats") or {}) # 模板写入时机:所有集合/产品(含种草图、OSS)全部完成后才执行本节点 print(f"[template] 全部集合({len(products)} 个产品)处理完成,开始统一写入模板…") tp = (pcfg.get("template_path") or "").strip() if not tp: print("[template] 未配置 product.template_path,跳过模板导出") return {"stats": stats, "errors": errors} if not Path(tp).exists(): cand = None for root in (runtime_root(), project_root()): c = root / tp if c.exists(): cand = str(c) break if cand: tp = cand else: print(f"[template] 模板文件不存在: {tp}") return {"stats": stats, "errors": errors} # db 路径 dbp = (pcfg.get("db_path") or "db/spu_sku.db") db_path = Path(dbp) if not db_path.is_absolute(): for root in (runtime_root(), project_root()): if (root / db_path).exists(): db_path = root / db_path break from graph.template_export import export_product tdir = (pcfg.get("template_dir") or "").strip() or str(Path(tp).parent) prod_dir = output_dir / "product" prod_dir.mkdir(parents=True, exist_ok=True) exported: List[str] = [] skipped = 0 merged_out: Optional[str] = None # 合并模式:一次任务所有产品填同一个模板 is_first = True for r in products: # 失败跳过:合成图(composite/printed)与标题都失败的产品不写进模板 has_img = bool(r.get("composite_path") or r.get("printed_path")) has_title = bool((r.get("cn_title") or "").strip()) if not (has_img and has_title): skipped += 1 print(f"[template] 跳过失败产品 {r.get('spu_code')}/{r.get('img_code','')}: " f"合成图={'有' if has_img else '无'} 标题={'有' if has_title else '无'}(不写入模板)") continue sku_codes = [cc.get("sku_code") for cc in (r.get("color_composites") or [])] if not sku_codes: sku_codes = [r.get("sku_code") or ""] try: if is_first: merged_out = str(_template_out_path(prod_dir, "商品上传")) out = export_product( db_path, r.get("spu_code", ""), sku_codes, tdir, tp, merged_out, images=[], spu_per_color=True, # 每颜色一个独立 SPU 块(单色多 SPU) oss_code=r.get("oss_code") or (r.get("color_composites") or [{}])[0].get("code", ""), cn_title=r.get("cn_title", ""), en_title=r.get("en_title", ""), ja_title=r.get("ja_title", ""), composite_urls=r.get("color_composites") or [], seed_shot_urls=r.get("seed_shot_urls") or [], append_to="" if is_first else merged_out, # 首个产品从模板创建,后续追加合并 markup_percent=float(pcfg.get("markup_percent") or 0), ) r["template_path"] = str(out) exported.append(str(out)) print(f"[template] 商品上传模板已生成({len(exported)}/{len(products)} 合并): {out}") except Exception as e: # noqa: BLE001 errors.append({"node": "template_export", "type": type(e).__name__, "message": f"模板导出失败 {r.get('spu_code')}: {e}", "trace": ""}) print(f"[template] 模板导出失败 {r.get('spu_code')}: {e}") is_first = False stats["template_export"] = {"exported": len(exported)} return {"product": products, "errors": errors, "stats": stats}