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:
@@ -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)):
|
||||
|
||||
Reference in New Issue
Block a user