Files
pod_trend_agent/graph/nodes/template_export_node.py
T
3218485270 d68cc3b9e3 v106-v109 童装支持 + 标题模板外部化 + 模板导出增强
- 新增男童/女童检测(gender_from_category 优先判童装)与童装场景图生成
  (configs/kids_features.yaml:模特/场景/服装风格,同一商品固定同一组)
- 童装 SPU 字段映射:kids_type→SPU商品属性-类型、kids_age→适用年龄段、
  target_audience 按性别映射、kids_type_map 女童「上衣」→「针织上衣」
- 标题生成提示词外部化:prompts/title_prompt_{1,2,3}.md + config.yaml 路由表
- 模板多站点匹配:经营站点可配多个,命中任意一个即匹配
- 种草图生成失败自动重试(seed_shot.retries,换场景/模特/风格)
- 模板导出新增 Preview 文件夹(成功产品 _composite.oss.jpg + result.xlsx)
- 修复 SKU 尺码未按从小到大排序(_size_rank 支持单一年龄码 6Y/10Y)
2026-09-01 17:24:23 +08:00

176 lines
7.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""节点 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"
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"
def _build_preview(output_dir: Path, products: List[Dict[str, Any]]) -> None:
"""生成 Preview 文件夹:仅成功导入模板的产品(template_path 已设置),
复制其 _composite.oss.jpg(压缩版合成图)到 Preview,并生成 result.xlsx
(货号 | 中文标题 | 英文标题 三列)。"""
import shutil
from openpyxl import Workbook
preview_dir = output_dir / "Preview"
preview_dir.mkdir(parents=True, exist_ok=True)
rows: List[tuple] = []
for r in products:
if not r.get("template_path"):
continue
code = str(r.get("img_code") or r.get("oss_code") or "")
comp = r.get("composite_path") or r.get("printed_path")
if comp:
oss_file = Path(comp).with_suffix(".oss.jpg")
if oss_file.exists():
dst = preview_dir / oss_file.name
if not dst.exists():
shutil.copy2(oss_file, dst)
rows.append((code, r.get("cn_title") or "", r.get("en_title") or ""))
if rows:
wb = Workbook()
ws = wb.active
ws.title = "result"
ws.append(["货号", "中文标题", "英文标题"])
for row in rows:
ws.append(list(row))
wb.save(str(preview_dir / "result.xlsx"))
print(f"[template] Preview 已生成({len(rows)} 个成功产品): {preview_dir}")
@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_products, _resolve_component_map,
_resolve_season_map, _resolve_pattern_map,
_resolve_target_audience_map, _resolve_kids_type_map)
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)
# 批量合并导出:所有产品一次性写入同一模板,只打开/保存一次(避免逐产品频繁读写)
batch: List[Dict[str, Any]] = []
skipped = 0
for r in products:
# 失败跳过:合成图(composite/printed)与标题都失败的产品不写进模板
has_img = bool(r.get("composite_path") or r.get("printed_path"))
has_title = bool((r.get("en_title") or "").strip()) # 商品名称统一用 en_title
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 ""]
batch.append({
"spu_code": r.get("spu_code", ""),
"img_code": r.get("img_code", ""),
"sku_codes": sku_codes,
"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", ""),
"es_title": r.get("es_title", ""),
"composite_urls": r.get("color_composites") or [],
"seed_shot_urls": r.get("seed_shot_urls") or [],
})
# 写入模板前按货号(前端自定义前缀+3位计数,如 DG001)最后3位从小到大排序,按顺序插入
def _tail_num(rec: Dict[str, Any]) -> tuple:
code = str(rec.get("img_code") or rec.get("oss_code") or "")
try:
return (0, int(code[-3:]))
except ValueError:
return (1, 0)
batch.sort(key=_tail_num)
exported: List[str] = []
if batch:
# 输出文件名 = 模板原文件名 + _已填写(如 NEW-波兰男黑T恤_已填写.xlsx
out = _template_out_path(prod_dir, Path(tp).stem)
try:
out = export_products(
db_path, batch, tdir, tp, str(out),
markup_percent=float(pcfg.get("markup_percent") or 0),
suggested_price_ratio=float(pcfg.get("suggested_price_ratio") or 0),
component_map=_resolve_component_map(config),
season_map=_resolve_season_map(config),
pattern_map=_resolve_pattern_map(config),
target_audience_map=_resolve_target_audience_map(config),
kids_type_map=_resolve_kids_type_map(config),
)
for r in products:
if (r.get("composite_path") or r.get("printed_path")) and (r.get("en_title") or "").strip():
r["template_path"] = str(out)
exported.append(str(out))
print(f"[template] 商品上传模板已生成({len(batch)} 个产品一次合并): {out}")
_build_preview(output_dir, products)
except Exception as e: # noqa: BLE001
errors.append({"node": "template_export", "type": type(e).__name__,
"message": f"模板批量导出失败: {e}", "trace": ""})
print(f"[template] 模板批量导出失败: {e}")
stats["template_export"] = {"exported": len(exported)}
return {"product": products, "errors": errors, "stats": stats}