feat: [workflow] 统一飞书款式身份配置
This commit is contained in:
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())
|
||||
Reference in new issue
Block a user