From 46fc1a4eccce1ce025658451114cf2b6a7e11044 Mon Sep 17 00:00:00 2001 From: Hermes CI Fix Date: Sat, 12 Sep 2026 11:09:16 +0800 Subject: [PATCH] =?UTF-8?q?fix(bot-bridge):=20subjects=20category=20?= =?UTF-8?q?=E7=99=BD=E5=90=8D=E5=8D=95=E8=A1=A5=E9=BD=90=E5=BA=93=E5=86=85?= =?UTF-8?q?=E5=AE=9E=E5=86=B5=20revenue/profit=5Floss=20=E2=80=94=20?= =?UTF-8?q?=E6=B6=88=E9=99=A4=E5=BA=93=E5=86=85=E5=90=88=E6=B3=95=E5=80=BC?= =?UTF-8?q?=E8=A2=AB400=E6=8B=92=20(dama-cma-subjects-category-whitelist-2?= =?UTF-8?q?0260912)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/bot_bridge.py | 15 ++++++- backend/tests/test_bot_bridge.py | 76 ++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/backend/app/api/bot_bridge.py b/backend/app/api/bot_bridge.py index 8ef38c3a..36e5b015 100644 --- a/backend/app/api/bot_bridge.py +++ b/backend/app/api/bot_bridge.py @@ -984,7 +984,20 @@ def bot_dict_zxt_account( # (见 cma-fullstack-ops「账套隔离」豁免说明),故本端点不做 entity 归属校验。 _SUBJ_FIELDS = ("subject_code", "subject_name", "parent_code", "level", "category", "is_active") -_SUBJ_CATEGORIES = ("asset", "liability", "equity", "cost", "income", "expense", "detail", "other") +# category 白名单:以新30号准则科目字典的**库内实况**为准,不臆造枚举。 +# 2026-09-12 修复(dama-cma-subjects-category-whitelist-20260912): +# 原白名单 8 值漏了库内真实存在的 revenue(5 行: 6001/6011/6051/6111/611101) 与 +# profit_loss(1 行: 6901),导致「对库内既有科目的任何合法更新」「按库内既有约定回填的 +# 批次导入」整批 400 —— DAMA 独立复核实证(3 行收入类科目被阻断)。 +# 证据:SELECT DISTINCT category FROM subjects → 9 值(2026-09-12 只读核验,207 行): +# asset/cost/detail/equity/expense/income/liability/profit_loss/revenue。 +# 取值策略(采用任务方案 A「补齐白名单」,DAMA 反对 B 静默归一化——同一字典内同一概念 +# 出现两种取值应由 DAMA 出标准化方案后统一改值,端点不得替用户改写数据): +# 9 个库内真实值 + 'other'(既有契约的写入口袋值,缩小白名单会引入回归,故保留)。 +# 防漂移:新增取值由 tests/test_bot_bridge.py::TestSubjectsWrite 的语料断言测试 + +# scripts/check_subjects_category_drift.py(生产库 SELECT DISTINCT ⊄ 白名单 → 失败)兜底。 +_SUBJ_CATEGORIES = ("asset", "liability", "equity", "cost", "income", "expense", "detail", "other", + "revenue", "profit_loss") _SUBJ_LEVELS = (1, 2, 3) _SUBJ_MAX_ROWS = 2000 # subject_code / parent_code 长度上限:任务规格为 ≤32,但 DB 列 subjects.subject_code / diff --git a/backend/tests/test_bot_bridge.py b/backend/tests/test_bot_bridge.py index bfbc560e..bf51890d 100644 --- a/backend/tests/test_bot_bridge.py +++ b/backend/tests/test_bot_bridge.py @@ -573,3 +573,79 @@ class TestSubjectsWrite: }, headers=BOT_KEY) assert resp.status_code == 200, resp.json() assert resp.json()["inserted"] == 2 + + # ── category 白名单 vs 字典实况(2026-09-12 dama-cma-subjects-category-whitelist)── + # 生产库 SELECT DISTINCT category FROM subjects(207 行,2026-09-12 只读核验): + DB_CATEGORY_CORPUS_20260912 = ("asset", "cost", "detail", "equity", "expense", + "income", "liability", "profit_loss", "revenue") + + def test_whitelist_covers_dictionary_corpus(self): + """防漂移:库内真实取值若被白名单漏掉 → 本测试失败 + + 原缺陷(DAMA 复核):白名单 8 值漏 revenue / profit_loss,库内合法值被 400 拒。 + """ + from app.api.bot_bridge import _SUBJ_CATEGORIES + missing = sorted(set(self.DB_CATEGORY_CORPUS_20260912) - set(_SUBJ_CATEGORIES)) + assert not missing, f"库内真实取值被端点白名单漏掉: {missing}" + + @pytest.mark.parametrize("cat", DB_CATEGORY_CORPUS_20260912) + def test_dictionary_category_accepted(self, client: TestClient, db: Session, cat: str): + """字典实况每个取值都能通过端点校验并落库(不再出现「库内合法、端点 400」)""" + code = "95" + f"{self.DB_CATEGORY_CORPUS_20260912.index(cat) + 1:02d}" + resp = client.post(self.URL, json={"subjects": [self._row(code, f"验证-{cat}", category=cat)]}, + headers=BOT_KEY) + assert resp.status_code == 200, resp.json() + assert resp.json()["inserted"] == 1 + db.expire_all() + row = db.query(Subject).filter(Subject.subject_code == code).first() + assert row is not None and row.category == cat + + def test_revenue_existing_row_update_existing_true(self, client: TestClient, db: Session): + """原 400 复现用例:库内既有 revenue 行(6001 主营业务收入)+ update_existing=true""" + db.add(Subject(subject_code="6001", subject_name="主营业务收入", level=1, + category="revenue", is_active=1)) + other = Subject(subject_code="6051", subject_name="其他业务收入", level=1, + category="revenue", is_active=1) + db.add(other) + db.commit() + before = db.query(Subject).count() + resp = client.post(self.URL, json={ + "subjects": [self._row("6001", "主营业务收入(新30号)", category="revenue", level=1)], + "update_existing": True}, headers=BOT_KEY) + assert resp.status_code == 200, resp.json() + d = resp.json() + assert (d["inserted"], d["updated"], d["skipped"]) == (0, 1, 0), d + db.expire_all() + row = db.query(Subject).filter(Subject.subject_code == "6001").first() + assert row.subject_name == "主营业务收入(新30号)" and row.category == "revenue" + # 仅目标行变化:行数不变,邻行逐字段未动 + assert db.query(Subject).count() == before + nb = db.query(Subject).filter(Subject.subject_code == "6051").first() + assert (nb.subject_name, nb.category, nb.level) == ("其他业务收入", "revenue", 1) + + def test_profit_loss_existing_row_update_existing_true(self, client: TestClient, db: Session): + db.add(Subject(subject_code="6901", subject_name="终止经营损益", level=1, + category="profit_loss", is_active=1)) + db.commit() + resp = client.post(self.URL, json={ + "subjects": [self._row("6901", "终止经营损益(V2)", category="profit_loss")], + "update_existing": True}, headers=BOT_KEY) + assert resp.status_code == 200, resp.json() + assert resp.json()["updated"] == 1 + db.expire_all() + row = db.query(Subject).filter(Subject.subject_code == "6901").first() + assert row.subject_name == "终止经营损益(V2)" and row.category == "profit_loss" + + def test_revenue_existing_row_update_existing_false_skips(self, client: TestClient, db: Session): + """update_existing=false:revenue 行正常 skip,逐字段未被改写""" + db.add(Subject(subject_code="6011", subject_name="利息收入(银行存款)", level=1, + category="revenue", is_active=1)) + db.commit() + resp = client.post(self.URL, json={ + "subjects": [self._row("6011", "不应生效的名称", category="income")]}, headers=BOT_KEY) + assert resp.status_code == 200, resp.json() + d = resp.json() + assert (d["inserted"], d["updated"], d["skipped"]) == (0, 0, 1), d + db.expire_all() + row = db.query(Subject).filter(Subject.subject_code == "6011").first() + assert (row.subject_name, row.category) == ("利息收入(银行存款)", "revenue")