- 新增男童/女童检测(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)
176 lines
7.9 KiB
Python
176 lines
7.9 KiB
Python
"""节点 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}
|