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)
This commit is contained in:
wangyunlong committed 2026-10-30 11:49:31 +08:00
1 parent 2f31d5872f
commit 622a609800
66 files changed
+4156 -469

No files matched your search

@@ -0,0 +1,74 @@
"""飞书目标表地址唯一来源:dim_style,失败即闭环保,不回退本地 JSON。
2026-10-30 起三个写表脚本(日报 / 天猫主图 / 京东主图)都去掉了
``read_json(bitable_*_map.json)`` 回退——库与本地文件不一致时,回退会让
数据静默写到错误的表,比直接失败更难发现。
"""
from __future__ import annotations
import sys
import insert_bitable_records as daily_insert
import pytest
from scripts import insert_jd_main_image_records as jd_insert
from scripts import insert_main_image_records as tmall_insert
def _raise_config_error() -> dict:
raise RuntimeError("云端动态配置读取失败")
@pytest.mark.parametrize(
"module, attr",
[
(daily_insert, "STYLE_MAP_PATH"),
(tmall_insert, "MAIN_IMAGE_MAP"),
],
)
def test_local_json_target_maps_are_gone(module, attr) -> None:
"""本地 JSON 回退入口必须彻底移除,防止以后被重新接回链路。"""
assert not hasattr(module, attr)
def test_daily_insert_fails_closed_without_style_map(monkeypatch) -> None:
monkeypatch.setattr(daily_insert, "get_bitable_style_map", _raise_config_error)
assert daily_insert.main(["--date", "2026-08-03"]) == 1
def test_daily_insert_fails_closed_on_empty_style_map(monkeypatch) -> None:
monkeypatch.setattr(daily_insert, "get_bitable_style_map", lambda: {})
assert daily_insert.main(["--date", "2026-08-03"]) == 1
def test_tmall_main_image_insert_fails_closed(monkeypatch) -> None:
monkeypatch.setattr(sys, "argv", ["insert_main_image_records.py", "--date", "2026-10-25"])
monkeypatch.setattr(tmall_insert, "get_main_image_styles", lambda: ["某款"])
monkeypatch.setattr(tmall_insert, "get_bitable_main_image_map", _raise_config_error)
assert tmall_insert.main() == 1
def test_tmall_main_image_insert_fails_closed_on_empty_map(monkeypatch) -> None:
monkeypatch.setattr(sys, "argv", ["insert_main_image_records.py", "--date", "2026-10-25"])
monkeypatch.setattr(tmall_insert, "get_main_image_styles", lambda: ["某款"])
monkeypatch.setattr(tmall_insert, "get_bitable_main_image_map", lambda: {})
assert tmall_insert.main() == 1
def test_jd_main_image_insert_fails_closed(monkeypatch) -> None:
monkeypatch.setattr(sys, "argv", ["insert_jd_main_image_records.py", "--date", "2026-10-25"])
monkeypatch.setattr(jd_insert, "get_bitable_main_image_map", _raise_config_error)
assert jd_insert.main() == 1
def test_jd_main_image_insert_fails_closed_on_empty_map(monkeypatch) -> None:
monkeypatch.setattr(sys, "argv", ["insert_jd_main_image_records.py", "--date", "2026-10-25"])
monkeypatch.setattr(jd_insert, "get_bitable_main_image_map", lambda: {})
assert jd_insert.main() == 1
@@ -0,0 +1,151 @@
"""商品日报采集开关(dim_style.collect_enabled,系列级)判定行为。
开关已从独立表 product_daily_collect_scope 迁到 dim_style.collect_enabled:
- 锚点是系列总开关,为 False 时整条系列不采;
- 锚点为 True 时,子款自身的开关只决定它的 ERP 编码是否并入聚合;
- 缺字段默认采集;
- 产品生命进程(另一个项目)只改锚点行即可控制整条系列。
"""
from __future__ import annotations
from gyxx_flow.modules.product_commerce.config.style_config_loader import (
StyleConfigLoader,
)
SALES_URL = "https://example.feishu.cn/base/sales?table=tblSales"
def _row(
style: str,
item_id: str,
*,
platform: str = "天猫",
collect_enabled: bool | None = True,
series_name: str = "",
is_series_anchor: bool = False,
erp_codes: list[str] | None = None,
) -> dict:
row = {
"platform": platform,
"style_name": style,
"erp_codes": erp_codes if erp_codes is not None else [f"ERP-{style}"],
"item_ids": [item_id],
"sales_bitable_url": SALES_URL,
"series_name": series_name,
"is_series_anchor": is_series_anchor,
}
if collect_enabled is not None:
row["collect_enabled"] = collect_enabled
return row
def _loader(rows: list[dict]) -> StyleConfigLoader:
return StyleConfigLoader(records_provider=lambda: rows)
def _two_plain_styles(flag_b: bool | None = True) -> list[dict]:
return [
_row("款A", "TM-1"),
_row("款B", "TM-2", collect_enabled=flag_b),
]
def test_style_flagged_disabled_is_not_collected() -> None:
loader = _loader(_two_plain_styles(flag_b=False))
daily = loader.get_daily_styles()
assert daily["tm"]["collectable"] == ["款A"]
assert {"style": "款B", "reason": "已标记不采集"} in daily["tm"]["skipped"]
assert loader.get_erp_daily_styles()["collectable"] == ["款A"]
def test_missing_flag_defaults_to_enabled() -> None:
"""字段缺失(旧 cmt_styles 回退 / 动态配置独有款)默认采集。"""
loader = _loader(_two_plain_styles(flag_b=None))
assert loader.get_daily_styles()["tm"]["collectable"] == ["款A", "款B"]
assert loader.get_erp_daily_styles()["collectable"] == ["款A", "款B"]
def test_series_anchor_disable_closes_whole_series() -> None:
"""系列级开关:锚点关掉,即使子款自己的 flag 为 True 也不采。"""
rows = [
_row("宙斯", "TM-9", series_name="宙斯", is_series_anchor=True,
collect_enabled=False, erp_codes=[]),
_row("宙斯3", "TM-10", series_name="宙斯", is_series_anchor=False,
collect_enabled=True, erp_codes=["10244"]),
]
loader = _loader(rows)
daily = loader.get_daily_styles()
assert daily["tm"]["collectable"] == []
assert {"style": "宙斯", "reason": "已标记不采集"} in daily["tm"]["skipped"]
assert {"style": "宙斯3", "reason": "已标记不采集"} in daily["tm"]["skipped"]
assert loader.get_erp_daily_styles()["collectable"] == []
def test_series_anchor_enable_wins_over_child_flag() -> None:
"""锚点是总开关:子款 False 不会让整系列停采,但它的 ERP 编码不并入聚合。"""
rows = [
_row("逐星", "TM-11", series_name="逐星", is_series_anchor=True,
collect_enabled=True, erp_codes=[]),
_row("逐星GT", "TM-12", series_name="逐星", is_series_anchor=False,
collect_enabled=False, erp_codes=["10494"]),
]
loader = _loader(rows)
erp_daily = loader.get_erp_daily_styles()
# 子款关闭后编码不进聚合,锚点自己没有编码 → 该系列不出现在采集清单
assert erp_daily["collectable"] == []
assert {"style": "逐星", "reason": "缺 ERP款式编码"} in erp_daily["skipped"]
# 子款仍然让出,不会被自己单独采一次
assert {"style": "逐星GT", "reason": "ERP 已由同系列「逐星」归集采集"} in erp_daily["skipped"]
def test_anchor_keeps_own_codes_when_child_disabled() -> None:
"""锚点 true + 子款 false:只把子款编码排除,锚点自己的编码照常采集。"""
rows = [
_row("逐星", "TM-11", series_name="逐星", is_series_anchor=True,
collect_enabled=True, erp_codes=["10493"]),
_row("逐星GT", "TM-12", series_name="逐星", is_series_anchor=False,
collect_enabled=False, erp_codes=["10494"]),
]
loader = _loader(rows)
payload = loader.load()
assert payload["styles"]["逐星"]["erp_codes"] == ["10493"]
assert payload["styles"]["逐星"]["series"]["erp_excluded"] == ["逐星GT"]
# 子款自己的原始编码保留,供月度销量表按款查询
assert payload["styles"]["逐星GT"]["series"]["own_erp_codes"] == ["10494"]
assert loader.get_erp_daily_styles()["collectable"] == ["逐星"]
def test_series_without_anchor_uses_sorted_first_style() -> None:
"""没有锚点标记时,系列内按款名排序第一个的值作为系列开关。"""
rows = [
_row("B款", "TM-1", series_name="无锚系列", collect_enabled=True),
_row("A款", "TM-2", series_name="无锚系列", collect_enabled=False),
]
loader = _loader(rows)
daily = loader.get_daily_styles()
# A款 排在前,其 False 成为系列值
assert daily["tm"]["collectable"] == []
def test_injected_records_provider_keeps_legacy_behavior() -> None:
loader = StyleConfigLoader(
records_provider=lambda: [_row("款A", "TM-1"), _row("款B", "TM-2")]
)
assert loader.get_daily_styles()["tm"]["collectable"] == ["款A", "款B"]
@@ -43,7 +43,9 @@ def test_style_plan_rejects_one_erp_code_owned_by_two_styles() -> None:
)
def test_style_plan_skips_erp_only_style_until_product_id_is_added() -> None:
def test_style_plan_keeps_erp_only_style() -> None:
"""全店铺日报按 ERP 编码取数,不需要平台商品编码。"""
plans = backfill.build_style_plans(
{
"styles": {
@@ -56,7 +58,44 @@ def test_style_plan_skips_erp_only_style_until_product_id_is_added() -> None:
}
)
assert plans == (backfill.StylePlan("已上架款", ("ERP-READY",)),)
assert plans == (
backfill.StylePlan("研发款", ("ERP-ONLY",)),
backfill.StylePlan("已上架款", ("ERP-READY",)),
)
def test_style_plan_folds_series_codes_into_anchor() -> None:
"""同系列多款:锚点承接并集编码,子款让出重复编码,不报冲突。"""
plans = backfill.build_style_plans(
{
"styles": {
"宙斯": {
"erp_codes": ["10244", "10387"],
"tm": {"item_ids": ["TM-1"]},
"series": {"name": "宙斯", "erp_owner": "宙斯"},
},
"宙斯-10244": {
"erp_codes": ["10244"],
"series": {
"name": "宙斯",
"erp_owner": "宙斯",
"own_erp_codes": ["10244"],
},
},
"宙斯Z": {
"erp_codes": ["10387"],
"series": {
"name": "宙斯",
"erp_owner": "宙斯",
"own_erp_codes": ["10387"],
},
},
}
}
)
assert plans == (backfill.StylePlan("宙斯", ("10244", "10387")),)
def test_all_shop_filter_selects_every_shop_and_batches_all_codes() -> None:
@@ -140,9 +179,15 @@ def test_style_extraction_maps_hidden_columns_via_visible_positions() -> None:
backfill.extract_style_rows(frame, requested_codes=("10416",))
assert "window._Cols" in frame.script
assert "visibleIdx.indexOf" in frame.script
assert "const toPos = index" in frame.script
assert "cells[stylePos]" in frame.script
assert "cells.length <= Math.max(stylePos, salesPos, returnsPos)" in frame.script
# 列布局未知时必须 fail closed,不能退回「按表头索引直取」
assert "column_layout_unknown" in frame.script
# 行 td 数必须与可见列数严格相等
assert "cells.length !== expectedCells" in frame.script
# 值域交叉校验:销量不得超过同行月销量一个量级
assert "sales_out_of_range" in frame.script
assert "month_sales_pos" in frame.script
def test_ensure_custom_columns_visible_only_touches_allsku_frames() -> None:
@@ -150,16 +195,16 @@ def test_ensure_custom_columns_visible_only_touches_allsku_frames() -> None:
url = "https://bi.erp321.com/app/report/subject/itemskuanalysis/allsku.aspx?r=1"
def __init__(self) -> None:
self.scripts: list[str] = []
self.calls: list[tuple] = []
def evaluate(self, script):
self.scripts.append(script)
return 2
def evaluate(self, script, *args):
self.calls.append((script, *args))
return {"changed": 2, "redrawn": True}
class OtherFrame:
url = "https://bi.erp321.com/app/report/subject/itemskuanalysis/filter.aspx"
def evaluate(self, script):
def evaluate(self, script, *args):
raise AssertionError("must not touch non-allsku frames")
class Page:
@@ -170,10 +215,59 @@ def test_ensure_custom_columns_visible_only_touches_allsku_frames() -> None:
assert changed == 2
allsku = page.frames[1]
assert len(allsku.scripts) == 1
assert "'自定义销量'" in allsku.scripts[0]
assert "'自定义退货量'" in allsku.scripts[0]
assert "_RptRefresh" in allsku.scripts[0]
assert len(allsku.calls) == 1
script, options = allsku.calls[0]
assert "'自定义销量'" in script
assert "'自定义退货量'" in script
# 换页重绘才是补齐数据行单元格的手段;_RptRefresh 在未渲染时会抛错。
assert "GotoPage" in script
assert options["redrawPage"] == 1
def test_ensure_custom_columns_rendered_redraws_requested_page() -> None:
class AllskuFrame:
url = "https://bi.erp321.com/app/report/subject/itemskuanalysis/allsku.aspx?r=1"
def __init__(self) -> None:
self.calls: list[tuple] = []
def evaluate(self, script, *args):
self.calls.append((script, *args))
return {"changed": 2, "redrawn": True}
class Page:
frames = [AllskuFrame()]
page = Page()
summary = backfill.ensure_custom_columns_rendered(page, redraw_page=3)
assert summary == {"changed": 2, "redrawn": 1}
assert page.frames[0].calls[0][1]["redrawPage"] == 3
assert page.frames[0].calls[0][1]["forceRedraw"] is False
def test_ensure_custom_columns_rendered_force_redraw_without_visibility_change() -> None:
class AllskuFrame:
url = "https://bi.erp321.com/app/report/subject/itemskuanalysis/allsku.aspx?r=1"
def __init__(self) -> None:
self.calls: list[tuple] = []
def evaluate(self, script, *args):
self.calls.append((script, *args))
return {"changed": 0, "redrawn": True}
class Page:
frames = [AllskuFrame()]
page = Page()
summary = backfill.ensure_custom_columns_rendered(
page, redraw_page=2, force_redraw=True
)
assert summary == {"changed": 0, "redrawn": 1}
assert page.frames[0].calls[0][1]["redrawPage"] == 2
assert page.frames[0].calls[0][1]["forceRedraw"] is True
def test_range_collector_queries_once_per_date_not_once_per_style(monkeypatch) -> None:
@@ -256,3 +350,73 @@ def test_schema_keeps_all_shop_metrics_separate_from_platform_metrics() -> None:
assert "CREATE TABLE IF NOT EXISTS erp_all_shop_style_daily_metrics" in schema
assert "PRIMARY KEY (metric_date, style_name)" in schema
assert "return_qty" in schema
def test_daily_total_guard_rejects_month_scale_values(monkeypatch) -> None:
"""单日合计比近 14 天中位数高 8 倍以上必须拒绝落库。"""
monkeypatch.setattr(backfill, "_recent_sales_baseline", lambda *a, **k: 2000.0)
with pytest.raises(RuntimeError, match="looks wrong"):
backfill._guard_daily_total(
date(2026, 10, 29),
[{"sales": 700000, "return_qty": 120000}],
)
# 正常量级放行
backfill._guard_daily_total(
date(2026, 10, 29), [{"sales": 3656, "return_qty": 534}]
)
# --force 时不做量级判断(业务量确实变化的场景)
backfill._guard_daily_total(
date(2026, 10, 29),
[{"sales": 700000, "return_qty": 120000}],
force=True,
)
def test_daily_total_guard_skips_when_no_baseline(monkeypatch) -> None:
monkeypatch.setattr(backfill, "_recent_sales_baseline", lambda *a, **k: None)
backfill._guard_daily_total(
date(2026, 10, 29), [{"sales": 999999, "return_qty": 0}]
)
def test_partial_page_is_treated_as_not_ready(monkeypatch) -> None:
"""非最后一页渲染不满 200 行必须重试,不能当成已完成收下。"""
class Frame:
url = "https://bi.erp321.com/app/report/subject/itemskuanalysis/allsku.aspx"
def evaluate(self, script, args=None):
return {"ok": True, "rows": [{"sales": 1}] * 174, "signature": "s"}
monkeypatch.setattr(backfill, "_allsku_frame", lambda page: Frame())
monkeypatch.setattr(
backfill, "ensure_custom_columns_rendered", lambda *a, **k: {"changed": 1}
)
with pytest.raises(TimeoutError, match="174/200 rows"):
backfill._wait_rendered_page(
object(), ("10416",), page_no=1, timeout_seconds=1, expect_full_page=True
)
def test_last_page_is_allowed_to_be_partial(monkeypatch) -> None:
class Frame:
url = "https://bi.erp321.com/app/report/subject/itemskuanalysis/allsku.aspx"
def evaluate(self, script, args=None):
return {"ok": True, "rows": [{"sales": 1}] * 59, "signature": "s"}
monkeypatch.setattr(backfill, "_allsku_frame", lambda page: Frame())
monkeypatch.setattr(
backfill, "ensure_custom_columns_rendered", lambda *a, **k: {"changed": 0}
)
result = backfill._wait_rendered_page(
object(), ("10416",), page_no=3, timeout_seconds=5, expect_full_page=False
)
assert result["ok"] is True
assert len(result["rows"]) == 59
@@ -156,9 +156,9 @@ def test_upsert_sales_theme_dedups_and_normalizes(monkeypatch: pytest.MonkeyPatc
def test_business_scope_constants_are_consistent() -> None:
assert len(collector.BRANDS) == 3
assert sorted(collector.BRANDS) == ["gyxx", "光影", "光影行星"]
assert len(collector.SHOP_NAMES) == len(set(collector.SHOP_NAMES)) > 0
# 2026-10-19 起品牌不限(全不勾); 店铺固定 22 家光影系渠道
assert collector.BRANDS == ()
assert len(collector.SHOP_NAMES) == 22 == len(set(collector.SHOP_NAMES))
for name in collector.SHOP_NAMES:
assert name == name.strip()
@@ -274,3 +274,51 @@ def test_parse_files_still_fails_when_both_reports_are_empty(tmp_path: Path) ->
advertiser_pin="光影行星旗舰店霖",
shop_account="光影行星研发",
)
def test_sku_match_prefers_explicit_jd_spus_owner_over_sub_style_name() -> None:
"""子款式名称更长时,仍按 dim_style.jd_spus 归属归到父款式(宙斯系)。"""
result = importer.resolve_sku_match(
"光影行星(GYXX)宙斯3双肩背包男士电脑包2026新款 【宙斯3】曜夜黑/大",
style_catalog=[
{"style_name": "宙斯", "jd_spus": ["spu-zeus3", "spu-zair"]},
{"style_name": "宙斯3", "jd_spus": []},
{"style_name": "宙斯Z Air", "jd_spus": []},
],
full_records=[
{"spu_id": "spu-zeus3", "spu_name": "光影行星(GYXX)宙斯3双肩背包男士电脑包"},
],
)
assert result["status"] == "matched"
assert result["spu_id"] == "spu-zeus3"
assert result["style_name"] == "宙斯"
assert "dim_style.jd_spus" in result["reason"]
def test_sku_match_keeps_style_when_spu_owner_is_itself() -> None:
result = importer.resolve_sku_match(
"光影行星(GYXX)盖亚C1斜挎包 曜夜黑",
style_catalog=[{"style_name": "盖亚斜挎", "jd_spus": ["spu-gy"]}],
full_records=[{"spu_id": "spu-gy", "spu_name": "盖亚C1斜挎包"}],
)
assert result["style_name"] == "盖亚斜挎"
def test_rollup_map_moves_sub_style_to_parent() -> None:
rolled = importer._apply_rollup(
{"status": "matched", "style_name": "宙斯3", "reason": "unique SPU name containment"},
{"宙斯3": "宙斯"},
)
assert rolled["style_name"] == "宙斯"
assert "父款式" in rolled["reason"]
def test_default_rollup_config_declares_zeus_family() -> None:
rollup = importer.load_rollup_map("jd")
assert rollup.get("宙斯3") == "宙斯"
assert rollup.get("宙斯Z Air") == "宙斯"
@@ -96,6 +96,84 @@ class MainImageWorkflowPreflightTests(unittest.TestCase):
jd_runner.load_dynamic_styles()
class MainImageMissingTableAddressTests(unittest.TestCase):
"""缺主图飞书表地址:照常采图入库,只是不写表(不报错、不阻断)。"""
def _write_tmall_payload(self, root: Path, style: str) -> None:
style_dir = root / "2026-08-02" / style
images = style_dir / "tm_images"
images.mkdir(parents=True)
(images / "a.png").write_bytes(b"img")
(style_dir / "wanxiang_creative_main_images.json").write_text(
json.dumps({"creatives": [{"image_key": "a.png", "impressions": 10, "clicks": 2}]}),
encoding="utf-8",
)
def _write_jd_payload(self, root: Path, style: str) -> None:
style_dir = root / "2026-08-02" / style
images = style_dir / "jd_images"
images.mkdir(parents=True)
(images / "a.jpg").write_bytes(b"img")
(style_dir / "jd_main_images.json").write_text(
json.dumps({"records": [{"image_key": "a.jpg", "spu": "JD-1", "impressions": 5, "clicks": 1}]}),
encoding="utf-8",
)
def test_tmall_style_without_address_only_lands_in_postgres(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
self._write_tmall_payload(root, "缺地址款")
pg_records: list = []
with (
patch.object(tmall_insert, "artifact_relative_path", return_value="a.png"),
patch.object(tmall_insert, "discover_fields") as discover,
patch.object(tmall_insert, "run_lark") as run_lark,
):
result = tmall_insert.process_product(
"缺地址款",
{"display_name": "缺地址款", "platform": "天猫", "enabled": True},
"2026-08-02",
root,
dry_run=False,
pg_records=pg_records,
)
discover.assert_not_called()
run_lark.assert_not_called()
self.assertEqual(result["found"], 1)
self.assertEqual(result["inserted"], 0)
self.assertEqual(result["errors"], [])
self.assertEqual([r["style_name"] for r in pg_records], ["缺地址款"])
self.assertIsNone(pg_records[0]["bitable_base"])
self.assertFalse(pg_records[0]["uploaded_to_bitable"])
def test_jd_style_without_address_only_lands_in_postgres(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
self._write_jd_payload(root, "缺地址款")
pg_records: list = []
with (
patch.object(jd_insert, "artifact_relative_path", return_value="a.jpg"),
patch.object(jd_insert, "discover_fields") as discover,
patch.object(jd_insert, "batch_create_records") as create,
):
result = jd_insert.process_style(
"缺地址款",
{"display_name": "缺地址款", "platform": "京东", "enabled": True},
"2026-08-02",
root,
dry_run=False,
pg_records=pg_records,
)
discover.assert_not_called()
create.assert_not_called()
self.assertEqual(result["found"], 1)
self.assertEqual(result["inserted"], 0)
self.assertEqual([r["style_name"] for r in pg_records], ["缺地址款"])
self.assertIsNone(pg_records[0]["bitable_base"])
class TmallCleanupTests(unittest.TestCase):
def test_locked_duplicate_is_retried_without_console_encoding_failure(self):
with tempfile.TemporaryDirectory() as tmp:
@@ -0,0 +1,242 @@
"""Tests for the weekly points-exchange ranking workflow."""
from __future__ import annotations
import importlib.util
import sys
from datetime import date
from decimal import Decimal
from pathlib import Path
from types import ModuleType, SimpleNamespace
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[3]
MODULE_PATH = (
PROJECT_ROOT
/ "src"
/ "gyxx_flow"
/ "modules"
/ "product_commerce"
/ "points_exchange_weekly.py"
)
ROWS: list[dict[str, object]] = [
{"itemId": "A", "title": "物资【电源包】", "skuId": "s1", "skuCode": "c1", "quantity": 491},
{"itemId": "A", "title": "物资【电源包】", "skuId": "s2", "skuCode": "c2", "quantity": 472},
{"itemId": "B", "title": "物资【耳机包】", "skuId": "s3", "skuCode": "c3", "quantity": 133},
{
"itemId": "B",
"title": "GYXX-物资【耳机包】",
"skuId": "s4",
"skuCode": "c4",
"quantity": 191,
},
{"itemId": "C", "title": "物资【零兑换】", "skuId": "s5", "skuCode": "c5", "quantity": 0},
]
@pytest.fixture(name="module")
def module_fixture() -> ModuleType:
spec = importlib.util.spec_from_file_location(
"points_exchange_weekly_under_test", MODULE_PATH
)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
def test_default_window_is_last_completed_natural_week(
module: ModuleType, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("GYXX_BUSINESS_DATE", "2026-10-26")
start, end = module.resolve_window(None, None)
assert (start, end) == (date(2026, 10, 19), date(2026, 10, 25))
@pytest.mark.parametrize(
("start_value", "end_value", "expected"),
[
("2026-10-19", None, (date(2026, 10, 19), date(2026, 10, 25))),
(None, "2026-10-27", (date(2026, 10, 21), date(2026, 10, 27))),
("2026-10-01", "2026-10-27", (date(2026, 10, 1), date(2026, 10, 27))),
],
)
def test_explicit_window_overrides_the_default(
module: ModuleType,
start_value: str | None,
end_value: str | None,
expected: tuple[date, date],
) -> None:
assert module.resolve_window(start_value, end_value) == expected
def test_window_rejects_reversed_or_oversized_ranges(module: ModuleType) -> None:
with pytest.raises(ValueError, match="before start"):
module.resolve_window("2026-10-27", "2026-10-01")
with pytest.raises(ValueError, match="supported maximum"):
module.resolve_window("2026-01-01", "2026-12-31")
with pytest.raises(ValueError, match="YYYY-MM-DD"):
module.resolve_window("2026/10/01", None)
def test_items_aggregate_every_sku_and_rank_by_quantity(module: ModuleType) -> None:
items = module.aggregate_items(ROWS)
assert [item.item_id for item in items] == ["A", "B"]
assert items[0].quantity == 963
assert items[0].sku_count == 2
assert items[0].title == "物资【电源包】"
assert items[1].quantity == 324
assert items[1].sku_count == 2
assert items[1].title == "GYXX-物资【耳机包】"
assert module._quantity_text(items[0].quantity) == "963"
def test_item_without_identifier_falls_back_to_sku(module: ModuleType) -> None:
items = module.aggregate_items(
[{"title": "物资【无 ID】", "skuCode": "c9", "quantity": 3}]
)
assert items[0].item_id == "sku:c9"
assert items[0].sku_count == 1
def test_non_numeric_quantity_is_rejected(module: ModuleType) -> None:
with pytest.raises(ValueError, match="invalid quantity"):
module.aggregate_items([{"itemId": "A", "quantity": "abc"}])
def test_card_contains_ranked_rows_and_window_summary(module: ModuleType) -> None:
items = module.aggregate_items(ROWS)
card = module._card(
items,
start=date(2026, 10, 19),
end=date(2026, 10, 25),
total_quantity=sum((item.quantity for item in items), Decimal("0")),
item_count=len(items),
page=1,
pages=1,
)
assert card["header"]["title"]["content"] == "私域积分兑换周报"
assert card["header"]["template"] == "blue"
summary = card["body"]["elements"][0]["content"]
assert "2026-10-19 ~ 2026-10-25" in summary
assert "**兑换商品**:2 个" in summary
assert "**兑换总量**:1287" in summary
table = card["body"]["elements"][1]
assert [column["display_name"] for column in table["columns"]] == [
"排名",
"商品",
"SKU 数",
"兑换数量",
]
assert table["rows"][0] == {
"rank": "1",
"title": "物资【电源包】",
"sku_count": "2",
"quantity": "963",
}
assert table["rows"][1]["rank"] == "2"
# Feishu rejects the whole card (ErrCode 200912) when any table column is
# narrower than 80px, so keep every fixed-width column at or above it.
for column in table["columns"]:
width = str(column["width"])
if width.endswith("px"):
assert int(width.removesuffix("px")) >= 80, (
f"Feishu table column {column['name']} is narrower than 80px"
)
def test_run_sends_one_card_to_every_stock_alert_recipient(
module: ModuleType, monkeypatch: pytest.MonkeyPatch
) -> None:
sent: list[dict[str, object]] = []
queries: list[dict[str, str]] = []
monkeypatch.setattr(
module,
"query_points_exchange",
lambda **kwargs: queries.append(kwargs) or ROWS,
)
monkeypatch.setattr(
module,
"resolve_notification_route",
lambda workflow_id, defaults: SimpleNamespace(
enabled=True,
open_ids=("ou_aaaaaaaa", "ou_bbbbbbbb"),
app_profile="hermes-analyzer",
),
)
monkeypatch.setattr(
module,
"send_lark_bot_message",
lambda **kwargs: sent.append(kwargs) or {"message_id": "om_1"},
)
assert module.run(["--from", "2026-10-19", "--to", "2026-10-25"]) == 0
assert queries == [
{"start_time": "2026-10-19 00:00:00", "end_time": "2026-10-25 23:59:59"}
]
assert [call["user_id"] for call in sent] == ["ou_aaaaaaaa", "ou_bbbbbbbb"]
assert all(call["msg_type"] == "interactive" for call in sent)
idempotency_keys = {call["idempotency_key"] for call in sent}
assert len(idempotency_keys) == 1
key = idempotency_keys.pop()
assert key.startswith("product.points_exchange.weekly:2026-10-19~2026-10-25:1:")
card = sent[0]["content"]
assert card["header"]["title"]["content"] == "私域积分兑换周报"
assert len(card["body"]["elements"][1]["rows"]) == 2
def test_dry_run_skips_feishu_delivery(
module: ModuleType, monkeypatch: pytest.MonkeyPatch
) -> None:
sent: list[dict[str, object]] = []
monkeypatch.setattr(module, "query_points_exchange", lambda **kwargs: ROWS)
monkeypatch.setattr(
module,
"send_lark_bot_message",
lambda **kwargs: sent.append(kwargs) or {"message_id": "om_1"},
)
assert module.run(["--from", "2026-10-19", "--dry-run"]) == 0
assert sent == []
def test_empty_window_does_not_notify(
module: ModuleType, monkeypatch: pytest.MonkeyPatch
) -> None:
sent: list[dict[str, object]] = []
monkeypatch.setattr(module, "query_points_exchange", lambda **kwargs: [])
monkeypatch.setattr(
module,
"send_lark_bot_message",
lambda **kwargs: sent.append(kwargs) or {"message_id": "om_1"},
)
assert module.run(["--from", "2026-10-19"]) == 0
assert sent == []
def test_missing_recipients_is_a_hard_failure(
module: ModuleType, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(module, "query_points_exchange", lambda **kwargs: ROWS)
monkeypatch.setattr(
module,
"resolve_notification_route",
lambda workflow_id, defaults: SimpleNamespace(
enabled=True, open_ids=(), app_profile="hermes-analyzer"
),
)
with pytest.raises(RuntimeError, match="没有配置接收人"):
module.run(["--from", "2026-10-19"])
@@ -0,0 +1,384 @@
"""同系列 ERP 编码归集到锚点 + 销量表地址不再挡采集。"""
from __future__ import annotations
from gyxx_flow.modules.product_commerce.config.style_config_loader import (
StyleConfigLoader,
)
SALES_URL = "https://example.feishu.cn/base/sales?table=tblSales"
def _series_rows() -> list[dict]:
"""宙斯系列:锚点只做占位(ERP 空),编码散落在两个子款上。"""
return [
{
"platform": "天猫",
"style_name": "宙斯",
"erp_codes": [],
"item_ids": ["TM-ZS"],
"sales_bitable_url": SALES_URL,
"series_name": "宙斯",
"is_series_anchor": True,
},
{
"platform": "天猫",
"style_name": "宙斯-10244",
"erp_codes": ["10244"],
"item_ids": [],
"series_name": "宙斯",
},
{
"platform": "天猫",
"style_name": "宙斯3",
"erp_codes": ["10484"],
"item_ids": [],
"series_name": "宙斯",
},
]
def _loader(flags: dict[str, bool] | None = None) -> StyleConfigLoader:
def rows() -> list[dict]:
data = _series_rows()
if flags:
for row in data:
row["collect_enabled"] = flags.get(row["style_name"], True)
return data
return StyleConfigLoader(records_provider=rows)
def test_anchor_takes_union_of_series_erp_codes() -> None:
styles = _loader().load()["styles"]
assert styles["宙斯"]["erp_codes"] == ["10244", "10484"]
assert styles["宙斯"]["series"]["erp_owner"] == "宙斯"
def test_child_keeps_own_codes_for_lookup() -> None:
styles = _loader().load()["styles"]
assert styles["宙斯3"]["erp_codes"] == ["10484"]
assert styles["宙斯3"]["series"]["own_erp_codes"] == ["10484"]
assert styles["宙斯3"]["series"]["erp_owner"] == "宙斯"
def test_anchor_is_the_only_erp_collection_unit() -> None:
loader = _loader()
erp_daily = loader.get_erp_daily_styles()
assert erp_daily["collectable"] == ["宙斯"]
assert {"style": "宙斯-10244", "reason": "ERP 已由同系列「宙斯」归集采集"} in erp_daily["skipped"]
assert loader.get_daily_styles()["tm"]["collectable"] == ["宙斯"]
def test_anchor_disabled_closes_whole_series_erp() -> None:
# 系列级开关语义:开关以锚点为准,锚点关掉后子款不会因为自己还有编码
# 就绕过系列开关自行采集(旧的白名单语义下子款会自采)。
loader = _loader({"宙斯": False})
erp_daily = loader.get_erp_daily_styles()
assert erp_daily["collectable"] == []
for name in ("宙斯", "宙斯-10244", "宙斯3"):
assert {"style": name, "reason": "已标记不采集"} in erp_daily["skipped"]
def test_disabled_child_erp_codes_are_excluded_from_anchor() -> None:
"""锚点为 true 时,自身 collect_enabled=false 的子款不并入 ERP 聚合。"""
styles = _loader({"宙斯3": False}).load()["styles"]
assert styles["宙斯"]["erp_codes"] == ["10244"]
assert styles["宙斯"]["series"]["erp_excluded"] == ["宙斯3"]
# 子款保留自己的原始编码,只是不再参与聚合
assert styles["宙斯3"]["series"]["own_erp_codes"] == ["10484"]
def test_enabled_child_keeps_contributing_erp_codes() -> None:
styles = _loader({"宙斯-10244": False}).load()["styles"]
assert styles["宙斯"]["erp_codes"] == ["10484"]
assert styles["宙斯"]["series"]["erp_excluded"] == ["宙斯-10244"]
def test_anchor_without_any_enabled_child_code_stops_erp_collection() -> None:
"""子款全部关闭时锚点没有可采编码,整条系列不采 ERP。"""
erp_daily = _loader({"宙斯-10244": False, "宙斯3": False}).get_erp_daily_styles()
assert erp_daily["collectable"] == []
assert {"style": "宙斯", "reason": "缺 ERP款式编码"} in erp_daily["skipped"]
def test_injected_provider_never_writes_production_cache(tmp_path) -> None:
"""注入假数据的 loader 不能写生产快照,否则数据库抖动时会按假款式采集。"""
cache = tmp_path / ".style_config_cache.json"
loader = StyleConfigLoader(records_provider=_series_rows, cache_path=cache)
loader.load()
assert loader.cache_enabled is False
assert not cache.exists()
def test_default_provider_enables_cache(tmp_path) -> None:
loader = StyleConfigLoader(cache_path=tmp_path / "cache.json")
assert loader.cache_enabled is True
def test_single_style_series_has_no_erp_owner() -> None:
loader = StyleConfigLoader(
records_provider=lambda: [
{
"platform": "天猫",
"style_name": "独苗",
"erp_codes": ["10500"],
"item_ids": ["TM-1"],
"series_name": "独苗",
"is_series_anchor": True,
}
]
)
assert "erp_owner" not in loader.load()["styles"]["独苗"]["series"]
def test_missing_sales_bitable_still_collects_but_skips_table_write() -> None:
"""简行三款的场景:平台编码与 ERP 都齐,只缺销量飞书表。"""
loader = StyleConfigLoader(
records_provider=lambda: [
{
"platform": "天猫",
"style_name": "简行手提",
"erp_codes": ["10523"],
"item_ids": ["TM-1"],
}
]
)
assert loader.get_erp_daily_styles()["collectable"] == ["简行手提"]
assert loader.get_daily_styles()["tm"]["collectable"] == ["简行手提"]
# 写表配置仍然跳过该款:没有销量表地址就不写飞书
assert loader.get_bitable_style_map() == {}
def test_child_platform_ids_are_merged_into_anchor() -> None:
"""逐星GT 的场景:子款自己填的商品 ID 并入锚点,整系列由一个单元采集。"""
loader = StyleConfigLoader(
records_provider=lambda: [
{
"platform": "天猫",
"style_name": "逐星",
"erp_codes": [],
"item_ids": ["TM-A", "TM-B"],
"series_name": "逐星",
"is_series_anchor": True,
},
{
"platform": "天猫",
"style_name": "逐星GT",
"erp_codes": ["10494"],
"item_ids": ["TM-GT"],
"series_name": "逐星",
},
]
)
styles = loader.load()["styles"]
erp_daily = loader.get_erp_daily_styles()
daily = loader.get_daily_styles()
# 子款编码聚合去重后挂锚点,顺序为「锚点原有 → 子款」
assert styles["逐星"]["tm"]["item_ids"] == ["TM-A", "TM-B", "TM-GT"]
# 子款保留原始编码供诊断,但不再单独采集
assert styles["逐星GT"]["series"]["platform_ids"]["tm"] == {
"逐星": ["TM-A", "TM-B", "TM-GT"],
"逐星GT": ["TM-GT"],
}
assert erp_daily["collectable"] == ["逐星"]
assert {"style": "逐星GT", "reason": "ERP 已由同系列「逐星」归集采集"} in erp_daily["skipped"]
assert daily["tm"]["collectable"] == ["逐星"]
assert {
"style": "逐星GT",
"reason": "缺 tm商品编码(同系列「逐星」已在采)",
} in daily["tm"]["skipped"]
def test_platform_ids_filled_on_child_are_lifted_to_anchor() -> None:
"""锚点平台编码为空、子款填了:聚合后由锚点承接,采集口径不变。"""
loader = StyleConfigLoader(
records_provider=lambda: [
{
"platform": "天猫",
"style_name": "某系列",
"erp_codes": ["1001"],
"item_ids": [],
"series_name": "某系列",
"is_series_anchor": True,
},
{
"platform": "天猫",
"style_name": "某系列-2",
"erp_codes": ["1002"],
"item_ids": ["TM-2"],
"series_name": "某系列",
},
]
)
styles = loader.load()["styles"]
daily = loader.get_daily_styles()
assert styles["某系列"]["tm"]["item_ids"] == ["TM-2"]
assert daily["tm"]["collectable"] == ["某系列"]
assert daily["tm"]["skipped"][0]["style"] == "某系列-2"
def test_series_addresses_aggregate_from_child_when_anchor_empty() -> None:
"""子款误填飞书地址、锚点为空时,地址上浮到锚点并下发全系列。"""
loader = StyleConfigLoader(
records_provider=lambda: [
{
"platform": "天猫",
"style_name": "地址系列",
"erp_codes": [],
"item_ids": ["TM-1"],
"series_name": "地址系列",
"is_series_anchor": True,
},
{
"platform": "天猫",
"style_name": "地址系列-2",
"erp_codes": ["1002"],
"item_ids": [],
"sales_bitable_url": SALES_URL,
"series_name": "地址系列",
},
]
)
styles = loader.load()["styles"]
assert styles["地址系列"]["sales_bitable"]["base_token"] == "sales"
assert styles["地址系列-2"]["sales_bitable"]["base_token"] == "sales"
# 地址上浮后锚点成为唯一采集单元,写表清单里只有锚点
assert set(loader.get_bitable_style_map()) == {"地址系列"}
def test_series_address_conflict_keeps_anchor_target(capsys) -> None:
"""同字段多套表:保留锚点那套并告警,避免同系列拆到两张表。"""
loader = StyleConfigLoader(
records_provider=lambda: [
{
"platform": "天猫",
"style_name": "冲突系列",
"erp_codes": ["1001"],
"item_ids": ["TM-1"],
"sales_bitable_url": SALES_URL,
"series_name": "冲突系列",
"is_series_anchor": True,
},
{
"platform": "天猫",
"style_name": "冲突系列-2",
"erp_codes": ["1002"],
"item_ids": [],
"sales_bitable_url": "https://example.feishu.cn/base/child?table=tblChild",
"series_name": "冲突系列",
},
]
)
styles = loader.load()["styles"]
assert styles["冲突系列"]["sales_bitable"]["base_token"] == "sales"
assert styles["冲突系列-2"]["sales_bitable"]["base_token"] == "sales"
assert "个不同表" in capsys.readouterr().out
def test_child_ids_covered_by_anchor_are_left_to_the_anchor() -> None:
"""锚点聚合了全系列商品 ID 时,子款不重复采同一批编码。"""
loader = StyleConfigLoader(
records_provider=lambda: [
{
"platform": "天猫",
"style_name": "聚合系列",
"erp_codes": ["2001"],
"item_ids": ["TM-1", "TM-2"],
"series_name": "聚合系列",
"is_series_anchor": True,
},
{
"platform": "天猫",
"style_name": "聚合系列-2",
"erp_codes": ["2002"],
"item_ids": ["TM-2"],
"series_name": "聚合系列",
},
]
)
daily = loader.get_daily_styles()
assert daily["tm"]["collectable"] == ["聚合系列"]
assert {
"style": "聚合系列-2",
"reason": "缺 tm商品编码(同系列「聚合系列」已在采)",
} in daily["tm"]["skipped"]
def test_anchor_owning_erp_and_all_platform_ids_owns_every_gate() -> None:
"""正常建档形态:锚点填 ERP + 全平台编码,子款只填 ERP。"""
loader = StyleConfigLoader(
records_provider=lambda: [
{
"platform": "天猫",
"style_name": "正常系列",
"erp_codes": ["3001"],
"item_ids": ["TM-1"],
"series_name": "正常系列",
"is_series_anchor": True,
},
{
"platform": "京东旗舰店",
"style_name": "正常系列",
"erp_codes": [],
"item_ids": ["JD-1"],
"series_name": "正常系列",
"is_series_anchor": True,
},
{
"platform": "天猫",
"style_name": "正常系列-2",
"erp_codes": ["3002"],
"item_ids": [],
"series_name": "正常系列",
},
]
)
styles = loader.load()["styles"]
daily = loader.get_daily_styles()
assert styles["正常系列"]["erp_codes"] == ["3001", "3002"]
assert loader.get_erp_daily_styles()["collectable"] == ["正常系列"]
assert daily["tm"]["collectable"] == ["正常系列"]
assert daily["jd"]["collectable"] == ["正常系列"]
assert {
"style": "正常系列-2",
"reason": "缺 tm商品编码(同系列「正常系列」已在采)",
} in daily["tm"]["skipped"]
@@ -62,16 +62,28 @@ def test_database_row_uses_explicit_style_name_not_style_content() -> None:
"brand": "",
"sales_bitable_url": "",
"main_image_bitable_url": "",
"creator_bitable_url": "",
"self_creator_bitable_url": "",
"weekly_note_analysis_url": "",
"persona_bitable_url": "",
"dy_persona_bitable_url": "",
"jd_persona_bitable_url": "",
"style_analysis_bitable_url": "",
"destinations": [],
}
"jd_persona_bitable_url": "",
"style_analysis_bitable_url": "",
"destinations": [],
"series_name": "",
"is_series_anchor": False,
"collect_enabled": True,
}
]
def test_erp_only_style_is_skipped_by_every_product_collection_gate() -> None:
def test_erp_only_style_collects_erp_but_skips_platform_gates() -> None:
"""只有 ERP 编码、没有平台编码的款式:ERP 销量照采,平台流量跳过。
销量表地址与平台编码都不再挡 ERP 采集——缺表地址只影响写飞书,缺平台
编码只影响平台流量。
"""
loader = StyleConfigLoader(
records_provider=lambda: [
{
@@ -89,14 +101,12 @@ def test_erp_only_style_is_skipped_by_every_product_collection_gate() -> None:
erp_daily = loader.get_erp_daily_styles()
assert daily["tm"]["collectable"] == []
assert daily["tm"]["skipped"] == [
{"style": "研发款", "reason": "缺 平台商品编码/tm商品编码"}
assert daily["tm"]["skipped"] == [{"style": "研发款", "reason": "缺 tm商品编码"}]
assert erp_daily["collectable"] == ["研发款"]
assert erp_daily["skipped"] == []
assert loader.get_erp_styles_input() == [
{"style_name": "研发款", "erp_style_codes": ["ERP-ONLY"]}
]
assert erp_daily["collectable"] == []
assert erp_daily["skipped"] == [
{"style": "研发款", "reason": "缺 平台商品编码"}
]
assert loader.get_erp_styles_input() == []
assert loader.get_main_image_styles() == []
@@ -127,3 +137,31 @@ def test_complete_identity_allows_only_configured_platforms() -> None:
assert daily["dy"]["collectable"] == []
assert loader.get_erp_daily_styles()["collectable"] == ["完整款"]
assert loader.get_main_image_styles() == ["完整款"]
def test_main_image_gate_collects_without_table_address() -> None:
"""主图采集只要求身份:缺主图飞书表地址照样采图入库,只是不写表。"""
loader = StyleConfigLoader(
records_provider=lambda: [
{
"platform": "天猫",
"style_name": "有地址款",
"erp_codes": ["ERP-1"],
"item_ids": ["TM-1"],
"main_image_bitable_url": "https://example.feishu.cn/base/image?table=tblImage",
},
{
"platform": "天猫",
"style_name": "缺地址款",
"erp_codes": ["ERP-2"],
"item_ids": ["TM-2"],
},
]
)
# 采集目标:两款都要采(身份齐全即可)
assert sorted(loader.get_main_image_styles()) == ["有地址款", "缺地址款"]
assert loader.get_tm_style_id_map() == {"有地址款": ["TM-1"], "缺地址款": ["TM-2"]}
# 写表目标:只有配了地址的那一款
assert list(loader.get_bitable_main_image_map()) == ["有地址款"]
@@ -234,6 +234,62 @@ def test_erp_style_plan_uses_style_base_codes_and_aliases() -> None:
assert missing == ["云栖相机双肩包"]
def test_erp_style_plan_uses_own_codes_not_merged_series_union() -> None:
"""月度表按款明细:不能照搬 loader 挂在锚点上的系列并集。
采集端把同系列编码聚合成并集交给锚点当唯一取数单元;报表若也用这份
并集,锚点和子款会把同一批编码各查一遍,销量重复计入。
"""
targets = [
ProductTarget("宙斯", 100),
ProductTarget("宙斯pro", 200),
ProductTarget("星迹2", 300),
ProductTarget("星迹2lite", 400),
]
series = {"name": "宙斯", "erp_owner": "宙斯"}
styles = {
# 锚点只做系列占位、自身没填编码:loader 聚合后 erp_codes 是系列并集
"宙斯": {
"erp_codes": ["10244", "10276", "10387"],
"series": {**series, "own_erp_codes": []},
},
"宙斯pro": {
"erp_codes": ["10276"],
"series": {**series, "own_erp_codes": ["10276"]},
},
# 正常建档形态:锚点一条编码,子款各一条
"星迹2": {
"erp_codes": ["10394", "10504"],
"series": {
"name": "星迹2",
"erp_owner": "星迹2",
"own_erp_codes": ["10394"],
},
},
"星迹2lite": {
"erp_codes": ["10504"],
"series": {
"name": "星迹2",
"erp_owner": "星迹2",
"own_erp_codes": ["10504"],
},
},
}
plans, missing = plan_erp_styles(targets, styles, {})
assert plans == [
ErpStylePlan("宙斯pro", ("10276",)),
ErpStylePlan("星迹2", ("10394",)),
ErpStylePlan("星迹2lite", ("10504",)),
]
# 锚点自身没编码:按 0 写入(--allow-missing-erp-as-zero),不算配置缺失
assert missing == ["宙斯"]
planned = [code for plan in plans for code in plan.erp_style_codes]
assert len(planned) == len(set(planned))
def test_execute_guard_rejects_incomplete_erp_mapping_before_sheet_write() -> None:
with pytest.raises(RuntimeError, match="目标表未清空"):
require_complete_erp_mappings(["云栖相机双肩包", "轻风双肩包"])