POD 趋势感知 Agent:缓存热点模式 + 三图合成 + 热点去重/风格去重 + review 兜底
- 缓存热点批量流程(有采集缓存不触发 Google) - 简报不足直接从采集缓存生成(轻量补齐) - 三图合成(模特/印花/底图)+ 底图压缩 <2MB - 热点去重→风格去重自动切换 + 不适合类目 review 兜底 - 透明背景(background=transparent)+ 提示词清洗(敏感词/背景描述) - 任务前 basemap 校验 + 模板国家校验 + 模特任务级分配
This commit is contained in:
@@ -0,0 +1,442 @@
|
||||
"""商品上传模板导出:从 db 读 SPU/SKU → 调 template_router 路由填入上传模板 Excel。
|
||||
|
||||
流程(product_node 生成产品图后调用):
|
||||
1. 从 spu_sku.db 读 SPU(款号)+ 该款选定颜色的全部尺码 SKU;
|
||||
2. 用 model/template_router.py 的 TemplateRouter:
|
||||
- insert 一行 SPU(SPU货号 + 商品属性字段)
|
||||
- 每个尺码 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", "カルーセル")
|
||||
|
||||
|
||||
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 _fill_design_fields(router, spu_code: str, oss_code: str, cn_title: str, en_title: str,
|
||||
ja_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货号=设计货号、商品名称=cn_title、英文名称=en_title、
|
||||
日语名称=ja_title、商品轮播图1=随机一张三合一主图、详情图文=全部主图+种草图链接 | 分割
|
||||
- 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:
|
||||
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, 1).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 cn_title:
|
||||
router.ws.cell(row, name_col, cn_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 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 cn_title:
|
||||
router.ws.cell(row, name_col, cn_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)
|
||||
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, origin_province: str,
|
||||
color: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""构造一行 SPU(固定字段:SKC货号=code、风格=休闲、商品产地=经营站点;多颜色时用色值列区分)。"""
|
||||
row: Dict[str, Any] = {
|
||||
"基础信息-商品层级": "spu",
|
||||
"SKC货号": spu_code, # code 路由为 SKC货号(用户要求)
|
||||
"风格": "休闲", # style 路由为"休闲"(用户要求)
|
||||
"商品产地": origin_province, # 产地省份不用填,经营站点填到「商品产地」
|
||||
}
|
||||
if color:
|
||||
row["色值(主规格)"] = color
|
||||
for dbk, header in SPU_MAP.items():
|
||||
v = spu.get(dbk)
|
||||
if v not in (None, ""):
|
||||
row[header] = v
|
||||
return row
|
||||
|
||||
|
||||
def _find_price_header(router) -> str:
|
||||
"""定位价格列表头:任意含「申报价格」的列(美站/日站/英站…模糊匹配);找不到回退默认。"""
|
||||
for k in router.column_map:
|
||||
if "申报价格" in str(k):
|
||||
return str(k)
|
||||
return "申报价格-日本站"
|
||||
|
||||
|
||||
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 = "申报价格-日本站") -> Dict[str, Any]:
|
||||
"""构造一行 SKU(固定字段:SPU货号、SKC货号=sku.code、规格类型2、币种 CNY、发货仓1~N 及库存 200)。
|
||||
价格(price_header 列,如 申报价格-美国站/日本站,模糊匹配)= SKU.price × (1+markup/100),预先填好。
|
||||
规格类型2 统一填「尺码」两个字(不是 size 参数值)。"""
|
||||
row: Dict[str, Any] = {
|
||||
"基础信息-商品层级": "sku",
|
||||
"SPU货号": spu_code,
|
||||
"SKC货号": sk.get("code") or sc, # SKC货号 = SKU 的 code(款号-颜色编码)
|
||||
"色值(主规格)": color,
|
||||
"规格类型2": "尺码", # 规格类型2 统一填「尺码」(不填 size 值)
|
||||
"币种": "CNY",
|
||||
}
|
||||
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
|
||||
if dbk == "price":
|
||||
header = price_header # 模糊匹配的实际价格列(申报价格-美站/日站/英站…)
|
||||
v = sk.get(dbk)
|
||||
if dbk == "price" and v not in (None, ""):
|
||||
v = round(float(v) * (1 + markup_percent / 100), 2) # 申报价格 = price × (1+加价%)
|
||||
if v not in (None, ""):
|
||||
row[header] = v
|
||||
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~5:db 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, 1).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": "是否透明",
|
||||
"layout": "版型",
|
||||
"weaving_method": "织造方式",
|
||||
"printing_type": "印花类型",
|
||||
"fabric_texture_1": "面料纹理1",
|
||||
"fabric_weight_1": "面料克重1(g/m²)",
|
||||
"fabric_weight_unit_1": "面料克重1(g/m²)单位",
|
||||
"lining_texture": "里料纹理",
|
||||
}
|
||||
|
||||
# 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:
|
||||
"""读模板顶头元信息:经营站点(第2行第1列)、发货仓(第2行第2列)。
|
||||
|
||||
返回 (origin_province, warehouses):
|
||||
- origin_province:经营站点去掉末尾「站」(如「日本站」→「日本」)
|
||||
- warehouses:发货仓按「、」分隔的列表(如「名古屋仓、inkreach——东京」→ 2 个)
|
||||
"""
|
||||
ws = router.ws
|
||||
site = str(ws.cell(2, 1).value or "").strip()
|
||||
origin_province = site[:-1] if site.endswith("站") else site
|
||||
raw = str(ws.cell(2, 2).value or "").strip()
|
||||
warehouses = [w.strip() for w in raw.split("、") if w.strip()]
|
||||
return origin_province, 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 = ?
|
||||
ORDER BY s.size""", (spu_code, sku_code)).fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in 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 = "",
|
||||
composite_urls: Optional[List[Dict[str, Any]]] = None,
|
||||
seed_shot_urls: Optional[List[str]] = None,
|
||||
append_to: str = "",
|
||||
markup_percent: float = 0.0,
|
||||
) -> 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 模板生成)
|
||||
composite_urls:[{"sku_code","color","url","code"}] 每色三合一主图(含图床链接与货号)
|
||||
seed_shot_urls :种草图图床链接列表(详情图文 | 拼接用)
|
||||
append_to :已有输出文件路径;提供则在其基础上追加本产品块(一次任务多产品合并一个模板)
|
||||
markup_percent :加价百分比,申报价格 = SKU.price × (1+markup/100) 预填
|
||||
返回输出文件路径。
|
||||
"""
|
||||
# 1) 读 db(支持单/多颜色)
|
||||
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, skus))
|
||||
images = [str(i) for i in (images or []) if i]
|
||||
|
||||
# 2) import template_router(优先 config 的 template_dir;打包后回退 _MEIPASS/model)
|
||||
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))
|
||||
from template_router import TemplateRouter # noqa: E402
|
||||
|
||||
# append_to:合并模式从已有输出文件继续追加(一次任务多产品填一个模板)
|
||||
router = TemplateRouter(append_to if append_to else template_path)
|
||||
try:
|
||||
origin_province, warehouses = _read_meta(router)
|
||||
price_header = _find_price_header(router) # 申报价格列(美站/日站/英站…模糊匹配)
|
||||
multi = len(skus_by_color) > 1
|
||||
color_col = router.resolve_col("色值(主规格)")
|
||||
block_rows: List[int] = [] # 本产品块插入的所有行号(_fill_design_fields 只填这些行)
|
||||
|
||||
if spu_per_color:
|
||||
# 3) 单 SPU 多色:1 个 SPU 行(无色值,SPU 级信息由 _fill_design_fields 填充)
|
||||
# + 全部颜色尺码 SKU 行(色值在 SKU 行区分)
|
||||
block_rows.append(router.insert(
|
||||
_build_spu_row(spu, spu_code, origin_province),
|
||||
match="exact",
|
||||
))
|
||||
for ci, (sc, skus) in enumerate(skus_by_color):
|
||||
first = skus[0]
|
||||
color = first.get("color") or sc
|
||||
|
||||
# 该颜色全部尺码 SKU(SKU 行 SPU货号/SKU货号=spu_code,色值区分)
|
||||
for i, sk in enumerate(skus):
|
||||
size = sk.get("size") or f"{i+1}"
|
||||
block_rows.append(router.insert(
|
||||
_build_sku_row(spu_code, sc, sk, size, color, warehouses,
|
||||
markup_percent=markup_percent, multi=True,
|
||||
price_header=price_header),
|
||||
spu_code=spu_code, match="exact",
|
||||
))
|
||||
|
||||
# 3.3) 轮播图:首色 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:
|
||||
# 4) 单 SPU + 多颜色变体:1 个 SPU 行(无色值)+ 所有颜色所有尺码 SKU 行(色值区分)
|
||||
block_rows.append(router.insert(
|
||||
_build_spu_row(spu, spu_code, origin_province), 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}"
|
||||
block_rows.append(router.insert(
|
||||
_build_sku_row(spu_code, sc, sk, size, color, warehouses,
|
||||
markup_percent=markup_percent, multi=multi_variant,
|
||||
price_header=price_header),
|
||||
))
|
||||
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)
|
||||
# 无 SPU 行:首图(轮播图1)由 _fill_design_fields 按 SKU 行填充
|
||||
|
||||
# 5) 设计联动字段:货号/标题/轮播图路由/详情图文(图床链接,| 分割)
|
||||
if oss_code or cn_title or en_title or ja_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,
|
||||
by_sku, all_urls, seed_shot_urls or [], only_rows=block_rows)
|
||||
|
||||
out = router.save(out_path)
|
||||
return Path(out)
|
||||
finally:
|
||||
try:
|
||||
router.close()
|
||||
except Exception:
|
||||
pass
|
||||
Reference in New Issue
Block a user