feat(bot-bridge): 新增会计科目写入端点 POST /api/cma/bot/subjects — 行级全量校验+按subject_code幂等UPSERT(dry_run/update_existing开关)+L3留痕, 绝不DELETE (dama-cma-subjects-write-endpoint-20260912)

This commit is contained in:
Hermes CI Fix
2026-09-12 10:42:46 +08:00
parent af541e9043
commit 840d98765d
4 changed files with 400 additions and 6 deletions
+253 -1
View File
@@ -14,7 +14,7 @@ from app.models import (
User, UserEntity, StrategicMap, KPIDefinition, KPITemplate, KPIValue,
DataSourceConfig, KPIAlert, OperationLog, NotificationChannel,
NotificationLog, RolePermission, ActionPlan, OrgNode,
StrategicMapVersion, MapObjective, Objective, KR,
StrategicMapVersion, MapObjective, Objective, KR, Subject,
)
from app.models.budget_plan import BudgetPlan
from app.models.cost_model import StandardCost, ActualCost, AbcActivity, AbcAllocation
@@ -973,6 +973,258 @@ def bot_dict_zxt_account(
"table": "zxt_account_dict", "source": source}
# ═══════════════ 会计科目写入(subjects 幂等 UPSERT)═══════════════
# 2026-09-12 新增(dama-cma-subjects-write-endpoint-20260912):补齐 bot_bridge 科目表写入能力,
# 使 DAMA 管道 gen_inject_sql.py 的「科目新增」DML 走 API(原「财务侧 cma_user 直连写库」路径
# 已随 2026-09-12 库访问治理失效:ERROR 1142 INSERT command denied)。
# 语义铁律:按 subject_code 幂等 UPSERT,**绝不 DELETE / 绝不清空重建**
# (v1 ops 直连版为「清空重建」,已记录为语义差异,禁止继承)。
# 分级 L3(批量写);留痕走 audit middle(logs/bot_audit.log)+ operation_logs。
# 表设计说明:subjects = 新30号准则会计科目字典,全局共享、无 entity_id 列
# (见 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")
_SUBJ_LEVELS = (1, 2, 3)
_SUBJ_MAX_ROWS = 2000
_SUBJ_CODE_MAXLEN = 32
_SUBJ_NAME_MAXLEN = 200
def _norm_subj_active(v):
"""is_active → (0/1, None) 或 (None, 错误信息)"""
if isinstance(v, bool):
return int(v), None
if isinstance(v, int):
return (v, None) if v in (0, 1) else (None, f"is_active 只允许 0/1(实际 {v})")
if isinstance(v, str) and v.strip() in ("0", "1"):
return int(v.strip()), None
return None, f"is_active 只允许 0/1(实际 {v!r})"
def _validate_subject_rows(rows, db):
"""行级全量校验(任一错误 → 400 且零落库),返回标准化行 [{字段}]
规则(任务 dama-cma-subjects-write-endpoint-20260912):
subject_code 非空且 ≤32 字符 + 批次内去重;subject_name 非空(DB NOT NULL)且 ≤200;
level ∈ {1,2,3}(缺省 1);parent_code 为空或存在于本表(或本批次内,支持层级同批导入);
category 限枚举;is_active ∈ {0,1}(缺省 1)。
"""
if not isinstance(rows, list) or not rows:
raise HTTPException(400, '请求体必须是非空行数组: {"subjects": [{...}]}')
if len(rows) > _SUBJ_MAX_ROWS:
raise HTTPException(400, f"单次批量上限 {_SUBJ_MAX_ROWS} 行,本次 {len(rows)} 行")
errs, clean, seen, parents = [], [], {}, set()
for i, raw in enumerate(rows, start=1):
if not isinstance(raw, dict):
errs.append(f"第{i}行: 行必须是对象(dict)")
continue
unknown = [k for k in raw.keys() if k not in _SUBJ_FIELDS]
if unknown:
errs.append(f"第{i}行: 非白名单字段 {unknown},允许字段 {list(_SUBJ_FIELDS)}")
continue
item, bad = {}, False
# subject_code
code = raw.get("subject_code")
code = "" if code is None else str(code).strip()
if not code:
errs.append(f"第{i}行: subject_code 必填且不能为空")
bad = True
elif len(code) > _SUBJ_CODE_MAXLEN:
errs.append(f"第{i}行: subject_code 超长(>{_SUBJ_CODE_MAXLEN} 字符):{code}")
bad = True
elif code in seen:
errs.append(f"第{i}行: 批次内 subject_code 重复(第{seen[code]}行已出现):{code}")
bad = True
else:
seen[code] = i
item["subject_code"] = code
# subject_name
name = raw.get("subject_name")
name = "" if name is None else str(name).strip()
if not name:
errs.append(f"第{i}行: subject_name 必填且不能为空")
bad = True
elif len(name) > _SUBJ_NAME_MAXLEN:
errs.append(f"第{i}行: subject_name 超长(>{_SUBJ_NAME_MAXLEN} 字符)")
bad = True
item["subject_name"] = name
# level
lvl = raw.get("level", 1)
if lvl is None:
lvl = 1
if isinstance(lvl, str) and lvl.strip().lstrip("-").isdigit():
lvl = int(lvl.strip())
if isinstance(lvl, bool) or not isinstance(lvl, int):
errs.append(f"第{i}行: level 必须为整数 {list(_SUBJ_LEVELS)}(实际 {raw.get('level')!r})")
bad = True
elif lvl not in _SUBJ_LEVELS:
errs.append(f"第{i}行: level 只允许 {list(_SUBJ_LEVELS)}(实际 {lvl})")
bad = True
item["level"] = lvl if isinstance(lvl, int) and not isinstance(lvl, bool) else 1
# parent_code
pc = raw.get("parent_code")
pc = None if pc is None or str(pc).strip() == "" else str(pc).strip()
if pc is not None:
if len(pc) > _SUBJ_CODE_MAXLEN:
errs.append(f"第{i}行: parent_code 超长(>{_SUBJ_CODE_MAXLEN} 字符):{pc}")
bad = True
elif pc == code:
errs.append(f"第{i}行: parent_code 不能等于自身 subject_code:{pc}")
bad = True
else:
parents.add(pc)
item["parent_code"] = pc
# category
cat = raw.get("category")
cat = None if cat is None or str(cat).strip() == "" else str(cat).strip()
if cat is not None and cat not in _SUBJ_CATEGORIES:
errs.append(f"第{i}行: category 只允许 {list(_SUBJ_CATEGORIES)}(实际 {cat})")
bad = True
item["category"] = cat
# is_active(null/缺省 → 1)
act_raw = raw.get("is_active")
act, aerr = _norm_subj_active(1 if act_raw is None else act_raw)
if aerr:
errs.append(f"第{i}行: {aerr}")
bad = True
item["is_active"] = act if act is not None else 1
if not bad:
clean.append(item)
# parent_code 存在性:库内(本表)∪ 本批次
unknown_parents = sorted(parents - set(seen.keys()))
if unknown_parents:
found = {c for (c,) in db.query(Subject.subject_code)
.filter(Subject.subject_code.in_(unknown_parents)).all()}
missing = [p for p in unknown_parents if p not in found]
if missing:
errs.append(f"parent_code 不存在于 subjects 表且不在本批次内: {missing}")
if errs:
raise HTTPException(400, detail={
"message": f"参数校验失败({len(errs)} 处),未写入任何数据",
"errors": errs[:20],
})
return clean
async def _read_subjects_body(request: Request):
"""解析请求体: {"subjects": [...], "dry_run": bool, "update_existing": bool}
兼容裸数组 [...](等价于 subjects 列表)。"""
try:
raw = await request.body()
data = json.loads(raw) if raw else None
except Exception as e:
raise HTTPException(400, f"JSON 解析失败: {e}")
dry_run, update_existing = False, False
if isinstance(data, dict):
if "dry_run" in data and not isinstance(data["dry_run"], bool):
raise HTTPException(400, "dry_run 必须是布尔值")
if "update_existing" in data and not isinstance(data["update_existing"], bool):
raise HTTPException(400, "update_existing 必须是布尔值")
dry_run = bool(data.get("dry_run"))
update_existing = bool(data.get("update_existing"))
rows = data.get("subjects")
else:
rows = data
if not isinstance(rows, list):
raise HTTPException(400, 'JSON 必须是 {"subjects": [...]} 或裸数组 [...]')
return {"rows": rows, "dry_run": dry_run, "update_existing": update_existing}
@router.post("/subjects")
@risk_level("L3")
def bot_subjects_upsert(
bot: dict = Depends(verify_bot_key),
payload: dict = Depends(_read_subjects_body),
db: Session = Depends(get_db),
):
"""会计科目写入(subjects 幂等 UPSERT,单事务;绝不 DELETE)
body: {"subjects": [{"subject_code","subject_name","parent_code","level","category","is_active"}],
"dry_run": false, "update_existing": false}
- 全量校验通过才落库;任一非法行 → 400 且库内零变化
- 已存在行:update_existing=false → skip(幂等默认);true → 更新 name/parent/level/category
- dry_run=true → 只校验与回显,零写入
返回: {"ok":true,"inserted":n,"updated":m,"skipped":k,"errors":[]};未授权 401。
"""
clean = _validate_subject_rows(payload.get("rows"), db)
dry_run = payload.get("dry_run", False)
update_existing = payload.get("update_existing", False)
codes = [r["subject_code"] for r in clean]
existing = set()
if codes:
existing = {c for (c,) in db.query(Subject.subject_code)
.filter(Subject.subject_code.in_(codes)).all()}
to_insert = [r for r in clean if r["subject_code"] not in existing]
existed = [r for r in clean if r["subject_code"] in existing]
to_update = existed if update_existing else []
to_skip = [] if update_existing else existed
before = db.query(func.count(Subject.id)).scalar() or 0
if dry_run:
logger.info(f"subjects UPSERT dry_run: bot={bot.get('name')} rows={len(clean)} "
f"would_insert={len(to_insert)} would_update={len(to_update)} "
f"would_skip={len(to_skip)}")
return {"ok": True, "dry_run": True, "applied": False, "table": "subjects",
"before": before, "after": before,
"inserted": 0, "updated": 0, "skipped": 0, "upserted": 0,
"would_insert": len(to_insert), "would_update": len(to_update),
"would_skip": len(to_skip), "validated": len(clean),
"errors": [], "update_existing": update_existing}
try:
for r in to_insert:
db.add(Subject(subject_code=r["subject_code"], subject_name=r["subject_name"],
parent_code=r["parent_code"], level=r["level"],
category=r["category"], is_active=r["is_active"]))
for r in to_update:
db.query(Subject).filter(Subject.subject_code == r["subject_code"]).update({
Subject.subject_name: r["subject_name"],
Subject.parent_code: r["parent_code"],
Subject.level: r["level"],
Subject.category: r["category"],
}, synchronize_session=False)
db.flush()
after = db.query(func.count(Subject.id)).scalar() or 0
db.add(OperationLog(
action="import", target_type="subjects", target_id=None,
detail={"bot": bot.get("name"), "endpoint": "/api/cma/bot/subjects",
"risk_level": "L3", "inserted": len(to_insert), "updated": len(to_update),
"skipped": len(to_skip), "before": before, "after": after,
"update_existing": update_existing, "source": "json"},
))
db.commit()
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"subjects upsert 失败: {e}")
raise HTTPException(500, f"科目写入失败: {e}")
if after != before + len(to_insert):
logger.warning(f"subjects UPSERT 行数口径差异: before={before} after={after} "
f"inserted={len(to_insert)}")
logger.info(f"subjects UPSERT: bot={bot.get('name')} inserted={len(to_insert)} "
f"updated={len(to_update)} skipped={len(to_skip)} before={before} after={after}")
return {"ok": True, "dry_run": False, "applied": True, "table": "subjects",
"before": before, "after": after,
"inserted": after - before, "updated": len(to_update), "skipped": len(to_skip),
"upserted": (after - before) + len(to_update), "errors": [],
"update_existing": update_existing}
@router.get("/risk-levels")
@risk_level("L1")
def bot_risk_levels(bot: dict = Depends(verify_bot_key)):