feat(entity-dim): 账套维度收口批 — subjects/analysis_results 加列 + 分析结论账套化 + 模板实例化构造点补账套
ci/woodpecker/push/main Pipeline failed
ci/woodpecker/push/main Pipeline failed
- ① kpi_templates instantiate 构造点显式传会话账套(原缺 entity_id ⇒ 静默落 e1),kpi_code 判重按账套收紧
- ② kpi_subject_map 3 个读取点加护栏注释(不加列、不改语义)
- ③ subjects 分层:读口 union(NULL=全局标准准则科目 / N=账套私有),UPSERT 唯一键改 (entity_id, subject_code)
- ④ analysis_results 账套化:读/写/删三端点按会话账套判定归属(列未落地时 kpi_code JOIN 止血),
无归属行进 unattributed 登记、删除一律 404
- 模型层按现网 DDL 补齐:KPIDefinition(template_id/is_system + uk_entity_kpi)、AnalysisResult.entity_id、Subject.entity_id
- 新增 migrate_{subjects,analysis_results}_entity_id_20260923.py(--check/--apply/--down)、
22 条对账用例 test_entity_dimension_convergence_20260923.py
DDL 已由 ops 受控通道落地(subjects 21:31:56 / analysis_results 21:31:59 CST,审批 1e9b4d9a/5ffb1640)
本提交为切片提交,未 push(发布须另取放行)
This commit is contained in:
@@ -1036,13 +1036,16 @@ def _norm_subj_active(v):
|
||||
return None, f"is_active 只允许 0/1(实际 {v!r})"
|
||||
|
||||
|
||||
def _validate_subject_rows(rows, db):
|
||||
def _validate_subject_rows(rows, db, entity_id: int = None):
|
||||
"""行级全量校验(任一错误 → 400 且零落库),返回标准化行 [{字段}]
|
||||
|
||||
规则(任务 dama-cma-subjects-write-endpoint-20260912):
|
||||
subject_code 非空且 ≤_SUBJ_CODE_MAXLEN(=20,DB varchar(20) 列宽)+ 批次内去重;subject_name 非空(DB NOT NULL)且 ≤200;
|
||||
level ∈ {1,2,3}(缺省 1);parent_code 为空或存在于本表(或本批次内,支持层级同批导入);
|
||||
category 限枚举;is_active ∈ {0,1}(缺省 1)。
|
||||
|
||||
账套维度(2026-09-23 `subjects` 分层):parent_code 存在性只认
|
||||
**全局科目(`entity_id IS NULL`)∪ 本会话账套私有科目**——他账套私有父科目不构成合法父级。
|
||||
"""
|
||||
if not isinstance(rows, list) or not rows:
|
||||
raise HTTPException(400, '请求体必须是非空行数组: {"subjects": [{...}]}')
|
||||
@@ -1134,11 +1137,13 @@ def _validate_subject_rows(rows, db):
|
||||
if not bad:
|
||||
clean.append(item)
|
||||
|
||||
# parent_code 存在性:库内(本表)∪ 本批次
|
||||
# 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()}
|
||||
pq = db.query(Subject.subject_code).filter(Subject.subject_code.in_(unknown_parents))
|
||||
if entity_id is not None:
|
||||
pq = pq.filter((Subject.entity_id.is_(None)) | (Subject.entity_id == entity_id))
|
||||
found = {c for (c,) in pq.all()}
|
||||
missing = [p for p in unknown_parents if p not in found]
|
||||
if missing:
|
||||
errs.append(f"parent_code 不存在于 subjects 表且不在本批次内: {missing}")
|
||||
@@ -1181,50 +1186,76 @@ def bot_subjects_upsert(
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
payload: dict = Depends(_read_subjects_body),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""会计科目写入(subjects 幂等 UPSERT,单事务;绝不 DELETE)
|
||||
|
||||
body: {"subjects": [{"subject_code","subject_name","parent_code","level","category","is_active"}],
|
||||
"dry_run": false, "update_existing": false}
|
||||
账套(`entity_id`)来源:会话账套(`get_entity_id`:token → query/header `X-Entity-Id`/`entity_id` →
|
||||
默认 1);**不接受 body 内的 entity_id**(Bot 通道按 header/query 显式传,防越权落错账套)。
|
||||
- 唯一键口径(2026-09-23 `subjects` 分层):`(entity_id, subject_code)`——同账套内同码才视为「已存在」;
|
||||
不同账套可各有同码科目(如两账套都有 `1001 库存现金`)
|
||||
- 全量校验通过才落库;任一非法行 → 400 且库内零变化
|
||||
- 已存在行:update_existing=false → skip(幂等默认);true → 更新 name/parent/level/category
|
||||
- 已存在行(同码**可见**=全局标准科目 ∪ 本账套私有行):update_existing=false → skip(幂等默认);
|
||||
true → 更新 name/parent/level/category —— **但只更新本账套私有行**;
|
||||
同码若只以**全局标准科目**(`entity_id IS NULL`)存在,则**永不被账套 UPSERT 改写**(计入 `skipped_global`),
|
||||
同时也不再插入本账套同码副本(避免标准科目重复)。
|
||||
- dry_run=true → 只校验与回显,零写入
|
||||
返回: {"ok":true,"inserted":n,"updated":m,"skipped":k,"errors":[]};未授权 401。
|
||||
返回: {"ok":true,"inserted":n,"updated":m,"skipped":k,"skipped_global":g,"entity_id":e,"errors":[]};未授权 401。
|
||||
"""
|
||||
clean = _validate_subject_rows(payload.get("rows"), db)
|
||||
clean = _validate_subject_rows(payload.get("rows"), db, entity_id=entity_id)
|
||||
dry_run = payload.get("dry_run", False)
|
||||
update_existing = payload.get("update_existing", False)
|
||||
codes = [r["subject_code"] for r in clean]
|
||||
|
||||
existing = set()
|
||||
existing = set() # 本会话「可见」的同码 = 全局标准科目(NULL)∪ 本账套私有行
|
||||
global_only = set() # 只以全局行(entity_id IS NULL)存在的同码:可见,但**不许被账套 UPSERT 改写**
|
||||
if codes:
|
||||
existing = {c for (c,) in db.query(Subject.subject_code)
|
||||
.filter(Subject.subject_code.in_(codes)).all()}
|
||||
rows = (db.query(Subject.subject_code, Subject.entity_id)
|
||||
.filter(Subject.subject_code.in_(codes),
|
||||
(Subject.entity_id.is_(None)) | (Subject.entity_id == entity_id))
|
||||
.all())
|
||||
by_code = {}
|
||||
for c, eid in rows:
|
||||
by_code.setdefault(c, set()).add(eid)
|
||||
existing = set(by_code)
|
||||
global_only = {c for c, eids in by_code.items() if eids == {None}}
|
||||
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
|
||||
# 可被本账套更新的只有「本账套私有行」;全局标准科目(NULL)一律不动(跨账套共享口径)
|
||||
updatable = [r for r in existed if r["subject_code"] not in global_only]
|
||||
to_update = updatable if update_existing else []
|
||||
updated_codes = {r["subject_code"] for r in to_update}
|
||||
to_skip = [r for r in existed if r["subject_code"] not in updated_codes]
|
||||
skip_global = [r for r in to_skip if r["subject_code"] in global_only]
|
||||
|
||||
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)} "
|
||||
logger.info(f"subjects UPSERT dry_run: bot={bot.get('name')} entity={entity_id} 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",
|
||||
"entity_id": entity_id,
|
||||
"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),
|
||||
"would_skip_global": len(skip_global),
|
||||
"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"]))
|
||||
category=r["category"], is_active=r["is_active"],
|
||||
entity_id=entity_id)) # 账套维度:落本会话账套(不再写全局 NULL 行)
|
||||
for r in to_update:
|
||||
db.query(Subject).filter(Subject.subject_code == r["subject_code"]).update({
|
||||
db.query(Subject).filter(
|
||||
Subject.subject_code == r["subject_code"],
|
||||
Subject.entity_id == entity_id, # 只更新本账套私有行(全局科目不被他账套改写)
|
||||
).update({
|
||||
Subject.subject_name: r["subject_name"],
|
||||
Subject.parent_code: r["parent_code"],
|
||||
Subject.level: r["level"],
|
||||
@@ -1250,11 +1281,13 @@ def bot_subjects_upsert(
|
||||
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)} "
|
||||
logger.info(f"subjects UPSERT: bot={bot.get('name')} entity={entity_id} 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",
|
||||
"entity_id": entity_id,
|
||||
"before": before, "after": after,
|
||||
"inserted": after - before, "updated": len(to_update), "skipped": len(to_skip),
|
||||
"skipped_global": len(skip_global),
|
||||
"upserted": (after - before) + len(to_update), "errors": [],
|
||||
"update_existing": update_existing}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user