Files
gyxx-flow/tests/test_workflow_dynamic_config.py
T
wangyunlong 622a609800 feat(product-commerce): 系列聚合去重与采集开关两层语义
配置来源收敛
- 系列平台编码 tm/jd/dy/jd_self 与 9 类飞书地址改为整系列并集去重后挂锚点,
  子款编码保留在 series.platform_ids 备查;多表冲突保留最高优先级并告警
- 写表脚本(当日/天猫主图/京东主图)去掉本地 JSON 回退,地址缺失或读取失败
  直接 fail closed,避免库与表地址不一致被静默掩盖
- 测试注入 records_provider 时禁用配置缓存读写,防止假数据写入生产快照

采集开关(dim_style.collect_enabled)
- 锚点为系列总开关:false 则整系列不采
- 锚点为 true 时,子款自身 false 仅把自己的 ERP 编码排除出聚合,其余子款照常
- 排除结果记录在 series.erp_excluded 并在运行时提示

聚水潭全店铺日报防错
- 列布局 fail closed、行 td 数校验、满页判定 + 总页数兜底、值域交叉校验
- 新增单日总量闸:超近 14 天中位数 8 倍拒绝落库

其他
- 新增电商费用归集配置 ad_cost_rollup.json 与积分兑换周报采集
- 店铺情报、供应链到货同步、通知路由与相关配置/文档同步更新
- 忽略本地 dsh workbench 脚手架(含 node_modules)
2026-10-30 11:49:31 +08:00

