Files
pod_trend_agent/graph/template_export.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

1230 lines
59 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
"""商品上传模板导出:从 db 读 SPU/SKU → 调 template_router 路由填入上传模板 Excel。
流程(product_node 生成产品图后调用):
1. 从 spu_sku.db 读 SPU(款号)+ 该款选定颜色的全部尺码 SKU;
2. 用 model/template_router.py 的 TemplateRouter
- insert 一行 SPUSPU货号 + 商品属性字段)
- 每个尺码 insert 一行 SKU(路由到 SPU 行下方,SKU货号 = 款号-颜色编码-尺码,填尺码表)
- 商品轮播图1~N 填生成的产品图路径(底图/印花/模特/合成)
3. save 输出 <模板名>_已填写.xlsx 到指定目录。
字段映射:db 字段名 → 上传模板列名(见 SPU_MAP / SKU_MAP)。
"""
import re
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional
from graph.product import _connect
# 商品轮播图列名关键词(模板存在中/英/日变体,如 商品轮播图1 / Product Carousel Image 1 / 商品カルーセル画像1)
_CAROUSEL_KW = ("轮播", "carousel", "カルーセル")
# 成分值字典映射:db 成分值 → 女装模板下拉框选项(男装模板选项与 db 值一致,直接保留)。
# 女装模板(如 SatVoy 沙特)成分下拉框是「中文+英文」格式(棉Cotton),db 存中文(棉),需映射。
# 该字典为「内置默认」,运行时可被 config.yaml 顶层 component_map.female 覆盖(缺失/留空回退这里)。
_COMPONENT_FEMALE_MAP = {
"棉": "棉Cotton",
"聚酯纤维": "聚酯纤维(涤纶)Polyester",
"氨纶": "氨纶Elastane",
"锦纶": "锦纶(尼龙)Polyamide",
"再生聚酯纤维": "聚酯纤维(涤纶)Polyester", # 女装无再生聚酯纤维,归入聚酯纤维
"棉Cotton": "棉Cotton", # 已是女装格式,保持
}
def _resolve_component_map(config: Optional[Dict[str, Any]]) -> Dict[str, Any]:
"""从 config 读取可配置成分映射(config.component_map.female),缺失/留空回退内置默认。
仅作用于 component_1/2/3material 字段不经过任何映射,原样透传。
"""
if not config:
return dict(_COMPONENT_FEMALE_MAP)
female = None
try:
cm = config.get("component_map") or {}
female = (cm.get("female") or {}) if isinstance(cm, dict) else {}
except Exception: # noqa: BLE001
female = {}
if not isinstance(female, dict) or not female:
return dict(_COMPONENT_FEMALE_MAP)
merged = dict(_COMPONENT_FEMALE_MAP)
merged.update({str(k): v for k, v in female.items() if v not in (None, "")})
return merged
def _map_component(value: Any, gender: Optional[str], cmp_map: Optional[Dict[str, Any]] = None) -> Any:
"""成分值按性别映射(仅 component 字段,不作用于 material):male/None 保留原值;
female 查成分映射表(默认内置、可由 config 覆盖,找不到保留原值)。"""
if gender != "female" or value in (None, ""):
return value
mapping = cmp_map if cmp_map is not None else _COMPONENT_FEMALE_MAP
return mapping.get(value, value)
# 季节值字典映射:db 季节值 → 女装模板下拉框选项(男装模板与 db 值一致,直接保留)。
# 女装模板(如 SatVoy 沙特)季节下拉框用「ALL/全球/所有」等英文+中文格式,db 存「四季」,需映射。
# 该字典为「内置默认」,运行时可被 config.yaml 顶层 season_map.female 覆盖(缺失/留空回退这里)。
_SEASON_FEMALE_MAP = {
"四季": "ALL/全球/所有",
}
# 印花图案值字典映射:db pattern 值 → 女装模板下拉框选项(男装模板与 db 值一致,直接保留)。
# 女装模板遇到 pattern="印花" 时映射为「卡通」;该字典为「内置默认」,
# 运行时可被 config.yaml 顶层 pattern_map.female 覆盖(缺失/留空回退这里)。
_PATTERN_FEMALE_MAP = {
"印花": "卡通",
}
def _resolve_pattern_map(config: Optional[Dict[str, Any]]) -> Dict[str, Any]:
"""从 config 读取可配置图案映射(config.pattern_map.female),缺失/留空回退内置默认。
仅作用于 spu.pattern 字段(女装模板时映射,男装/None 保留原值)。
"""
if not config:
return dict(_PATTERN_FEMALE_MAP)
female = None
try:
pm = config.get("pattern_map") or {}
female = (pm.get("female") or {}) if isinstance(pm, dict) else {}
except Exception: # noqa: BLE001
female = {}
if not isinstance(female, dict) or not female:
return dict(_PATTERN_FEMALE_MAP)
merged = dict(_PATTERN_FEMALE_MAP)
merged.update({str(k): v for k, v in female.items() if v not in (None, "")})
return merged
def _map_pattern(value: Any, gender: Optional[str], pattern_map: Optional[Dict[str, Any]] = None) -> Any:
"""图案值按性别映射(仅 spu.pattern 字段):male/None 保留原值;
female 查图案映射表(默认内置、可由 config 覆盖,找不到保留原值),如「印花」→「卡通」。"""
if gender != "female" or value is None:
return value
v = str(value).strip()
mapping = pattern_map if pattern_map is not None else _PATTERN_FEMALE_MAP
return mapping.get(v, value)
def _resolve_season_map(config: Optional[Dict[str, Any]]) -> Dict[str, Any]:
"""从 config 读取可配置季节映射(config.season_map.female),缺失/留空回退内置默认。
仅作用于 spu.season 字段(女装模板时映射,男装/None 保留原值)。
"""
if not config:
return dict(_SEASON_FEMALE_MAP)
female = None
try:
sm = config.get("season_map") or {}
female = (sm.get("female") or {}) if isinstance(sm, dict) else {}
except Exception: # noqa: BLE001
female = {}
if not isinstance(female, dict) or not female:
return dict(_SEASON_FEMALE_MAP)
merged = dict(_SEASON_FEMALE_MAP)
merged.update({str(k): v for k, v in female.items() if v not in (None, "")})
return merged
def _map_season(value: Any, gender: Optional[str], season_map: Optional[Dict[str, Any]] = None) -> Any:
"""季节值按性别映射(仅 spu.season 字段):male/None 保留原值;
female 查季节映射表(默认内置、可由 config 覆盖,找不到保留原值)。"""
if gender != "female" or value in (None, ""):
return value
mapping = season_map if season_map is not None else _SEASON_FEMALE_MAP
return mapping.get(str(value), value)
# 适用人群值字典映射:db target_audience 值 → 童装模板下拉框选项(男童/女童互映射)。
# 女童模板遇到 target_audience="男童" 时映射为「女童」;男童模板遇到「女童」时映射为「男童」。
# 该字典为「内置默认」,运行时可被 config.yaml 顶层 target_audience_map.kids 覆盖(缺失/留空回退这里)。
_TARGET_AUDIENCE_KIDS_MAP = {
"girl_kids": {"男童": "女童"},
"boy_kids": {"女童": "男童"},
}
def _resolve_target_audience_map(config: Optional[Dict[str, Any]]) -> Dict[str, Any]:
"""从 config 读取可配置适用人群映射(config.target_audience_map.kids),缺失/留空回退内置默认。"""
if not config:
return {g: dict(m) for g, m in _TARGET_AUDIENCE_KIDS_MAP.items()}
kids = None
try:
tm = config.get("target_audience_map") or {}
kids = (tm.get("kids") or {}) if isinstance(tm, dict) else {}
except Exception: # noqa: BLE001
kids = {}
if not isinstance(kids, dict) or not kids:
return {g: dict(m) for g, m in _TARGET_AUDIENCE_KIDS_MAP.items()}
merged = {g: dict(m) for g, m in _TARGET_AUDIENCE_KIDS_MAP.items()}
for g, m in kids.items():
if isinstance(m, dict):
merged.setdefault(str(g), {}).update(
{str(k): v for k, v in m.items() if v not in (None, "")})
return merged
def _map_target_audience(value: Any, gender: Optional[str],
kids_map: Optional[Dict[str, Any]] = None) -> Any:
"""适用人群值按童装性别映射(仅 spu.target_audience 字段):gender 为 boy_kids/girl_kids 时
查适用人群映射表(默认内置、可由 config 覆盖,找不到保留原值),如「男童」→「女童」。"""
if gender not in ("boy_kids", "girl_kids") or value in (None, ""):
return value
mapping = kids_map if kids_map is not None else _TARGET_AUDIENCE_KIDS_MAP
return mapping.get(gender, {}).get(str(value), value)
# kids_type 值字典映射:db kids_type 值 → 模板「SPU商品属性-类型」下拉框选项(仅女童模板映射)。
# 女童模板遇到 kids_type="上衣" 时映射为「针织上衣」;男童模板保留原值。
# 该字典为「内置默认」,运行时可被 config.yaml 顶层 kids_type_map.girl_kids 覆盖(缺失/留空回退这里)。
_KIDS_TYPE_GIRL_MAP = {
"girl_kids": {"上衣": "针织上衣"},
}
def _resolve_kids_type_map(config: Optional[Dict[str, Any]]) -> Dict[str, Any]:
"""从 config 读取可配置 kids_type 映射(config.kids_type_map),缺失/留空回退内置默认。"""
if not config:
return {g: dict(m) for g, m in _KIDS_TYPE_GIRL_MAP.items()}
kids = None
try:
ktm = config.get("kids_type_map") or {}
kids = ktm if isinstance(ktm, dict) else {}
except Exception: # noqa: BLE001
kids = {}
if not isinstance(kids, dict) or not kids:
return {g: dict(m) for g, m in _KIDS_TYPE_GIRL_MAP.items()}
merged = {g: dict(m) for g, m in _KIDS_TYPE_GIRL_MAP.items()}
for g, m in kids.items():
if isinstance(m, dict):
merged.setdefault(str(g), {}).update(
{str(k): v for k, v in m.items() if v not in (None, "")})
return merged
def _map_kids_type(value: Any, gender: Optional[str],
kids_type_map: Optional[Dict[str, Any]] = None) -> Any:
"""kids_type 值按童装性别映射(仅 spu.kids_type 字段):gender 为 girl_kids 时
查 kids_type 映射表(默认内置、可由 config 覆盖,找不到保留原值),如「上衣」→「针织上衣」。
男童/成人保留原值。"""
if gender != "girl_kids" or value in (None, ""):
return value
mapping = kids_type_map if kids_type_map is not None else _KIDS_TYPE_GIRL_MAP
return mapping.get("girl_kids", {}).get(str(value), value)
def _template_gender(template_path: str) -> Optional[str]:
"""通过表头解析模版「类目」判断男装/女装:类目含「男」→male;含「女」→female;都不含→None。
用于成分值映射(女装模板下拉框是「中文+英文」格式,需字典映射;男装保留 db 值)。"""
try:
from graph.seed_shot import gender_from_category, read_template_category
return gender_from_category(read_template_category(template_path))
except Exception: # noqa: BLE001
return None
def _size_rank(size) -> tuple:
"""把尺码字符串转成可排序 rank(从小到大)。
优先级:数字码(90/100...) < 字母码(XS/S/M/L/XL/XXL/3XL...) < 童装年龄码(0/3M、1-2Y...)
< 尺寸规格(30*40...) < 未知(字典序兜底) < 均码/Onesize(最后)。
"""
s = str(size or "").strip().upper()
if not s:
return (9, 0, "")
if s in ("ONESIZE", "ONE SIZE", "FREESIZE", "FREE SIZE", "均码"):
return (8, 0, s)
if s == "XS":
return (2, 0, s)
if s in ("S", "M", "L"):
return (2, {"S": 1, "M": 2, "L": 3}[s], s)
m = re.fullmatch(r"(X+)(L)", s) # XL/XXL/XXXL/XXXXL/XXXXXL
if m:
return (2, 3 + len(m.group(1)), s)
m = re.fullmatch(r"(\d+)XL", s) # 3XL/4XL/5XL
if m:
return (2, 3 + int(m.group(1)), s)
if s.isdigit(): # 数字码:90/100/110...
return (1, int(s), s)
m = re.fullmatch(r"(\d+)\s*(?:M|Y|YRS|YR)", s) # 单一年龄码:6Y/10Y/18M
if m:
return (3, float(m.group(1)), s)
m = re.fullmatch(r"(\d+)\s*[-/]\s*(\d+)\s*(?:M|Y|YRS|YR)?", s) # 童装年龄码范围:1-2Y/6/7Y
if m:
return (3, (int(m.group(1)) + int(m.group(2))) / 2.0, s)
m = re.search(r"(\d+(?:\.\d+)?)\s*(?:CM)?\s*[X*]", s) # 尺寸规格:30*40/12CM X 12CM
if m:
return (4, float(m.group(1)), s)
return (5, 0, s) # 未知格式:字典序兜底
def _carousel_col(router, idx: int) -> Optional[int]:
"""定位「商品轮播图{idx}」列号:先精确匹配(中文列名),失败则按中/英/日关键词模糊匹配序号。"""
try:
return router.resolve_col(f"商品轮播图{idx}")
except KeyError:
pass
for name, col in router.column_map.items():
low = str(name).lower().replace(" ", "").replace(" ", "")
if not any(k in low for k in _CAROUSEL_KW):
continue
m = re.search(r"(\d+)$", low)
if m and int(m.group(1)) == idx:
return col
return None
def _detail_col(router) -> Optional[int]:
"""定位「详情图文」列:优先英语(详情图文-英语),回退日语(详情图文-日语),再回退任意详情图文。"""
for name in ("详情图文-英语", "详情图文-英文", "详情图文-EN"):
try:
return router.resolve_col(name)
except KeyError:
pass
try:
return router.resolve_col("详情图文-日语")
except KeyError:
pass
for name, col in router.column_map.items():
if "详情图文" in str(name):
return col
return None
def _ja_col(router) -> Optional[int]:
"""定位「日语名称」列(基础信息组,如 日语名称/日语标题)。"""
try:
return router.resolve_col("日语名称")
except KeyError:
pass
for name, col in router.column_map.items():
low = str(name)
if "日语" in low and "详情图文" not in low and "轮播图" not in low and "名称" in low:
return col
return None
def _es_col(router) -> Optional[int]:
"""定位「西语名称」列(基础信息组,如 西语名称/西班牙语名称)。"""
try:
return router.resolve_col("西语名称")
except KeyError:
pass
for name, col in router.column_map.items():
low = str(name)
if ("西语" in low or "西班牙" in low) and "详情图文" not in low and "轮播图" not in low and "名称" in low:
return col
return None
def _fill_design_fields(router, spu_code: str, oss_code: str, cn_title: str, en_title: str,
ja_title: str, es_title: str, composite_by_sku: Dict[str, Any],
all_composite_urls: List[str], seed_shot_urls: List[str],
only_rows: Optional[List[int]] = None) -> None:
"""按用户要求填充设计联动字段:
- SPU 行:SPU货号=设计货号、SKU货号=设计货号、商品名称=en_title、英文名称=en_title、
日语名称=ja_title、西语名称=es_title、商品轮播图1=随机一张三合一主图、
详情图文=全部主图+种草图 链接 | 分割(不含 img_url_2
- SKU 行:SPU货号=设计货号、SKU货号=该颜色货号、商品轮播图1=该颜色三合一链接、
商品名称/英文名称/日语名称/西语名称 与 SPU 一致
only_rows:合并模式下只填充本产品块的行(None=该款全部行)
"""
import random
try:
color_col = router.resolve_col("色值(主规格)")
except KeyError:
color_col = None
try:
spu_col = router.resolve_col("SPU货号")
except KeyError:
spu_col = None
try:
name_col = router.resolve_col("商品名称")
except KeyError:
name_col = None
try:
en_col = router.resolve_col("英文名称")
except KeyError:
en_col = None
try:
ja_col = _ja_col(router)
except KeyError:
ja_col = None
try:
es_col = _es_col(router)
except KeyError:
es_col = None
try:
sku_code_col = router.resolve_col("SKU货号")
except KeyError:
sku_code_col = None
car1 = _carousel_col(router, 1)
detail_col = _detail_col(router)
for row in router.find_spu_rows(spu_code):
if only_rows is not None and row not in only_rows:
continue # 合并模式:只填本产品块的行
lvl = str(router.ws.cell(row, router.level_col).value or "").strip().lower()
color = str(router.ws.cell(row, color_col).value or "").strip() if color_col else ""
if lvl == "spu":
if spu_col and oss_code:
router.ws.cell(row, spu_col, oss_code)
if sku_code_col and oss_code:
router.ws.cell(row, sku_code_col, oss_code)
if name_col and en_title:
router.ws.cell(row, name_col, en_title) # 商品名称统一用 en_title
if en_col and en_title:
router.ws.cell(row, en_col, en_title)
if ja_col and ja_title:
router.ws.cell(row, ja_col, ja_title)
if es_col and es_title:
router.ws.cell(row, es_col, es_title)
if car1 is not None and all_composite_urls:
router.ws.cell(row, car1, random.choice(all_composite_urls)) # SPU 轮播图1 随机
if detail_col is not None:
links = [u for u in (all_composite_urls + list(seed_shot_urls or [])) if u]
if links:
router.ws.cell(row, detail_col, "|".join(links)) # 详情图文 | 分割
else:
if spu_col and oss_code:
router.ws.cell(row, spu_col, oss_code)
# SKU 行与 SPU 一致:商品名称/英文名称/日语名称/西语名称
if name_col and en_title:
router.ws.cell(row, name_col, en_title) # 商品名称统一用 en_title
if en_col and en_title:
router.ws.cell(row, en_col, en_title)
if ja_col and ja_title:
router.ws.cell(row, ja_col, ja_title)
if es_col and es_title:
router.ws.cell(row, es_col, es_title)
cc = composite_by_sku.get(color) or composite_by_sku.get("") # 按色值匹配该颜色主图
if sku_code_col and oss_code:
router.ws.cell(row, sku_code_col, oss_code) # SKU货号=SPU货号(同一货号)
if car1 is not None and cc and cc.get("url"):
router.ws.cell(row, car1, cc["url"]) # 该颜色轮播图1
def _build_spu_row(spu: Dict[str, Any], spu_code: str,
color: Optional[str] = None,
fabric_headers: Optional[List[str]] = None,
gender: Optional[str] = None,
component_map: Optional[Dict[str, Any]] = None,
season_map: Optional[Dict[str, Any]] = None,
pattern_map: Optional[Dict[str, Any]] = None,
target_audience_map: Optional[Dict[str, Any]] = None,
kids_type_map: Optional[Dict[str, Any]] = None,
oss_code: str = "",
model_info: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
"""构造一行 SPU(固定字段:SKC货号=当前生成货号、风格=休闲、商品产地=中国大陆、产地省份=广东省;多颜色时用色值区分)。
fabric 填「面料弹性」列(fabric_headers,如 SPU商品属性-面料弹性,检测到才填 spu.fabric)。
component_1/2/3 按性别映射(gender=female 时查成分映射表 component_map,男装/None 保留原值;
material 字段不经过任何映射,原样透传)。
season 按性别映射(gender=female 时查季节映射表 season_map,如「四季」→「ALL/全球/所有」)。
pattern 按性别映射(gender=female 时空值查图案映射表 pattern_map,如空值→「卡通」)。
target_audience 按童装性别映射(gender=boy_kids/girl_kids 时查适用人群映射表 target_audience_map
如「男童」→「女童」)。
kids_type 按童装性别映射(gender=girl_kids 时查 kids_type 映射表 kids_type_map,如「上衣」→「针织上衣」;
男童保留原值)。"""
row: Dict[str, Any] = {
"基础信息-商品层级": "spu",
"SKC货号": oss_code or spu_code, # SKC货号 = 当前生成货号 oss_code(无则回退 spu_code
"风格": "休闲", # style 路由为"休闲"(用户要求)
"商品产地": "中国大陆", # 所有国家统一「中国大陆」,不读经营站点/不做字典匹配(用户要求)
"产地省份": "广东省", # 新增:精确匹配「产地省份」列,统一填「广东省」(用户要求)
"款式来源": "现货款", # SPU商品属性-款式来源 统一填「现货款」(用户要求)
}
if color:
row["色值(主规格)"] = color
for dbk, header in SPU_MAP.items():
v = spu.get(dbk)
if dbk in ("component_1", "component_2", "component_3"):
v = _map_component(v, gender, component_map)
elif dbk == "season":
v = _map_season(v, gender, season_map)
elif dbk == "pattern":
v = _map_pattern(v, gender, pattern_map)
elif dbk == "target_audience":
v = _map_target_audience(v, gender, target_audience_map)
if v not in (None, ""):
row[header] = v
fabric = spu.get("fabric")
if fabric not in (None, "") and fabric_headers:
for h in fabric_headers:
row[h] = fabric
if model_info:
if model_info.get("model"):
row["试穿模特"] = model_info["model"]
if model_info.get("size"):
row["试穿尺码"] = model_info["size"]
if model_info.get("feel"):
row["试穿感受"] = model_info["feel"]
# 童装(男童/女童):kids_type → SPU商品属性-类型(女童查 kids_type 映射表,如「上衣」→「针织上衣」),
# kids_age → 适用年龄段(仅童装模板填)
if gender in ("boy_kids", "girl_kids"):
for dbk, header in (("kids_type", "SPU商品属性-类型"), ("kids_age", "适用年龄段")):
v = spu.get(dbk)
if dbk == "kids_type":
v = _map_kids_type(v, gender, kids_type_map)
if v not in (None, ""):
row[header] = v
return row
def _find_price_headers(router) -> List[str]:
"""定位所有「申报价格」列(美站/日站/英站…模糊匹配);匹配到多个时全部返回,统一填加价后价格。"""
hits = [str(k) for k in router.column_map if "申报价格" in str(k)]
return hits or ["申报价格-日本站"]
def _find_bust_headers(router) -> List[str]:
"""定位所有「胸围」列(基码表-胸围(cm)/胸围全围(cm)…模糊匹配);匹配到多个时全部填 sku.bust。"""
hits = [str(k) for k in router.column_map if "胸围" in str(k)]
return hits or ["胸围全围(cm"]
def _find_fabric_headers(router) -> List[str]:
"""定位所有「面料弹性」列(SPU商品属性-面料弹性…模糊匹配);匹配到多个时全部填 spu.fabric。"""
return [str(k) for k in router.column_map if "面料弹性" in str(k)]
def _find_sa_size_headers(router) -> List[str]:
"""定位所有「沙特阿拉伯码」列(尺码表-沙特阿拉伯码…模糊匹配);匹配到多个时全部填 sku.size。"""
return [str(k) for k in router.column_map if "沙特阿拉伯码" in str(k)]
def _find_suggested_price_headers(router) -> List[str]:
"""定位「建议售价」列(不含「单位」);匹配到多个时全部返回。"""
return [str(k) for k in router.column_map if "建议售价" in str(k) and "单位" not in str(k)]
def _find_suggested_unit_headers(router) -> List[str]:
"""定位「建议售价单位」列。"""
return [str(k) for k in router.column_map if "建议售价单位" in str(k)]
def _read_range_values(ws, ref: str) -> Optional[set]:
"""读取单元格/区域内的非空值集合;解析失败返回 None。"""
try:
cells = ws[ref]
except Exception: # noqa: BLE001
return None
opts = []
if isinstance(cells, (list, tuple)):
for row in cells:
for cell in row:
if cell.value not in (None, ""):
opts.append(str(cell.value).strip())
else:
if cells.value not in (None, ""):
opts.append(str(cells.value).strip())
return set(opts) if opts else None
def _resolve_named_range(router, values) -> Optional[set]:
"""级联下拉:INDIRECT 引用的单元格值可能是名称引用(如「欧美尺码常规」)。
从 defined names 解析该名称对应的区域(可能跨表,如 ProductSizeSpec!$B$6:$I$6),
读取区域内容作为真实选项(如 S/M/L/XL/XXL/XXXL)。解析不到返回 None。"""
wb = router.ws.parent
resolved = set()
for v in values:
name = str(v).strip()
if name not in wb.defined_names:
continue
ref = wb.defined_names[name].value # 如 ProductSizeSpec!$B$6:$I$6
sheet_name, _, cell_ref = ref.partition("!")
if not cell_ref:
continue
ws = wb[sheet_name] if sheet_name in wb.sheetnames else router.ws
opts = _read_range_values(ws, cell_ref)
if opts:
resolved |= opts
return resolved if resolved else None
def _read_range_values_any(router, ref: str) -> Optional[set]:
"""读取单元格/区域内的非空值集合,支持跨表(Sheet!$A$1:$A$5);解析失败返回 None。"""
sheet_name, _, cell_ref = ref.partition("!")
if cell_ref:
ws = router.ws.parent[sheet_name] if sheet_name in router.ws.parent.sheetnames else router.ws
else:
ws = router.ws
cell_ref = ref
return _read_range_values(ws, cell_ref)
def _read_dropdown_options(router, col: Optional[int]) -> Optional[set]:
"""读取指定列(col)的 dataValidation 下拉选项集合;无下拉/非 list/无值 返回 None。
支持内联列表("S,M,L")、引用区域(=Sheet!$A$1:$A$5)、跨表引用(Sheet!$A$1:$A$5)、
INDIRECT 动态引用(含名称引用级联)。"""
import re
from openpyxl.utils import get_column_letter
if col is None:
return None
lf = get_column_letter(col)
dvs = getattr(router.ws, "data_validations", None)
if dvs is None:
return None
for dv in dvs.dataValidation:
if getattr(dv, "type", "") != "list":
continue
if lf not in str(getattr(dv, "sqref", "")):
continue
f = (getattr(dv, "formula1", "") or "").strip()
if not f:
continue
# 动态引用 INDIRECT(...):尝试解析引用的单元格/区域内容;无法解析 → None
m = re.match(r"^=?INDIRECT\((.+)\)$", f, re.IGNORECASE)
if m:
opts = _read_range_values(router.ws, m.group(1).strip())
if opts:
named = _resolve_named_range(router, opts)
if named:
return named
return opts if opts else None
# 引用区域(可能跨表:Sheet!$A$1:$A$5 或 =Sheet!$A$1:$A$5
ref = f[1:] if f.startswith("=") else f
if ":" in ref:
opts = _read_range_values_any(router, ref)
return opts if opts else None
# 内联列表("S,M,L" / "S;M;L" / "SML"
sep = "" if "" in f else (";" if ";" in f else ",")
opts = [x.strip().strip('"') for x in f.split(sep) if x.strip()]
return set(opts) if opts else None
return None
def _read_size_options(router) -> Optional[set]:
"""读取「尺码」列的 dataValidation 下拉选项集合;无下拉/非 list/无值 返回 None。
仅作「尺码下拉受限时过滤 SKU 行」用:sku.size 不在下拉选项内则该 SKU 行跳过。
支持下拉直接内联("S,M,L")、引用区域("=Sheet!$A$1:$A$5")、动态引用(INDIRECT(...))。
动态引用无法解析(INDIRECT 指向空单元格/公式/跨表)→ 返回 None(不启用过滤,避免误杀全部 SKU 行)。"""
import re
from openpyxl.utils import get_column_letter
col = router.column_map.get("尺码") or router.column_map.get("商品规格-尺码")
if col is None:
return None
lf = get_column_letter(col)
dvs = getattr(router.ws, "data_validations", None)
if dvs is None:
return None
for dv in dvs.dataValidation:
if getattr(dv, "type", "") != "list":
continue
if lf not in str(getattr(dv, "sqref", "")):
continue
f = (getattr(dv, "formula1", "") or "").strip()
if not f:
continue
# 动态引用 INDIRECT(...):尝试解析引用的单元格/区域内容;无法解析 → 不过滤
m = re.match(r"^=?INDIRECT\((.+)\)$", f, re.IGNORECASE)
if m:
opts = _read_range_values(router.ws, m.group(1).strip())
if opts:
# 级联下拉:INDIRECT 引用的单元格值可能是名称引用(如「欧美尺码常规」),
# 从 defined names 解析对应命名区域(跨表)得到真实尺码选项
named = _resolve_named_range(router, opts)
if named:
return named
return opts if opts else None
# 引用区域(=Sheet!$A$1:$A$5 或 Sheet!$A$1:$A$5
ref = f[1:] if f.startswith("=") else f
if ":" in ref:
opts = _read_range_values(router.ws, ref)
return opts if opts else None
# 内联列表("S,M,L" / "S;M;L" / "SML"
sep = "" if "" in f else (";" if ";" in f else ",")
opts = [x.strip() for x in f.split(sep) if x.strip()]
return set(opts) if opts else None
return None
def _find_sku_category_headers(router) -> List[str]:
"""定位「SKU分类」列(模糊匹配,同建议售价检测方式);匹配到多个时全部返回。"""
return [str(k) for k in router.column_map if "SKU分类" in str(k)]
def _read_model_info(router, sku_sizes: Optional[set]) -> Dict[str, str]:
"""读取模板「模特信息」三属性(试穿模特/试穿尺码/试穿感受)的 SPU 行填写值。
优先用模板数据区已有值(校验通过才用):
- 试穿模特:值须在下拉框选项内(如 ModalConfig 表)
- 试穿感受:值须在 [偏小/合身/偏大] 内
- 试穿尺码:值须是商品有效尺码(sku 尺码之一)
模板无值或校验失败 → 默认值:试穿模特=下拉框第一个、试穿感受=合身、试穿尺码=L。"""
col_model = router.column_map.get("试穿模特") or router.column_map.get("模特信息-试穿模特")
col_size = router.column_map.get("试穿尺码") or router.column_map.get("模特信息-试穿尺码")
col_feel = router.column_map.get("试穿感受") or router.column_map.get("模特信息-试穿感受")
model_opts = _read_dropdown_options(router, col_model) if col_model else None
feel_opts = _read_dropdown_options(router, col_feel) if col_feel else None
# 模板数据区(第 6 行起)已有值
tpl_model = tpl_size = tpl_feel = None
ws = router.ws
for r in range(6, min(ws.max_row, 30) + 1):
if col_model and tpl_model is None and ws.cell(r, col_model).value not in (None, ""):
tpl_model = str(ws.cell(r, col_model).value).strip()
if col_size and tpl_size is None and ws.cell(r, col_size).value not in (None, ""):
tpl_size = str(ws.cell(r, col_size).value).strip()
if col_feel and tpl_feel is None and ws.cell(r, col_feel).value not in (None, ""):
tpl_feel = str(ws.cell(r, col_feel).value).strip()
# 试穿模特:模板值在下拉框内才用,否则下拉框第一个
if tpl_model and model_opts and tpl_model in model_opts:
model = tpl_model
elif model_opts:
model = sorted(model_opts)[0]
else:
model = tpl_model or ""
# 试穿感受:模板值在 [偏小/合身/偏大] 内才用,否则「合身」
feel = tpl_feel if tpl_feel and feel_opts and tpl_feel in feel_opts else "合身"
# 试穿尺码:模板值是有效尺码才用,否则「L」
size = tpl_size if tpl_size and (not sku_sizes or tpl_size in sku_sizes) else "L"
return {"model": model, "size": size, "feel": feel}
def _find_sku_qty_headers(router) -> List[str]:
"""定位「SKU数量」列(排除「SKU数量单位」列);匹配到多个时全部返回。"""
return [str(k) for k in router.column_map if "SKU数量" in str(k) and "单位" not in str(k)]
def _find_sku_qty_unit_headers(router) -> List[str]:
"""定位「SKU数量单位」列。"""
return [str(k) for k in router.column_map if "SKU数量单位" in str(k)]
def _build_sku_row(spu_code: str, sc: str, sk: Dict[str, Any], size: str, color: str,
warehouses: List[str], markup_percent: float = 0.0,
multi: bool = True, price_header: str = "申报价格-日本站",
bust_headers: Optional[List[str]] = None,
price_headers: Optional[List[str]] = None,
sa_size_headers: Optional[List[str]] = None,
suggested_price_ratio: float = 0.0,
suggested_price_headers: Optional[List[str]] = None,
suggested_unit_headers: Optional[List[str]] = None,
sku_category_headers: Optional[List[str]] = None,
sku_qty_headers: Optional[List[str]] = None,
sku_qty_unit_headers: Optional[List[str]] = None,
oss_code: str = "",
size_options: Optional[set] = None) -> Dict[str, Any]:
"""构造一行 SKU(固定字段:SPU货号、SKC货号=当前生成货号 oss_code、规格类型2、币种 CNY、发货仓1~N 及库存 200)。
价格(price_headers 列,如 申报价格-美国站/日本站,模糊匹配到多个时全部填)= SKU.price × (1+markup/100)
预先填好。建议售价(suggested_price_headers 列,模板「建议售价」必填时才填)= 申报价格 × (1+suggested_price_ratio/100)
填了建议售价同时填「建议售价单位」=CNY。bust 填所有「胸围」列(bust_headers,如 基码表-胸围(cm)/胸围全围(cm),检测到才填)。
size 填「尺码」列 + 所有「沙特阿拉伯码」列(sa_size_headers,如 尺码表-沙特阿拉伯码,检测到才填)。
规格类型2 统一填「尺码」两个字(不是 size 参数值)。"""
row: Dict[str, Any] = {
"基础信息-商品层级": "sku",
"SPU货号": spu_code,
"SKC货号": oss_code or spu_code, # SKC货号 = 当前生成货号 oss_code(无则回退 spu_code
"色值(主规格)": color,
"规格类型2": "尺码", # 规格类型2 统一填「尺码」(不填 size 值)
"币种": "CNY",
}
if price_headers is None:
price_headers = [price_header]
if bust_headers is None:
bust_headers = ["胸围全围(cm"]
if sa_size_headers is None:
sa_size_headers = []
for j, w in enumerate(warehouses, start=1):
row[f"发货仓{j}"] = w
row[f"发货仓{j}库存"] = 200
for dbk, header in SKU_MAP.items():
if dbk == "color":
continue
v = sk.get(dbk)
if dbk == "bust":
if v in (None, ""):
continue
for h in bust_headers:
row[h] = v
continue
if dbk == "price":
if v in (None, ""):
continue
v = round(float(v) * (1 + markup_percent / 100), 2) # 申报价格 = price × (1+加价%)
for h in price_headers:
row[h] = v
# 建议售价 = 申报价格 × (1+建议售价比例%);统一必填,填了同时填单位 CNY
if suggested_price_headers and suggested_price_ratio > 0:
suggested = round(v * (1 + suggested_price_ratio / 100), 2)
for h in suggested_price_headers:
row[h] = suggested
for uh in suggested_unit_headers:
row[uh] = "CNY"
continue
if dbk == "size":
if v in (None, ""):
continue
if size_options is not None and v not in size_options:
return None # 尺码不在模板下拉框选项内,跳过该 SKU 行
row[header] = v
for h in sa_size_headers:
row[h] = v
continue
if v not in (None, ""):
row[header] = v
# SKU 分类/数量/数量单位:模板检测到这些列(且非「非必填」)时统一填固定值(单品 / 1 / 件)
for h in (sku_category_headers or []):
row[h] = "单品"
for h in (sku_qty_headers or []):
row[h] = 1
for h in (sku_qty_unit_headers or []):
row[h] = "件"
return row
def _fill_sku_carousel(router, spu_code: str, color: str, color_col: int,
first: Dict[str, Any], sku_imgs: List[str]) -> None:
"""SKU 行商品轮播图2~5db img_url_2~5 优先,无则回退生成图;按色值列匹配所属 SKU 行。"""
for j in range(2, 6):
url = first.get(f"img_url_{j}") # db CDN url(该颜色 SKU 的 img_url_2~5
img = url if url not in (None, "") else (sku_imgs[j - 2] if j - 2 < len(sku_imgs) else None)
if not img:
continue
col = _carousel_col(router, j)
if col is None:
continue
for row in router.find_spu_rows(spu_code):
if (str(router.ws.cell(row, router.level_col).value or "").strip().lower() == "sku"
and str(router.ws.cell(row, color_col).value or "").strip() == color):
router.ws.cell(row, col, str(img))
# db SPU 字段 -> 上传模板列名
SPU_MAP: Dict[str, str] = {
"code": "SPU货号",
"material": "材质",
"component_1": "成分1",
"component_proportion_1": "成分1成分比例",
"component_2": "成分2",
"component_proportion_2": "成分2成分比例",
"component_3": "成分3",
"component_proportion_3": "成分3成分比例",
"pattern": "图案",
"details": "细节",
"collar_style": "领型",
"care_Instructions": "护理说明",
"fabric": "面料",
"target_audience": "适用人群",
"season": "季节",
"is_transparent": "是否透明",
"weaving_method": "织造方式",
"printing_type": "印花类型",
"fabric_texture_1": "面料纹理1",
"fabric_weight_1": "面料克重1g/m²)",
"fabric_weight_unit_1": "面料克重1g/m²)单位",
"lining_texture": "里料纹理",
"placket_type": "SPU商品属性-门襟类型",
"sleeve_type": "SPU商品属性-袖型",
"sleeve_length_type": "SPU商品属性-袖长",
"breast_pad": "SPU商品属性-胸垫",
"layout": "SPU商品属性-版型",
"silhouette": "SPU商品属性-廓形",
"length_type": "SPU商品属性-长度",
"belt": "SPU商品属性-腰带",
"occasion": "SPU商品属性-场合",
"scene": "SPU商品属性-场景",
"hemline_shape": "SPU商品属性-下摆形状",
}
# db SKU 字段 -> 上传模板列名
SKU_MAP: Dict[str, str] = {
"color": "色值(主规格)",
"size": "尺码",
"size_group": "尺码组别",
"size_type": "尺码类型",
"shoulder_width": "肩宽(cm",
"bust": "胸围全围(cm",
"clothing_length": "衣长(cm",
"sleeve_length": "袖长(cm",
"longest_side": "最长边(cm",
"secondary_long_side": "次长边(cm",
"shortest_side": "最短边(cm",
"package_weight": "重量(g", # 包装重量:从 db 提取
"price": "申报价格-日本站",
}
def _read_spu(db_path, spu_code: str) -> Optional[Dict[str, Any]]:
conn = _connect(db_path)
row = conn.execute("SELECT * FROM SPU WHERE code = ?", (spu_code,)).fetchone()
conn.close()
return dict(row) if row else None
def _read_meta(router) -> tuple:
"""读模板顶头元信息:发货仓(按标签名精准定位,不依赖固定行列)。
返回 warehouses:发货仓按「、」分隔的列表(如「名古屋仓、inkreach——东京」→ 2 个)。
商品产地/产地省份已固定为「中国大陆/广东省」写死在 _build_spu_row,不再读取经营站点。"""
ws = router.ws
top = max(1, router.group_row - 1) # 元信息区位于分组行之前
def _val(label: str) -> str:
for row in range(1, top + 1):
for col in range(1, min(ws.max_column, 30) + 1):
if str(ws.cell(row, col).value or "").strip() == label:
return str(ws.cell(row + 1, col).value or "").strip()
return ""
raw = _val("发货仓")
if not raw:
# 回退:旧版固定位置(第2行第2列=发货仓)
raw = str(ws.cell(2, 2).value or "").strip()
warehouses = [w.strip() for w in raw.split("、") if w.strip()]
return warehouses
def _read_skus(db_path, spu_code: str, sku_code: str) -> List[Dict[str, Any]]:
"""该款该颜色的全部尺码 SKU,按尺码从小到大排序(字母码/数字码/童装码/规格码)。"""
conn = _connect(db_path)
rows = conn.execute(
"""SELECT s.*, p.code AS spu_code FROM SKU s
JOIN SPU p ON s.spu_id = p.id
WHERE p.code = ? AND s.code = ?""", (spu_code, sku_code)).fetchall()
conn.close()
skus = [dict(r) for r in rows]
skus.sort(key=lambda sk: _size_rank(sk.get("size")))
return skus
def _import_router(template_dir: str) -> None:
"""import template_router(优先 config 的 template_dir;打包后回退 _MEIPASS/model;再兜底项目自带 templates/)。"""
tdir = Path(template_dir)
candidates = [tdir]
meipass = getattr(sys, "_MEIPASS", None)
if meipass:
candidates.append(Path(meipass) / "model")
# 用户上传的模板可能在任意目录(无 template_router.py),兜底项目自带 templates/
from graph.paths import project_root as _proj_root
candidates.append(_proj_root() / "templates")
for d in candidates:
if d.exists() and str(d) not in sys.path:
sys.path.insert(0, str(d))
def _insert_product_block(
router, db_path, spu_code, sku_code,
warehouses, price_headers,
markup_percent: float = 0.0, images: Optional[List[str]] = None,
spu_per_color: bool = True,
oss_code: str = "", cn_title: str = "", en_title: str = "", ja_title: str = "",
es_title: str = "",
composite_urls: Optional[List[Dict[str, Any]]] = None,
seed_shot_urls: Optional[List[str]] = None,
bust_headers: Optional[List[str]] = None,
fabric_headers: Optional[List[str]] = None,
sa_size_headers: Optional[List[str]] = None,
gender: Optional[str] = None,
component_map: Optional[Dict[str, Any]] = None,
season_map: Optional[Dict[str, Any]] = None,
pattern_map: Optional[Dict[str, Any]] = None,
target_audience_map: Optional[Dict[str, Any]] = None,
kids_type_map: Optional[Dict[str, Any]] = None,
suggested_price_ratio: float = 0.0,
suggested_price_headers: Optional[List[str]] = None,
suggested_unit_headers: Optional[List[str]] = None,
sku_category_headers: Optional[List[str]] = None,
sku_qty_headers: Optional[List[str]] = None,
sku_qty_unit_headers: Optional[List[str]] = None,
) -> List[int]:
"""在已打开的 router 中插入一个产品的 SPU+SKU 块并填充设计字段,返回本块行号。
供单产品 export_product 与批量 export_products 复用(批量时只打开/保存一次)。
"""
spu = _read_spu(db_path, spu_code)
if spu is None:
raise ValueError(f"SPU {spu_code} 不存在于 db")
if isinstance(sku_code, str) and "," in sku_code:
sku_codes = [s.strip() for s in sku_code.split(",") if s.strip()]
elif isinstance(sku_code, (list, tuple)):
sku_codes = list(sku_code)
else:
sku_codes = [sku_code]
skus_by_color: List[tuple] = []
for sc in sku_codes:
skus = _read_skus(db_path, spu_code, sc)
if not skus:
raise ValueError(f"SKU {sc} 不存在于 db(款号 {spu_code}")
skus_by_color.append((sc, sorted(skus, key=lambda sk: _size_rank(sk.get("size")))))
images = [str(i) for i in (images or []) if i]
color_col = router.resolve_col("色值(主规格)")
block_rows: List[int] = []
size_options = _read_size_options(router) # 尺码下拉框选项(sku.size 不在其中则跳过该 SKU 行)
sku_sizes = {sk.get("size") for _, skus in skus_by_color for sk in skus if sk.get("size")}
model_info = _read_model_info(router, sku_sizes) # 模特信息三属性(试穿模特/试穿尺码/试穿感受)
if spu_per_color:
# 单 SPU 多色:1 个 SPU 行(无色值,SPU 级信息由 _fill_design_fields 填充)
# + 全部颜色尺码 SKU 行(色值在 SKU 行区分)
block_rows.append(router.insert(
_build_spu_row(spu, spu_code, fabric_headers=fabric_headers,
gender=gender, component_map=component_map,
season_map=season_map, pattern_map=pattern_map,
target_audience_map=target_audience_map,
kids_type_map=kids_type_map,
oss_code=oss_code, model_info=model_info),
match="exact",
))
for ci, (sc, skus) in enumerate(skus_by_color):
first = skus[0]
color = first.get("color") or sc
# 该颜色全部尺码 SKUSKU 行 SPU货号/SKU货号=spu_code,色值区分)
for i, sk in enumerate(skus):
size = sk.get("size") or f"{i+1}"
row = _build_sku_row(spu_code, sc, sk, size, color, warehouses,
markup_percent=markup_percent, multi=True,
bust_headers=bust_headers, price_headers=price_headers,
sa_size_headers=sa_size_headers,
suggested_price_ratio=suggested_price_ratio,
suggested_price_headers=suggested_price_headers,
suggested_unit_headers=suggested_unit_headers,
sku_category_headers=sku_category_headers,
sku_qty_headers=sku_qty_headers,
sku_qty_unit_headers=sku_qty_unit_headers,
oss_code=oss_code, size_options=size_options)
if row is None:
continue
block_rows.append(router.insert(row, spu_code=spu_code, match="exact"))
# 轮播图:首色 SKU 行轮播图1 = 生成首图;SKU 行按色值填 db url/生成图
if ci == 0 and images:
col1 = _carousel_col(router, 1)
if col1 is not None:
sku_rows = router.find_sku_rows(spu_code)
if sku_rows:
router.ws.cell(min(sku_rows), col1, str(images[0]))
sku_imgs = [x for x in (images[1:] + images[:1]) if x][:4] if (ci == 0 and images) else []
_fill_sku_carousel(router, spu_code, color, color_col, first, sku_imgs)
else:
# 单 SPU + 多颜色变体:1 个 SPU 行(无色值)+ 所有颜色所有尺码 SKU 行(色值区分)
block_rows.append(router.insert(
_build_spu_row(spu, spu_code, fabric_headers=fabric_headers,
gender=gender, component_map=component_map,
season_map=season_map, pattern_map=pattern_map,
target_audience_map=target_audience_map,
kids_type_map=kids_type_map,
oss_code=oss_code, model_info=model_info), match="exact"))
multi_variant = len(skus_by_color) > 1
for ci, (sc, skus) in enumerate(skus_by_color):
first = skus[0]
color = first.get("color") or sc
for i, sk in enumerate(skus):
size = sk.get("size") or f"{i+1}"
row = _build_sku_row(spu_code, sc, sk, size, color, warehouses,
markup_percent=markup_percent, multi=multi_variant,
bust_headers=bust_headers, price_headers=price_headers,
sa_size_headers=sa_size_headers,
suggested_price_ratio=suggested_price_ratio,
suggested_price_headers=suggested_price_headers,
suggested_unit_headers=suggested_unit_headers,
sku_category_headers=sku_category_headers,
sku_qty_headers=sku_qty_headers,
sku_qty_unit_headers=sku_qty_unit_headers,
oss_code=oss_code, size_options=size_options)
if row is None:
continue
block_rows.append(router.insert(row))
sku_imgs = [x for x in (images[1:] + images[:1]) if x][:4] if (ci == 0 and images) else []
_fill_sku_carousel(router, spu_code, color, color_col, first, sku_imgs)
# 设计联动字段:货号/标题/轮播图路由/详情图文(图床链接,| 分割)
if oss_code or cn_title or en_title or ja_title or es_title or composite_urls:
by_sku: Dict[str, Any] = {}
all_urls: List[str] = []
for cc in (composite_urls or []):
if cc.get("color"):
by_sku[str(cc["color"]).strip()] = cc
if cc.get("url"):
all_urls.append(str(cc["url"]))
_fill_design_fields(router, spu_code, oss_code, cn_title, en_title, ja_title,
es_title, by_sku, all_urls, seed_shot_urls or [],
only_rows=block_rows)
return block_rows
def export_product(
db_path,
spu_code: str,
sku_code, # str | List[str]:单颜色或多个颜色
template_dir: str,
template_path: str,
out_path: str,
images: Optional[List[str]] = None,
spu_per_color: bool = True,
oss_code: str = "",
cn_title: str = "",
en_title: str = "",
ja_title: str = "",
es_title: str = "",
composite_urls: Optional[List[Dict[str, Any]]] = None,
seed_shot_urls: Optional[List[str]] = None,
append_to: str = "",
markup_percent: float = 0.0,
suggested_price_ratio: float = 0.0,
component_map: Optional[Dict[str, Any]] = None,
season_map: Optional[Dict[str, Any]] = None,
pattern_map: Optional[Dict[str, Any]] = None,
target_audience_map: Optional[Dict[str, Any]] = None,
kids_type_map: Optional[Dict[str, Any]] = None,
) -> Path:
"""生成商品上传"已填写"模板(支持多产品合并到同一文件)。
sku_code SKU 颜色编码,支持单个 str 或多个(list/tuple/逗号分隔字符串)。
spu_per_color True(默认)= 每个颜色导出一个 SPU 块;False = 单 SPU 下挂所有颜色 SKU 变体。
template_dir template_router.py 所在目录(用于 import
template_path :商品上传模版 xlsx 路径
images :生成的产品图路径列表(仅作用于第一个颜色块:SPU 行轮播图1 + SKU 行回退)
oss_code :设计货号(前缀+3位计数),SPU货号/SKU货号 列均填它
cn_title :商品名称(中文标题)
en_title :英文名称(英文标题)
ja_title :日语名称(日语标题,JP 模板生成)
es_title :西语名称(西班牙语标题,ES 模板生成)
composite_urls[{"sku_code","color","url","code"}] 每色三合一主图(含图床链接与货号)
seed_shot_urls :种草图图床链接列表(详情图文 | 拼接用)
append_to :已有输出文件路径;提供则在其基础上追加本产品块(一次任务多产品合并一个模板)
markup_percent :加价百分比,申报价格 = SKU.price × (1+markup/100) 预填
返回输出文件路径。
"""
_import_router(template_dir)
from template_router import TemplateRouter # noqa: E402
# append_to:合并模式从已有输出文件继续追加(一次任务多产品填一个模板)
router = TemplateRouter(append_to if append_to else template_path)
try:
warehouses = _read_meta(router)
price_headers = _find_price_headers(router) # 申报价格列(美站/日站/英站…模糊匹配,多个全填)
bust_headers = _find_bust_headers(router) # 胸围列(基码表-胸围(cm)/胸围全围(cm)…多个全填)
fabric_headers = _find_fabric_headers(router) # 面料弹性列(SPU商品属性-面料弹性…检测到才填)
sa_size_headers = _find_sa_size_headers(router) # 沙特阿拉伯码列(尺码表-沙特阿拉伯码…检测到才填)
gender = _template_gender(template_path) # 类目含「男」→male /「女」→female(成分值映射用)
suggested_price_headers = _find_suggested_price_headers(router) # 建议售价列(不含单位)
suggested_unit_headers = _find_suggested_unit_headers(router) # 建议售价单位列
sku_category_headers = _find_sku_category_headers(router) # SKU分类列(存在即填默认值「单品」)
sku_qty_headers = _find_sku_qty_headers(router) # SKU数量列(存在即填 1
sku_qty_unit_headers = _find_sku_qty_unit_headers(router) # SKU数量单位列(存在即填「件」)
_insert_product_block(router, db_path, spu_code, sku_code,
warehouses, price_headers,
markup_percent=markup_percent, images=images,
spu_per_color=spu_per_color,
oss_code=oss_code, cn_title=cn_title, en_title=en_title,
ja_title=ja_title, es_title=es_title,
composite_urls=composite_urls,
seed_shot_urls=seed_shot_urls,
bust_headers=bust_headers,
fabric_headers=fabric_headers,
sa_size_headers=sa_size_headers,
gender=gender,
component_map=component_map,
season_map=season_map,
pattern_map=pattern_map,
target_audience_map=target_audience_map,
kids_type_map=kids_type_map,
suggested_price_ratio=suggested_price_ratio,
suggested_price_headers=suggested_price_headers,
suggested_unit_headers=suggested_unit_headers,
sku_category_headers=sku_category_headers,
sku_qty_headers=sku_qty_headers,
sku_qty_unit_headers=sku_qty_unit_headers)
out = router.save(out_path)
return Path(out)
finally:
try:
router.close()
except Exception:
pass
def export_products(
db_path,
products: List[Dict[str, Any]],
template_dir: str,
template_path: str,
out_path: str,
markup_percent: float = 0.0,
suggested_price_ratio: float = 0.0,
component_map: Optional[Dict[str, Any]] = None,
season_map: Optional[Dict[str, Any]] = None,
pattern_map: Optional[Dict[str, Any]] = None,
target_audience_map: Optional[Dict[str, Any]] = None,
kids_type_map: Optional[Dict[str, Any]] = None,
) -> Path:
"""批量合并导出:所有产品一次性写入同一模板,只打开/保存一次。
products 每项字段:spu_code / sku_codesstr 或 list/ oss_code / cn_title / en_title /
ja_title / es_title / composite_urls / seed_shot_urls / images / spu_per_color。
相比逐产品调用 export_product(每次全量读写工作簿),批量模式显著提速。
"""
_import_router(template_dir)
from template_router import TemplateRouter # noqa: E402
router = TemplateRouter(template_path)
try:
warehouses = _read_meta(router)
price_headers = _find_price_headers(router) # 申报价格列(美站/日站/英站…模糊匹配,多个全填)
bust_headers = _find_bust_headers(router) # 胸围列(基码表-胸围(cm)/胸围全围(cm)…多个全填)
fabric_headers = _find_fabric_headers(router) # 面料弹性列(SPU商品属性-面料弹性…检测到才填)
sa_size_headers = _find_sa_size_headers(router) # 沙特阿拉伯码列(尺码表-沙特阿拉伯码…检测到才填)
gender = _template_gender(template_path) # 类目含「男」→male /「女」→female(成分值映射用)
suggested_price_headers = _find_suggested_price_headers(router) # 建议售价列(不含单位)
suggested_unit_headers = _find_suggested_unit_headers(router) # 建议售价单位列
sku_category_headers = _find_sku_category_headers(router) # SKU分类列(存在即填默认值「单品」)
sku_qty_headers = _find_sku_qty_headers(router) # SKU数量列(存在即填 1
sku_qty_unit_headers = _find_sku_qty_unit_headers(router) # SKU数量单位列(存在即填「件」)
for r in products:
try:
_insert_product_block(
router, db_path, r.get("spu_code", ""),
r.get("sku_codes") or r.get("sku_code") or "",
warehouses, price_headers,
markup_percent=markup_percent,
images=r.get("images"),
spu_per_color=bool(r.get("spu_per_color", True)),
oss_code=r.get("oss_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("composite_urls"),
seed_shot_urls=r.get("seed_shot_urls"),
bust_headers=bust_headers,
fabric_headers=fabric_headers,
sa_size_headers=sa_size_headers,
gender=gender,
component_map=component_map,
season_map=season_map,
pattern_map=pattern_map,
target_audience_map=target_audience_map,
kids_type_map=kids_type_map,
suggested_price_ratio=suggested_price_ratio,
suggested_price_headers=suggested_price_headers,
suggested_unit_headers=suggested_unit_headers,
sku_category_headers=sku_category_headers,
sku_qty_headers=sku_qty_headers,
sku_qty_unit_headers=sku_qty_unit_headers,
)
except Exception as e: # noqa: BLE001
print(f"[template_export] 产品 {r.get('spu_code')} 写入失败,跳过: {e}")
continue
out = router.save(out_path)
return Path(out)
finally:
try:
router.close()
except Exception:
pass