feat: [workflow] 统一飞书款式身份配置

This commit is contained in:
wangyunlong committed 2026-09-07 15:01:04 +08:00
1 parent 92289b17ed
commit 485e77ee74
13 files changed
+1153 -135

No files matched your search

@@ -79,7 +79,19 @@ def _lark_cli() -> str:
return executable
def read_source_records() -> list[dict[str, Any]]:
def read_source_records(
*,
base_token: str = BASE_TOKEN,
table_id: str = TABLE_ID,
view_id: str = VIEW_ID,
) -> list[dict[str, Any]]:
"""Read all rows from the legacy Feishu configuration table.
The defaults preserve the original seed export behavior. Callers that
need to audit a particular shared view can pass the resolved IDs instead
of duplicating the pagination and field-mapping logic.
"""
records: list[dict[str, Any]] = []
offset = 0
while True:
@@ -88,11 +100,11 @@ def read_source_records() -> list[dict[str, Any]]:
"base",
"+record-list",
"--base-token",
BASE_TOKEN,
base_token,
"--table-id",
TABLE_ID,
table_id,
"--view-id",
VIEW_ID,
view_id,
"--offset",
str(offset),
"--limit",
@@ -0,0 +1,347 @@
"""将飞书款式 ID 表一次性合并到产品生命进程身份表。
这个脚本只负责初始化/校准 ``cmt_styles`` 的身份字段:
* 按款式聚合旧表的一行多平台记录;
* 将 ERP 款式编码和四个平台商品编码去重后写入生命进程;
* 保留生命进程已有的 NFC、图片、类目、市场标签等字段;
* 空的源字段不会清空已有身份,避免旧表不完整行破坏已维护数据;
* 默认只预览,只有显式传入 ``--apply`` 才写入数据库。
运行时采集仍从 PostgreSQL 的产品生命进程读取,不会把飞书旧表重新
变成第二个运行时配置源。
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from dataclasses import dataclass, field
from typing import Any, Mapping
import psycopg
from psycopg.rows import dict_row
from gyxx_flow.migration.feishu_style_config_seed import (
BASE_TOKEN as SOURCE_BASE_TOKEN,
)
from gyxx_flow.migration.feishu_style_config_seed import (
TABLE_ID as SOURCE_TABLE_ID,
)
from gyxx_flow.migration.feishu_style_config_seed import (
read_source_records,
)
SOURCE_URL = (
"https://bu0zgpibak.feishu.cn/base/"
f"{SOURCE_BASE_TOKEN}?table={SOURCE_TABLE_ID}&view=vewT7L4JUr"
)
SOURCE_VIEW_ID = "vewT7L4JUr"
PLATFORM_KEYS = {
"天猫": "tm",
"京东旗舰店": "jd",
"京东": "jd",
"京东自营": "jd_self",
"抖音": "dy",
}
KNOWN_PLATFORM_KEYS = ("tm", "jd", "jd_self", "dy")
class FeishuLifecycleSyncError(RuntimeError):
"""Raised when the source or lifecycle target cannot be safely synchronized."""
def _clean_list(value: Any) -> list[str]:
if value is None:
return []
if isinstance(value, (list, tuple, set)):
parts = value
else:
parts = str(value).replace(",", ",").replace("\r", ",").replace("\n", ",").split(",")
result: list[str] = []
seen: set[str] = set()
for item in parts:
text = str(item or "").strip()
if text and text not in seen:
seen.add(text)
result.append(text)
return result
def _clean_style_name(value: Any) -> str:
return " ".join(str(value or "").split())
@dataclass
class StyleIdentity:
name: str
brand: str = ""
erp_codes: list[str] = field(default_factory=list)
platform_product_ids: dict[str, list[str]] = field(
default_factory=lambda: {key: [] for key in KNOWN_PLATFORM_KEYS}
)
source_record_ids: list[str] = field(default_factory=list)
unsupported_platform_ids: dict[str, list[str]] = field(default_factory=dict)
def add_unique(self, field_name: str, values: list[str]) -> None:
target = getattr(self, field_name)
for value in values:
if value not in target:
target.append(value)
def build_sync_plan(source_records: list[Mapping[str, Any]]) -> tuple[list[StyleIdentity], dict[str, Any]]:
"""Group the legacy per-platform rows into lifecycle style identities."""
grouped: dict[str, StyleIdentity] = {}
skipped_rows: list[dict[str, str]] = []
unknown_platform_rows: list[dict[str, Any]] = []
for entry in source_records:
config = entry.get("config") or {}
if not isinstance(config, Mapping):
skipped_rows.append({"record_id": str(entry.get("source_record_id") or ""), "reason": "配置结构无效"})
continue
source_record_id = str(entry.get("source_record_id") or "").strip()
name = _clean_style_name(config.get("style_name"))
if not name:
skipped_rows.append({"record_id": source_record_id, "reason": "款式名称为空"})
continue
identity = grouped.setdefault(name, StyleIdentity(name=name))
brand = str(config.get("brand") or "").strip()
if brand and not identity.brand:
identity.brand = brand
identity.add_unique("erp_codes", _clean_list(config.get("erp_codes")))
if source_record_id and source_record_id not in identity.source_record_ids:
identity.source_record_ids.append(source_record_id)
platform = str(config.get("platform") or "").strip()
item_ids = _clean_list(config.get("item_ids"))
key = PLATFORM_KEYS.get(platform)
if key:
for item_id in item_ids:
if item_id not in identity.platform_product_ids[key]:
identity.platform_product_ids[key].append(item_id)
elif platform and item_ids:
unknown = identity.unsupported_platform_ids.setdefault(platform, [])
for item_id in item_ids:
if item_id not in unknown:
unknown.append(item_id)
unknown_platform_rows.append(
{
"style": name,
"platform": platform,
"item_count": len(item_ids),
"record_id": source_record_id,
}
)
styles = [grouped[name] for name in sorted(grouped)]
report = {
"source_url": SOURCE_URL,
"source_records": len(source_records),
"named_styles": len(styles),
"skipped_rows": skipped_rows,
"unknown_platform_rows": unknown_platform_rows,
"styles_without_erp": [style.name for style in styles if not style.erp_codes],
"styles_without_supported_platform_id": [
style.name
for style in styles
if not any(style.platform_product_ids.values())
],
"collectable_styles": [
style.name
for style in styles
if style.erp_codes and any(style.platform_product_ids.values())
],
}
return styles, report
def _connect(environment: Mapping[str, str] | None = None) -> psycopg.Connection[Any]:
env = environment if environment is not None else os.environ
dsn = str(env.get("GYXX_POSTGRES_DSN", "")).strip()
if dsn:
return psycopg.connect(dsn, row_factory=dict_row)
required = ("PG_HOST", "PG_PORT", "PG_DB", "PG_USER", "PG_PASSWORD")
missing = [name for name in required if not str(env.get(name, "")).strip()]
if missing:
raise FeishuLifecycleSyncError(
"缺少 PostgreSQL 配置: " + ", ".join(missing)
)
try:
port = int(env["PG_PORT"])
except ValueError as exc:
raise FeishuLifecycleSyncError("PG_PORT 必须是整数") from exc
return psycopg.connect(
host=env["PG_HOST"].strip(),
port=port,
dbname=env["PG_DB"].strip(),
user=env["PG_USER"].strip(),
password=env["PG_PASSWORD"],
row_factory=dict_row,
)
def _require_target_schema(conn: psycopg.Connection[Any]) -> None:
with conn.cursor() as cursor:
cursor.execute(
"SELECT column_name FROM information_schema.columns "
"WHERE table_schema = 'public' AND table_name = 'cmt_styles'"
)
columns = {str(row["column_name"]) for row in cursor.fetchall()}
required = {"name", "brand", "erp_style_codes", "platform_product_ids"}
missing = sorted(required - columns)
if missing:
raise FeishuLifecycleSyncError(
"cmt_styles 缺少身份字段: " + ", ".join(missing)
)
def _existing_rows(
conn: psycopg.Connection[Any], names: list[str]
) -> dict[str, dict[str, Any]]:
if not names:
return {}
with conn.cursor() as cursor:
cursor.execute(
"SELECT name, brand, erp_style_codes, platform_product_ids "
"FROM cmt_styles WHERE name = ANY(%s)",
(names,),
)
return {str(row["name"]): dict(row) for row in cursor.fetchall()}
def _json_platform_ids(value: Any) -> dict[str, list[str]]:
if isinstance(value, str):
try:
value = json.loads(value)
except json.JSONDecodeError:
value = {}
if not isinstance(value, Mapping):
value = {}
# ProductLifecycleServiceImpl strictly rejects unknown JSON keys. The
# legacy database may contain Chinese platform labels, so only carry the
# canonical four keys into the lifecycle record.
return {
key: _clean_list(value.get(key))
for key in KNOWN_PLATFORM_KEYS
}
def _merge_target_row(
style: StyleIdentity, existing: Mapping[str, Any] | None
) -> dict[str, Any]:
existing = existing or {}
erp_codes = _clean_list(existing.get("erp_style_codes"))
if style.erp_codes:
erp_codes = list(style.erp_codes)
platform_ids = _json_platform_ids(existing.get("platform_product_ids"))
for key in KNOWN_PLATFORM_KEYS:
if style.platform_product_ids[key]:
platform_ids[key] = list(style.platform_product_ids[key])
return {
"name": style.name,
"brand": style.brand or str(existing.get("brand") or "").strip(),
"erp_style_codes": erp_codes,
"platform_product_ids": platform_ids,
"existing": existing,
}
def build_target_rows(
conn: psycopg.Connection[Any], styles: list[StyleIdentity]
) -> list[dict[str, Any]]:
existing = _existing_rows(conn, [style.name for style in styles])
return [_merge_target_row(style, existing.get(style.name)) for style in styles]
def apply_target_rows(
conn: psycopg.Connection[Any], rows: list[Mapping[str, Any]]
) -> int:
with conn.cursor() as cursor:
for row in rows:
cursor.execute(
"INSERT INTO cmt_styles "
"(name, brand, erp_style_codes, platform_product_ids) "
"VALUES (%s, %s, %s, %s::jsonb) "
"ON CONFLICT (name) DO UPDATE SET "
"brand = CASE WHEN EXCLUDED.brand <> '' "
"THEN EXCLUDED.brand ELSE cmt_styles.brand END, "
"erp_style_codes = CASE "
"WHEN COALESCE(array_length(EXCLUDED.erp_style_codes, 1), 0) > 0 "
"THEN EXCLUDED.erp_style_codes ELSE cmt_styles.erp_style_codes END, "
"platform_product_ids = EXCLUDED.platform_product_ids, "
"updated_at = CURRENT_TIMESTAMP",
(
row["name"],
row["brand"],
row["erp_style_codes"],
json.dumps(row["platform_product_ids"], ensure_ascii=False),
),
)
return len(rows)
def _print_report(report: Mapping[str, Any], rows: list[Mapping[str, Any]]) -> None:
print(f"[INFO] 飞书源记录: {report['source_records']}")
print(f"[INFO] 有效款式: {report['named_styles']}")
print(f"[INFO] 将合并到 cmt_styles: {len(rows)}")
print(f"[INFO] 可采集款式: {len(report['collectable_styles'])}")
print(f"[INFO] ERP 缺失款式: {len(report['styles_without_erp'])}")
print(
"[INFO] 不支持的平台行: "
f"{len(report['unknown_platform_rows'])}(保留在旧动态平台配置,不写入四平台身份 JSON)"
)
if report["skipped_rows"]:
print(f"[WARN] 跳过空款式行: {len(report['skipped_rows'])}")
for row in rows[:5]:
print(
f" - {row['name']}: erp={row['erp_style_codes']} "
f"platform_ids={row['platform_product_ids']}"
)
def run(*, apply: bool, environment: Mapping[str, str] | None = None) -> int:
source_records = read_source_records(
base_token=SOURCE_BASE_TOKEN,
table_id=SOURCE_TABLE_ID,
view_id=SOURCE_VIEW_ID,
)
styles, report = build_sync_plan(source_records)
with _connect(environment) as conn:
_require_target_schema(conn)
rows = build_target_rows(conn, styles)
_print_report(report, rows)
if not apply:
print("[DRY-RUN] 未写入数据库;使用 --apply 执行同步")
return 0
applied = apply_target_rows(conn, rows)
conn.commit()
print(f"[OK] 产品生命进程 cmt_styles 已同步: {applied} 行")
return 0
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="飞书款式配置同步到产品生命进程")
parser.add_argument(
"--apply",
action="store_true",
help="执行数据库写入;默认仅预览",
)
args = parser.parse_args(argv)
try:
return run(apply=args.apply)
except (FeishuLifecycleSyncError, psycopg.Error) as exc:
print(f"[FAIL] 飞书款式同步失败: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())