740 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
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.
from __future__ import annotations
import json
import threading
from pathlib import Path
from typing import Any, Mapping
from urllib.request import Request, urlopen
import pytest
from gyxx_flow.adapters.workflow_config import (
PostgresWorkflowConfigStore,
WorkflowConfigConflictError,
merge_lifecycle_style_records,
normalize_config,
normalize_style_config,
)
from gyxx_flow.console import (
ConsoleConflictError,
WorkflowConsoleService,
create_console_server,
)
from gyxx_flow.core.config import Settings
from gyxx_flow.modules.product_commerce.config.style_config_loader import (
StyleConfigLoader,
)
PROJECT_ROOT = Path(__file__).resolve().parents[1]
class FakeDynamicConfigStore:
def __init__(self) -> None:
self.records = [
{
"id": 1,
"style_name": "极星pro",
"erp_codes": ["10398"],
"platforms": [
{
"id": 11,
"platform": "天猫",
"item_ids": ["832214449817"],
"persona_bitable_url": "",
"enabled": True,
}
],
"enabled": True,
"revision": 1,
}
]
def snapshot(
self,
*,
search: str = "",
platform: str = "",
enabled: bool | None = None,
) -> dict[str, Any]:
del search, platform, enabled
return {
"styles": self.records,
"summary": {"total": 1, "enabled": 1, "platform_configs": 1, "matched": 1},
"platforms": [{"name": "天猫", "count": 1}],
"source": "postgresql",
}
def runtime_records(self) -> list[dict[str, Any]]:
return self.records
def create(self, payload: Mapping[str, Any]) -> dict[str, Any]:
return {"id": 2, **normalize_style_config(payload), "revision": 1}
def update(
self,
config_id: int,
payload: Mapping[str, Any],
*,
expected_revision: int,
) -> dict[str, Any]:
if expected_revision != 1:
raise WorkflowConfigConflictError("配置已被其他人更新,请重新载入")
return {
"id": config_id,
**normalize_style_config(payload),
"revision": 2,
}
def delete(self, config_id: int, *, expected_revision: int) -> None:
del config_id
if expected_revision != 1:
raise WorkflowConfigConflictError("配置已被其他人更新,请重新载入")
def _payload() -> dict[str, Any]:
return {
"style_name": "极星pro",
"brand": "光影行星",
"erp_codes": "10398,10398, 10400",
"note": "",
"sales_bitable_url": "https://example.feishu.cn/base/abc?table=tbl1",
"main_image_bitable_url": "",
"creator_bitable_url": "",
"self_creator_bitable_url": "",
"weekly_note_analysis_url": "",
"style_analysis_bitable_url": "",
"style_content": "",
"enabled": True,
"platforms": [
{
"platform": "天猫",
"item_ids": ["832214449817", "832214449817"],
"persona_bitable_url": "",
"enabled": True,
}
],
}
def test_normalize_config_cleans_ids_and_markdown_urls() -> None:
payload = {
"platform": "天猫",
"style_name": "极星pro",
"brand": "光影行星",
"erp_codes": "10398,10398, 10400",
"item_ids": ["832214449817", "832214449817"],
"note": "",
"sales_bitable_url": "https://example.feishu.cn/base/abc?table=tbl1",
"main_image_bitable_url": "",
"creator_bitable_url": "",
"tm_persona_bitable_url": "",
"jd_persona_bitable_url": "",
"dy_persona_bitable_url": "",
"self_creator_bitable_url": "",
"weekly_note_analysis_url": "",
"style_analysis_bitable_url": "",
"style_content": "",
"enabled": True,
}
payload["sales_bitable_url"] = "[销量表](https://example.feishu.cn/base/abc?table=tbl1)"
normalized = normalize_config(payload)
assert normalized["erp_codes"] == ["10398", "10400"]
assert normalized["item_ids"] == ["832214449817"]
assert normalized["sales_bitable_url"] == (
"https://example.feishu.cn/base/abc?table=tbl1"
)
def test_normalize_style_config_owns_erp_once_and_platform_ids_per_child() -> None:
normalized = normalize_style_config(_payload())
assert normalized["erp_codes"] == ["10398", "10400"]
assert normalized["platforms"] == [
{
"platform": "天猫",
"item_ids": ["832214449817"],
"persona_bitable_url": "",
"enabled": True,
}
]
def test_runtime_records_use_lifecycle_identity_and_lifecycle_targets() -> None:
"""地址以 dim_style 为准:动态配置里的旧地址不再覆盖建档值。"""
dynamic_records = [
{
"style_name": "款式甲",
"brand": "旧品牌",
"erp_codes": ["OLD-ERP"],
"platform": "天猫",
"item_ids": ["OLD-TM"],
"sales_bitable_url": "https://example.feishu.cn/base/sales?table=tblSales",
"tm_persona_bitable_url": "tm-persona",
}
]
lifecycle_rows = [
{
"id": 8,
"name": "款式甲",
"brand": "光影行星",
"erp_style_codes": ["NEW-ERP"],
"platform_product_ids": {
"tm": ["NEW-TM"],
"jd": ["NEW-JD"],
"jd_self": [],
"dy": [],
},
"sales_bitable_url": "https://example.feishu.cn/base/newsales?table=tblNewSales",
"tm_persona_bitable_url": "tm-persona-new",
}
]
records = merge_lifecycle_style_records(dynamic_records, lifecycle_rows)
by_platform = {row["platform"]: row for row in records}
assert by_platform["天猫"]["item_ids"] == ["NEW-TM"]
assert by_platform["京东旗舰店"]["item_ids"] == ["NEW-JD"]
assert by_platform["京东自营"]["item_ids"] == []
assert by_platform["抖音"]["item_ids"] == []
assert all(row["erp_codes"] == ["NEW-ERP"] for row in records)
assert by_platform["天猫"]["sales_bitable_url"].endswith("table=tblNewSales")
assert by_platform["天猫"]["tm_persona_bitable_url"] == "tm-persona-new"
def test_dynamic_targets_are_ignored_when_lifecycle_has_none() -> None:
"""建档地址为空时不再回退旧表:飞书地址只有 dim_style 一个来源。"""
records = merge_lifecycle_style_records(
[
{
"style_name": "款式乙",
"erp_codes": ["ERP-B"],
"platform": "天猫",
"item_ids": ["TM-B"],
"sales_bitable_url": "https://example.feishu.cn/base/legacy?table=tblLegacy",
"main_image_bitable_url": "https://example.feishu.cn/base/legacy?table=tblLegacyMain",
"creator_bitable_url": "https://example.feishu.cn/base/legacy?table=tblLegacyCreator",
}
],
[
{
"id": 9,
"name": "款式乙",
"erp_style_codes": ["ERP-B"],
"platform_product_ids": {"tm": ["TM-B"], "jd": [], "jd_self": [], "dy": []},
}
],
)
row = next(r for r in records if r["platform"] == "天猫")
assert row["sales_bitable_url"] == ""
assert row["main_image_bitable_url"] == ""
assert row["creator_bitable_url"] == ""
def test_series_targets_propagate_from_anchor() -> None:
"""锚点的 9 类地址下发到同系列子款;子款自身的旧地址被覆盖。"""
lifecycle_rows = [
{
"id": 1,
"name": "逐星",
"erp_style_codes": ["E-ANCHOR"],
"series_name": "逐星",
"is_series_anchor": True,
"platform_product_ids": {"tm": ["TM-A"], "jd": [], "jd_self": [], "dy": []},
"sales_bitable_url": "https://example.feishu.cn/base/anchor?table=tblAnchorSales",
"main_image_bitable_url": "https://example.feishu.cn/base/anchor?table=tblAnchorMain",
"creator_bitable_url": "https://example.feishu.cn/base/anchor?table=tblAnchorCreator",
},
{
"id": 2,
"name": "逐星GT",
"erp_style_codes": ["E-CHILD"],
"series_name": "逐星",
"is_series_anchor": False,
"platform_product_ids": {"tm": ["TM-C"], "jd": [], "jd_self": [], "dy": []},
"sales_bitable_url": "https://example.feishu.cn/base/child?table=tblChildSales",
},
]
records = merge_lifecycle_style_records([], lifecycle_rows)
child = next(r for r in records if r["style_name"] == "逐星GT")
assert "anchor" in child["sales_bitable_url"]
assert "anchor" in child["main_image_bitable_url"]
assert "anchor" in child["creator_bitable_url"]
def test_series_targets_aggregate_from_child_when_anchor_empty() -> None:
"""子款误填地址、锚点为空:聚合上浮到锚点并下发整条系列,不丢配置。"""
lifecycle_rows = [
{
"id": 1,
"name": "云栖",
"erp_style_codes": ["E-ANCHOR"],
"series_name": "云栖",
"is_series_anchor": True,
"platform_product_ids": {"tm": ["TM-A"], "jd": [], "jd_self": [], "dy": []},
},
{
"id": 2,
"name": "云栖标准版",
"erp_style_codes": ["E-CHILD"],
"series_name": "云栖",
"is_series_anchor": False,
"platform_product_ids": {"tm": [], "jd": [], "jd_self": [], "dy": []},
"sales_bitable_url": "https://example.feishu.cn/base/child?table=tblChildSales",
"main_image_bitable_url": "https://example.feishu.cn/base/child?table=tblChildMain",
},
]
records = merge_lifecycle_style_records([], lifecycle_rows)
anchor_sales = {r["sales_bitable_url"] for r in records if r["style_name"] == "云栖"}
child_sales = {r["sales_bitable_url"] for r in records if r["style_name"] == "云栖标准版"}
# 子款地址上浮到锚点,并统一下发到整条系列
assert anchor_sales == {"https://example.feishu.cn/base/child?table=tblChildSales"}
assert child_sales == anchor_sales
def test_series_targets_conflict_keeps_anchor_address(capsys) -> None:
"""同一字段多张表:保留锚点那条并告警,避免同系列拆到两个表。"""
lifecycle_rows = [
{
"id": 1,
"name": "冲突款",
"erp_style_codes": ["E-ANCHOR"],
"series_name": "冲突系列",
"is_series_anchor": True,
"platform_product_ids": {"tm": ["TM-A"], "jd": [], "jd_self": [], "dy": []},
"sales_bitable_url": "https://example.feishu.cn/base/anchor?table=tblAnchorSales",
},
{
"id": 2,
"name": "冲突款-2",
"erp_style_codes": ["E-CHILD"],
"series_name": "冲突系列",
"is_series_anchor": False,
"platform_product_ids": {"tm": [], "jd": [], "jd_self": [], "dy": []},
"sales_bitable_url": "https://example.feishu.cn/base/child?table=tblChildSales",
},
]
records = merge_lifecycle_style_records([], lifecycle_rows)
assert {r["sales_bitable_url"] for r in records} == {
"https://example.feishu.cn/base/anchor?table=tblAnchorSales"
}
assert "个不同地址" in capsys.readouterr().out
def test_runtime_records_empty_lifecycle_ids_clear_legacy_platform_ids() -> None:
records = merge_lifecycle_style_records(
[
{
"style_name": "研发款",
"erp_codes": ["ERP-1"],
"platform": "天猫",
"item_ids": ["LEGACY-TM"],
}
],
[
{
"id": 9,
"name": "研发款",
"erp_style_codes": ["ERP-1"],
"platform_product_ids": {
"tm": [],
"jd": [],
"jd_self": [],
"dy": [],
},
}
],
)
assert all(not row["item_ids"] for row in records)
def test_lifecycle_merge_respects_disabled_dynamic_platform() -> None:
records = merge_lifecycle_style_records(
[
{
"style_name": "款式甲",
"platform": "天猫",
"item_ids": ["TM-1"],
"enabled": False,
},
{
"style_name": "款式甲",
"platform": "京东旗舰店",
"item_ids": ["JD-1"],
"enabled": True,
},
],
[
{
"id": 10,
"name": "款式甲",
"erp_style_codes": ["ERP-1"],
"platform_product_ids": {
"tm": ["TM-1"],
"jd": ["JD-1"],
"jd_self": [],
"dy": [],
},
}
],
)
assert {row["platform"] for row in records} == {"京东旗舰店", "京东自营", "抖音"}
def test_normalize_style_config_supports_extensible_business_targets() -> None:
payload = _payload()
payload["destinations"] = [
{
"key": "sales_bitable_url",
"label": "销量表",
"url": "https://example.feishu.cn/base/sales?table=tblSales",
"description": "销量日报写入目标",
"enabled": True,
},
{
"key": "style_quality_analysis",
"label": "款式质量分析",
"url": "https://example.feishu.cn/base/quality?table=tblQuality",
"description": "未来分析逻辑写入的结果表",
"enabled": True,
},
]
normalized = normalize_style_config(payload)
assert [item["key"] for item in normalized["destinations"]] == [
"sales_bitable_url",
"style_quality_analysis",
]
assert normalized["sales_bitable_url"] == (
"https://example.feishu.cn/base/sales?table=tblSales"
)
assert normalized["destinations"][1]["description"] == (
"未来分析逻辑写入的结果表"
)
def test_postgres_store_omits_empty_dsn_for_split_connection_fields(
monkeypatch,
) -> None:
from gyxx_flow.adapters import workflow_config
calls: list[dict[str, Any]] = []
class FakeConnection:
def __enter__(self):
return self
def __exit__(self, *_args) -> None:
pass
def close(self) -> None:
pass
def connect(**kwargs):
calls.append(kwargs)
return FakeConnection()
monkeypatch.setattr(workflow_config.psycopg, "connect", connect)
store = PostgresWorkflowConfigStore(
{
"PG_HOST": "db.example.test",
"PG_PORT": "5432",
"PG_DB": "app",
"PG_USER": "app",
"PG_PASSWORD": "secret",
}
)
with store._connection():
pass
assert calls[0]["host"] == "db.example.test"
assert "conninfo" not in calls[0]
def test_migration_seed_captures_all_feishu_records() -> None:
seed = json.loads(
(PROJECT_ROOT / "config" / "workflow-dynamic-config-seed.json").read_text(
encoding="utf-8"
)
)
assert seed["record_count"] == 215
assert len(seed["records"]) == 215
assert len({item["source_record_id"] for item in seed["records"]}) == 215
assert any(
item["config"]["style_name"] == "极星pro"
and item["config"]["platform"] == "天猫"
for item in seed["records"]
)
def test_style_loader_reads_database_provider_and_never_needs_feishu(
tmp_path: Path,
) -> None:
loader = StyleConfigLoader(
cache_path=tmp_path / "style-cache.json",
records_provider=lambda: [
{
**normalize_config(
{
"platform": "天猫",
"style_name": "极星pro",
"brand": "光影行星",
"erp_codes": ["10398", "10400"],
"item_ids": ["832214449817"],
"note": "",
"sales_bitable_url": "https://example.feishu.cn/base/abc?table=tbl1",
"main_image_bitable_url": "",
"creator_bitable_url": "",
"tm_persona_bitable_url": "",
"jd_persona_bitable_url": "",
"dy_persona_bitable_url": "",
"self_creator_bitable_url": "",
"weekly_note_analysis_url": "",
"style_analysis_bitable_url": "",
"style_content": "",
"enabled": True,
}
),
"sales_bitable_url": (
"https://example.feishu.cn/base/abc?table=tbl1&view=vew1"
),
}
],
)
loaded = loader.load()
assert loaded["source"] == "database"
assert loaded["styles"]["极星pro"]["tm"]["item_ids"] == [
"832214449817"
]
assert loaded["styles"]["极星pro"]["erp_codes"] == ["10398", "10400"]
def test_style_loader_preserves_extensible_business_targets(tmp_path: Path) -> None:
destinations = [
{
"key": "style_quality_analysis",
"label": "款式质量分析",
"url": "https://example.feishu.cn/base/quality?table=tblQuality",
"description": "质量分析结果表",
"enabled": True,
}
]
loader = StyleConfigLoader(
cache_path=tmp_path / "style-cache.json",
records_provider=lambda: [
{
"platform": "天猫",
"style_name": "极星pro",
"brand": "光影行星",
"erp_codes": ["10398"],
"item_ids": ["832214449817"],
"sales_bitable_url": (
"https://example.feishu.cn/base/sales?table=tblSales"
),
"destinations": destinations,
}
],
)
loaded = loader.load()
assert loaded["styles"]["极星pro"]["destinations"] == destinations
def test_grouped_database_keeps_original_platform_and_target_contracts() -> None:
shared = {
"style_name": "款式甲",
"brand": "光影行星",
"erp_codes": ["ERP-1"],
"sales_bitable_url": "https://example.feishu.cn/base/sales?table=tblSales",
"main_image_bitable_url": "https://example.feishu.cn/base/image?table=tblImage",
"tm_persona_bitable_url": "https://example.feishu.cn/base/tm?table=tblTm",
"jd_persona_bitable_url": "https://example.feishu.cn/base/jd?table=tblJd",
"dy_persona_bitable_url": "https://example.feishu.cn/base/dy?table=tblDy",
"style_analysis_bitable_url": "https://example.feishu.cn/base/report?table=tblReport",
}
rows = [
{**shared, "platform": "天猫", "item_ids": ["TM-1"]},
{**shared, "platform": "京东旗舰店", "item_ids": ["JD-SPU-1"]},
{**shared, "platform": "京东自营", "item_ids": ["JD-SKU-1"]},
{**shared, "platform": "抖音", "item_ids": ["DY-1"]},
]
loader = StyleConfigLoader(records_provider=lambda: rows)
style = loader._aggregate(loader._load_records_from_database())["styles"]["款式甲"]
assert style["erp_codes"] == ["ERP-1"]
assert style["tm"]["item_ids"] == ["TM-1"]
assert style["jd"]["spus"] == ["JD-SPU-1"]
assert style["jd_self"]["skus"] == ["JD-SKU-1"]
assert style["dy"]["item_ids"] == ["DY-1"]
assert style["sales_bitable"]["table_id"] == "tblSales"
assert style["main_image_bitable"]["table_id"] == "tblImage"
assert style["persona_bitable"]["table_id"] == "tblTm"
assert style["jd_persona_bitable"]["table_id"] == "tblJd"
assert style["dy_persona_bitable"]["table_id"] == "tblDy"
assert style["style_analysis_bitable"]["table_id"] == "tblReport"
def test_console_dynamic_config_crud_maps_optimistic_conflicts(
tmp_path: Path,
) -> None:
service = WorkflowConsoleService(
Settings(project_root=PROJECT_ROOT, data_root=tmp_path),
dynamic_configs=FakeDynamicConfigStore(),
)
assert service.dynamic_config_snapshot()["summary"]["total"] == 1
created = service.create_dynamic_config(_payload())
assert created["revision"] == 1
updated = service.update_dynamic_config(1, _payload(), expected_revision=1)
assert updated["revision"] == 2
with pytest.raises(ConsoleConflictError, match="其他人更新"):
service.update_dynamic_config(1, _payload(), expected_revision=9)
def test_console_assets_include_dynamic_config_crud_page() -> None:
html = (PROJECT_ROOT / "src" / "gyxx_flow" / "web" / "index.html").read_text(
encoding="utf-8"
)
script = (PROJECT_ROOT / "src" / "gyxx_flow" / "web" / "app.js").read_text(
encoding="utf-8"
)
assert 'id="dynamic-config-view"' in html
assert 'id="dynamic-config-form"' in html
assert "/api/dynamic-configs" in script
assert "#/configs/products" in script
def test_content_summary_targets_are_loaded_from_project_database(monkeypatch) -> None:
from gyxx_flow.modules.content_marketing import weekly_summary_all
monkeypatch.setattr(
weekly_summary_all,
"runtime_workflow_configs",
lambda: [
{
"style_name": "极星pro",
"weekly_note_analysis_url": (
"https://example.feishu.cn/base/base1?table=table1&view=view1"
),
}
],
)
assert weekly_summary_all.load_index_styles() == {
"极星pro": (
"base1",
"table1",
"https://example.feishu.cn/base/base1?table=table1&view=view1",
)
}
def test_content_mapping_rejects_legacy_static_cache(
monkeypatch, tmp_path: Path
) -> None:
from gyxx_flow.modules.content_marketing import feishu_mapping
cache_path = tmp_path / "mapping.json"
cache_path.write_text(
json.dumps({"platform": "xhs_pgy", "tables": [{"name": "旧款"}]}),
encoding="utf-8",
)
monkeypatch.setattr(feishu_mapping, "MAPPING_PATH", cache_path)
monkeypatch.setattr(feishu_mapping, "refresh_mapping", lambda **_kwargs: None)
with pytest.raises(RuntimeError, match="项目数据库不可用"):
feishu_mapping.load_mapping(force_refresh=False)
def test_retired_feishu_master_is_not_referenced_by_runtime_modules() -> None:
retired_token = "TtoCb1NuQaDy3NsZWTpc0GIvnph"
runtime_root = PROJECT_ROOT / "src" / "gyxx_flow"
offenders = []
for path in runtime_root.rglob("*.py"):
if path == runtime_root / "migration" / "feishu_style_config_seed.py":
continue
if retired_token in path.read_text(encoding="utf-8"):
offenders.append(path.relative_to(PROJECT_ROOT).as_posix())
assert offenders == []
def test_dynamic_config_http_crud_uses_revision_preconditions(tmp_path: Path) -> None:
server = create_console_server(
Settings(project_root=PROJECT_ROOT, data_root=tmp_path),
host="127.0.0.1",
port=0,
dynamic_configs=FakeDynamicConfigStore(),
)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
base_url = f"http://127.0.0.1:{server.server_address[1]}"
def request(
method: str,
path: str,
payload: dict[str, Any] | None = None,
revision: int | None = None,
) -> tuple[int, dict[str, Any]]:
data = json.dumps(payload).encode("utf-8") if payload is not None else None
headers = {
"Content-Type": "application/json",
"X-GYXX-Console": "1",
"Origin": base_url,
}
if revision is not None:
headers["If-Match"] = str(revision)
response = urlopen(
Request(base_url + path, data=data, headers=headers, method=method),
timeout=5,
)
return response.status, json.loads(response.read().decode("utf-8"))
try:
status, snapshot = request("GET", "/api/dynamic-configs")
assert status == 200
assert snapshot["summary"]["total"] == 1
status, created = request("POST", "/api/dynamic-configs", _payload())
assert status == 201
assert created["revision"] == 1
status, updated = request(
"PUT", "/api/dynamic-configs/1", _payload(), revision=1
)
assert status == 200
assert updated["revision"] == 2
status, deleted = request(
"DELETE", "/api/dynamic-configs/1", revision=1
)
assert status == 200
assert deleted == {"deleted": True, "id": 1}
finally:
server.shutdown()
server.server_close()
thread.join(timeout=5)