v89-v91 模板增强 + 图源映射 + 多模态提示词可配置化

- 图源映射统一:热点采集与 Pinterest 模式均走 config.product.mark_dirs 配置,按任务序号随机抽模特图/平铺图
- 商品产地固定:统一为「中国大陆」+「产地省份=广东省」(不再读站点/字典映射)
- 模板 SKU 字段检测:按建议售价同一套路检测 SKU分类/SKU数量/SKU数量单位,必填时填入单品/1/件
- 多模态分析提示词可配置:prompts/pinterest_analyze_system.md + user.md,支持国家覆盖,不丢文件回退内置
- 自定义图片模式:新增 pinterest_custom_load_node,图片数量硬校验,选品清单 ≤ 有效图片数
- 模板导出优化:写入前按货号末 3 位升序排序,不再产生空白 xlsx
- 修复 v90 project review 10 项(503 致命终止、线程安全、原子写入等)
This commit is contained in:
2026-08-28 16:24:50 +08:00
parent 2a96ec0870
commit 71a48e4ed5
40 changed files with 780 additions and 200 deletions
+83 -31
View File
@@ -20,11 +20,6 @@ from graph.product import _connect
# 商品轮播图列名关键词(模板存在中/英/日变体,如 商品轮播图1 / Product Carousel Image 1 / 商品カルーセル画像1)
_CAROUSEL_KW = ("轮播", "carousel", "カルーセル")
# 商品产地:国家简称 → 正式名称(模版要求,如「沙特站」提取为「沙特」但需填「沙特阿拉伯」)
_COUNTRY_NAME_MAP = {
"沙特": "沙特阿拉伯",
}
# 成分值字典映射:db 成分值 → 女装模板下拉框选项(男装模板选项与 db 值一致,直接保留)。
# 女装模板(如 SatVoy 沙特)成分下拉框是「中文+英文」格式(棉Cotton),db 存中文(棉),需映射。
_COMPONENT_FEMALE_MAP = {
@@ -232,18 +227,19 @@ def _fill_design_fields(router, spu_code: str, oss_code: str, cn_title: str, en_
router.ws.cell(row, car1, cc["url"]) # 该颜色轮播图1
def _build_spu_row(spu: Dict[str, Any], spu_code: str, origin_province: str,
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) -> Dict[str, Any]:
"""构造一行 SPU(固定字段:SKC货号=code、风格=休闲、商品产地=经营站点;多颜色时用色值列区分)。
"""构造一行 SPU(固定字段:SKC货号=code、风格=休闲、商品产地=中国大陆、产地省份=广东省;多颜色时用色值列区分)。
fabric 填「面料弹性」列(fabric_headers,如 SPU商品属性-面料弹性,检测到才填 spu.fabric)。
component_1/2/3 按性别映射(gender=female 时查 _COMPONENT_FEMALE_MAP,男装/None 保留原值)。"""
row: Dict[str, Any] = {
"基础信息-商品层级": "spu",
"SKC货号": spu_code, # code 路由为 SKC货号(用户要求)
"风格": "休闲", # style 路由为"休闲"(用户要求)
"商品产地": origin_province, # 产地省份不用填,经营站点填到「商品产地」
"商品产地": "中国大陆", # 所有国家统一「中国大陆」,不读经营站点/不做字典匹配(用户要求)
"产地省份": "广东省", # 新增:精确匹配「产地省份」列,统一填「广东省」(用户要求)
"款式来源": "现货款", # SPU商品属性-款式来源 统一填「现货款」(用户要求)
}
if color:
@@ -302,6 +298,37 @@ def _read_suggested_required(router, col: int) -> bool:
return "必填" in note
def _find_sku_category_headers(router) -> List[str]:
"""定位「SKU分类」列(模糊匹配,同建议售价检测方式);匹配到多个时全部返回。"""
return [str(k) for k in router.column_map if "SKU分类" in str(k)]
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 _required_headers(router, headers) -> List[str]:
"""过滤「非必填」列:读列头下一行备注(与建议售价同套路),备注显式写「非必填」才跳过。
SKU分类/SKU数量/SKU数量单位在本模板族属必填项(SKU分类备注「必填」,数量/单位为按规则填写),
因此默认视为需填写,仅在列备注写明「非必填」时跳过。"""
keep: List[str] = []
for h in headers:
col = router.column_map.get(h)
if col is not None:
note = str(router.ws.cell(router.header_row + 1, col).value or "")
if "非必填" in note:
continue
keep.append(h)
return keep
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 = "申报价格-日本站",
@@ -311,7 +338,10 @@ def _build_sku_row(spu_code: str, sc: str, sk: Dict[str, Any], size: str, color:
suggested_price_ratio: float = 0.0,
suggested_price_headers: Optional[List[str]] = None,
suggested_required: Optional[Dict[str, bool]] = None,
suggested_unit_headers: Optional[List[str]] = None) -> Dict[str, Any]:
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) -> Dict[str, Any]:
"""构造一行 SKU(固定字段:SPU货号、SKC货号=sku.code、规格类型2、币种 CNY、发货仓1~N 及库存 200)。
价格(price_headers 列,如 申报价格-美国站/日本站,模糊匹配到多个时全部填)= SKU.price × (1+markup/100)
预先填好。建议售价(suggested_price_headers 列,模板「建议售价」必填时才填)= 申报价格 × (1+suggested_price_ratio/100)
@@ -369,6 +399,13 @@ def _build_sku_row(spu_code: str, sc: str, sk: Dict[str, Any], size: str, color:
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
@@ -441,12 +478,10 @@ def _read_spu(db_path, spu_code: str) -> Optional[Dict[str, Any]]:
def _read_meta(router) -> tuple:
"""读模板顶头元信息:经营站点、发货仓(按标签名定位,不依赖固定行列)。
"""读模板顶头元信息:发货仓(按标签名精准定位,不依赖固定行列)。
返回 (origin_province, warehouses)
- origin_province:经营站点去掉末尾「站」(如「日本站」→「日本」)
- warehouses:发货仓按「、」分隔的列表(如「名古屋仓、inkreach——东京」→ 2 个)
"""
返回 warehouses:发货仓按「、」分隔的列表(如「名古屋仓、inkreach——东京」→ 2 个)。
商品产地/产地省份已固定为「中国大陆/广东省」写死在 _build_spu_row,不再读取经营站点。"""
ws = router.ws
top = max(1, router.group_row - 1) # 元信息区位于分组行之前
@@ -457,16 +492,12 @@ def _read_meta(router) -> tuple:
return str(ws.cell(row + 1, col).value or "").strip()
return ""
site = _val("经营站点")
raw = _val("发货仓")
if not site and not raw:
# 回退:旧版固定位置(第2行第1/2列)
site = str(ws.cell(2, 1).value or "").strip()
if not raw:
# 回退:旧版固定位置(第2行第2列=发货仓
raw = str(ws.cell(2, 2).value or "").strip()
origin_province = site[:-1] if site.endswith("") else site
origin_province = _COUNTRY_NAME_MAP.get(origin_province, origin_province) # 简称→正式名称(如 沙特→沙特阿拉伯)
warehouses = [w.strip() for w in raw.split("") if w.strip()]
return origin_province, warehouses
return warehouses
def _read_skus(db_path, spu_code: str, sku_code: str) -> List[Dict[str, Any]]:
@@ -499,7 +530,7 @@ def _import_router(template_dir: str) -> None:
def _insert_product_block(
router, db_path, spu_code, sku_code,
origin_province, warehouses, price_headers,
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 = "",
@@ -514,6 +545,9 @@ def _insert_product_block(
suggested_price_headers: Optional[List[str]] = None,
suggested_required: Optional[Dict[str, bool]] = 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 块并填充设计字段,返回本块行号。
@@ -543,7 +577,7 @@ def _insert_product_block(
# 单 SPU 多色:1 个 SPU 行(无色值,SPU 级信息由 _fill_design_fields 填充)
# + 全部颜色尺码 SKU 行(色值在 SKU 行区分)
block_rows.append(router.insert(
_build_spu_row(spu, spu_code, origin_province, fabric_headers=fabric_headers,
_build_spu_row(spu, spu_code, fabric_headers=fabric_headers,
gender=gender),
match="exact",
))
@@ -562,7 +596,10 @@ def _insert_product_block(
suggested_price_ratio=suggested_price_ratio,
suggested_price_headers=suggested_price_headers,
suggested_required=suggested_required,
suggested_unit_headers=suggested_unit_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),
spu_code=spu_code, match="exact",
))
@@ -578,7 +615,7 @@ def _insert_product_block(
else:
# 单 SPU + 多颜色变体:1 个 SPU 行(无色值)+ 所有颜色所有尺码 SKU 行(色值区分)
block_rows.append(router.insert(
_build_spu_row(spu, spu_code, origin_province, fabric_headers=fabric_headers,
_build_spu_row(spu, spu_code, fabric_headers=fabric_headers,
gender=gender), match="exact"))
multi_variant = len(skus_by_color) > 1
for ci, (sc, skus) in enumerate(skus_by_color):
@@ -594,7 +631,10 @@ def _insert_product_block(
suggested_price_ratio=suggested_price_ratio,
suggested_price_headers=suggested_price_headers,
suggested_required=suggested_required,
suggested_unit_headers=suggested_unit_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),
))
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)
@@ -659,7 +699,7 @@ def export_product(
# append_to:合并模式从已有输出文件继续追加(一次任务多产品填一个模板)
router = TemplateRouter(append_to if append_to else template_path)
try:
origin_province, warehouses = _read_meta(router)
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商品属性-面料弹性…检测到才填)
@@ -669,8 +709,11 @@ def export_product(
suggested_unit_headers = _find_suggested_unit_headers(router) # 建议售价单位列
suggested_required = {h: _read_suggested_required(router, router.column_map[h])
for h in suggested_price_headers} # 各建议售价列是否必填(结合非必填/必填判断)
sku_category_headers = _required_headers(router, _find_sku_category_headers(router)) # SKU分类列(非必填才跳过)
sku_qty_headers = _required_headers(router, _find_sku_qty_headers(router)) # SKU数量列
sku_qty_unit_headers = _required_headers(router, _find_sku_qty_unit_headers(router)) # SKU数量单位列
_insert_product_block(router, db_path, spu_code, sku_code,
origin_province, warehouses, price_headers,
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,
@@ -684,7 +727,10 @@ def export_product(
suggested_price_ratio=suggested_price_ratio,
suggested_price_headers=suggested_price_headers,
suggested_required=suggested_required,
suggested_unit_headers=suggested_unit_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:
@@ -714,7 +760,7 @@ def export_products(
router = TemplateRouter(template_path)
try:
origin_province, warehouses = _read_meta(router)
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商品属性-面料弹性…检测到才填)
@@ -724,12 +770,15 @@ def export_products(
suggested_unit_headers = _find_suggested_unit_headers(router) # 建议售价单位列
suggested_required = {h: _read_suggested_required(router, router.column_map[h])
for h in suggested_price_headers} # 各建议售价列是否必填(结合非必填/必填判断)
sku_category_headers = _required_headers(router, _find_sku_category_headers(router)) # SKU分类列(非必填才跳过)
sku_qty_headers = _required_headers(router, _find_sku_qty_headers(router)) # SKU数量列
sku_qty_unit_headers = _required_headers(router, _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 "",
origin_province, warehouses, price_headers,
warehouses, price_headers,
markup_percent=markup_percent,
images=r.get("images"),
spu_per_color=bool(r.get("spu_per_color", True)),
@@ -748,6 +797,9 @@ def export_products(
suggested_price_headers=suggested_price_headers,
suggested_required=suggested_required,
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}")