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:
@@ -1,10 +1,25 @@
|
||||
"""置信度评分系统 — 财务Bot分析结论管理"""
|
||||
"""置信度评分系统 — 财务Bot分析结论管理
|
||||
|
||||
账套维度(2026-09-23 账套维度收口批,任务 0-P1-project-20260923-entity-dimension-convergence
|
||||
§二③ 读侧止血 + §三④ 账套化):
|
||||
|
||||
处置前:三个端点均无 `get_entity_id` ⇒ e1 / e2 双 token 拿到**逐字节相同**的返回体
|
||||
(实测 2 行存量:`id=1 TEST001` / `id=2 kpi_code=NULL`)。
|
||||
处置后:读/写/删均按会话账套判定归属;`entity_id` 列未落地时读侧退化为
|
||||
「`kpi_code` JOIN `kpi_definitions.entity_id`」止血口径(DDL 与代码可分两步上线)。
|
||||
归属三级(**任何一级都不猜账套**):
|
||||
① `analysis_results.entity_id`(列已落地且非 NULL)
|
||||
② 该行 `kpi_code` 在 `kpi_definitions` 中**唯一归属单账套**时所指向的 `entity_id`
|
||||
③ 判不出归属(`kpi_code` 为空 / 库内无该码 / 该码跨账套二义 / 列为 NULL)→ 进
|
||||
`unattributed` 登记池,**不返回给任何账套**;删除端点对其一律 404。
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy import func, inspect
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth
|
||||
from app.models import AnalysisResult, KPIValue, KPIDefinition
|
||||
from app.models import AnalysisResult, KPIValue, KPIDefinition, OperationLog
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
@@ -12,6 +27,95 @@ router = APIRouter(prefix="/api/cma/analysis", tags=["置信度评分"],
|
||||
dependencies=[Depends(require_auth)],
|
||||
)
|
||||
|
||||
# `analysis_results.entity_id` 列存在性缓存(键 = engine 对象 id;DDL 前后同一进程内可能变化,
|
||||
# 故只缓存判定结果、不做进程级常量:迁移后重启服务即刷新)
|
||||
_ENTITY_COL_CACHE: dict = {}
|
||||
|
||||
|
||||
def _has_entity_column(db) -> bool:
|
||||
"""`analysis_results.entity_id` 是否已落地(DDL 迁移 2026-09-23 未执行时为 False)。
|
||||
|
||||
读侧止血必须先于 DDL 合入 ⇒ 不能无条件引用该列;列不在时按 `kpi_code` JOIN 判归属。
|
||||
"""
|
||||
bind = db.get_bind()
|
||||
key = id(bind)
|
||||
cached = _ENTITY_COL_CACHE.get(key)
|
||||
if cached is None:
|
||||
try:
|
||||
cols = {c["name"] for c in inspect(bind).get_columns(AnalysisResult.__tablename__)}
|
||||
cached = "entity_id" in cols
|
||||
except Exception: # noqa: BLE001 —— 结构探不通时按"列不存在"的保守口径走 join
|
||||
cached = False
|
||||
_ENTITY_COL_CACHE[key] = cached
|
||||
return cached
|
||||
|
||||
|
||||
def _kpi_owners(db, codes: List[Optional[str]]) -> dict:
|
||||
"""`kpi_code` → 拥有该码的账套集合(同码可跨账套存在,故为集合)"""
|
||||
codes = [c for c in codes if c]
|
||||
if not codes:
|
||||
return {}
|
||||
owners: dict = {}
|
||||
rows = (
|
||||
db.query(KPIDefinition.kpi_code, KPIDefinition.entity_id)
|
||||
.filter(KPIDefinition.kpi_code.in_(codes))
|
||||
.all()
|
||||
)
|
||||
for code, eid in rows:
|
||||
if eid is not None:
|
||||
owners.setdefault(code, set()).add(eid)
|
||||
return owners
|
||||
|
||||
|
||||
def _row_owners(db, r, owners_map: dict) -> set:
|
||||
"""单行归属账套集合;空集 = 判不出归属(不猜账套)
|
||||
|
||||
列口径:`entity_id` 非 NULL 即为唯一归属。
|
||||
join 口径(列未落地 / 列为 NULL 的历史行):只有该 `kpi_code` 在 `kpi_definitions`
|
||||
中**唯一归属单账套**时才认定——同码跨账套(库内 22 个码属两账套)属归属二义,
|
||||
按「不猜账套」进登记池,宁可不可见也不误判给他账套。
|
||||
"""
|
||||
if _has_entity_column(db):
|
||||
eid = getattr(r, "entity_id", None)
|
||||
if eid is not None:
|
||||
return {eid}
|
||||
if r.kpi_code:
|
||||
owners = owners_map.get(r.kpi_code, set())
|
||||
return owners if len(owners) == 1 else set()
|
||||
return set()
|
||||
|
||||
|
||||
def _to_dict(r) -> dict:
|
||||
return {
|
||||
"id": r.id,
|
||||
"period": r.period,
|
||||
"结论": r.conclusion,
|
||||
"置信度": f"{r.confidence}%",
|
||||
"数据来源": r.data_source,
|
||||
"计算逻辑": r.calculation_logic,
|
||||
"可比基准": r.comparable_benchmark,
|
||||
"局限": r.limitations,
|
||||
"kpi_code": r.kpi_code,
|
||||
"kpi_name": r.kpi_name,
|
||||
"has_actual": bool(r.has_actual),
|
||||
"has_target": bool(r.has_target),
|
||||
"has_trend": bool(r.has_trend),
|
||||
"has_review": bool(r.has_review),
|
||||
"created_at": r.created_at.strftime("%Y-%m-%d %H:%M:%S") if r.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _unattributed_dict(r) -> dict:
|
||||
"""无归属登记项:只回标识与元信息,不回结论正文(登记而非共享)"""
|
||||
return {
|
||||
"id": r.id,
|
||||
"period": r.period,
|
||||
"kpi_code": r.kpi_code,
|
||||
"kpi_name": r.kpi_name,
|
||||
"confidence": r.confidence,
|
||||
"created_at": r.created_at.strftime("%Y-%m-%d %H:%M:%S") if r.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _calc_confidence(has_actual: bool, has_target: bool, has_trend: bool, has_review: bool) -> int:
|
||||
"""基于数据完整度自动计算置信度"""
|
||||
@@ -31,8 +135,14 @@ async def get_analysis_results(
|
||||
period: Optional[str] = Query(None, description="期间 YYYY-MM"),
|
||||
kpi_code: Optional[str] = Query(None, description="KPI编码"),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""查询分析结论(按期间和/或KPI编码过滤)"""
|
||||
"""查询分析结论(按期间和/或KPI编码过滤)— 账套隔离(2026-09-23)
|
||||
|
||||
返回体字段:原有字段全保留(`total` 语义收窄为「本账套可见行数」,`results` 仍为列表),
|
||||
新增 `entity_id` / `unattributed_total` / `unattributed` / `entity_scope`(均为附加字段,前端契约不破坏)。
|
||||
无归属行(存量 id=1/2)进 `unattributed`:**对任何账套都不进 `results`**,只作登记。
|
||||
"""
|
||||
query = db.query(AnalysisResult).order_by(AnalysisResult.created_at.desc())
|
||||
|
||||
if period:
|
||||
@@ -41,36 +151,34 @@ async def get_analysis_results(
|
||||
query = query.filter(AnalysisResult.kpi_code == kpi_code)
|
||||
|
||||
results = query.all()
|
||||
owners_map = _kpi_owners(db, [r.kpi_code for r in results])
|
||||
mine, unattributed = [], []
|
||||
for r in results:
|
||||
owners = _row_owners(db, r, owners_map)
|
||||
if not owners:
|
||||
unattributed.append(r)
|
||||
elif entity_id in owners:
|
||||
mine.append(r)
|
||||
|
||||
return {
|
||||
"total": len(results),
|
||||
"total": len(mine),
|
||||
"period": period,
|
||||
"results": [
|
||||
{
|
||||
"id": r.id,
|
||||
"period": r.period,
|
||||
"结论": r.conclusion,
|
||||
"置信度": f"{r.confidence}%",
|
||||
"数据来源": r.data_source,
|
||||
"计算逻辑": r.calculation_logic,
|
||||
"可比基准": r.comparable_benchmark,
|
||||
"局限": r.limitations,
|
||||
"kpi_code": r.kpi_code,
|
||||
"kpi_name": r.kpi_name,
|
||||
"has_actual": bool(r.has_actual),
|
||||
"has_target": bool(r.has_target),
|
||||
"has_trend": bool(r.has_trend),
|
||||
"has_review": bool(r.has_review),
|
||||
"created_at": r.created_at.strftime("%Y-%m-%d %H:%M:%S") if r.created_at else None,
|
||||
}
|
||||
for r in results
|
||||
],
|
||||
"entity_id": entity_id,
|
||||
"entity_scope": "column" if _has_entity_column(db) else "kpi_join",
|
||||
"unattributed_total": len(unattributed),
|
||||
"unattributed": [_unattributed_dict(r) for r in unattributed],
|
||||
"results": [_to_dict(r) for r in mine],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/result")
|
||||
def create_analysis_result(data: dict = None, db: Session = Depends(get_db)):
|
||||
"""提交分析结果(自动计算置信度)"""
|
||||
def create_analysis_result(data: dict = None, db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""提交分析结果(自动计算置信度)— 账套维度:本次写入挂**会话账套**
|
||||
|
||||
`entity_id` 列未落地时(DDL 迁移前)先把会话账套写进 `OperationLog.detail.entity_id`,
|
||||
列落地后直接写列(响应始终回 `entity_id`)。
|
||||
"""
|
||||
if not data:
|
||||
data = {}
|
||||
period = data.get("period", "")
|
||||
@@ -88,6 +196,7 @@ def create_analysis_result(data: dict = None, db: Session = Depends(get_db)):
|
||||
|
||||
confidence = _calc_confidence(has_actual, has_target, has_trend, has_review)
|
||||
|
||||
has_column = _has_entity_column(db)
|
||||
result = AnalysisResult(
|
||||
period=period,
|
||||
conclusion=conclusion,
|
||||
@@ -103,10 +212,25 @@ def create_analysis_result(data: dict = None, db: Session = Depends(get_db)):
|
||||
has_trend=1 if has_trend else 0,
|
||||
has_review=1 if has_review else 0,
|
||||
)
|
||||
if has_column:
|
||||
result.entity_id = entity_id
|
||||
db.add(result)
|
||||
db.commit()
|
||||
db.refresh(result)
|
||||
|
||||
if not has_column:
|
||||
# DDL 未落地前的过渡留痕:会话账套只存在于日志,不猜列
|
||||
import json
|
||||
db.add(OperationLog(
|
||||
user_id=None, action="create", target_type="analysis_result",
|
||||
target_id=result.id,
|
||||
detail=json.dumps({"entity_id": entity_id, "kpi_code": kpi_code,
|
||||
"period": period,
|
||||
"note": "analysis_results.entity_id 列未落地,账套暂存于日志"},
|
||||
ensure_ascii=False),
|
||||
))
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"id": result.id,
|
||||
"period": result.period,
|
||||
@@ -117,6 +241,8 @@ def create_analysis_result(data: dict = None, db: Session = Depends(get_db)):
|
||||
"可比基准": result.comparable_benchmark,
|
||||
"局限": result.limitations,
|
||||
"confidence_score": result.confidence,
|
||||
"entity_id": entity_id,
|
||||
"entity_persisted": "column" if has_column else "operation_log",
|
||||
"message": "分析结论已保存",
|
||||
}
|
||||
|
||||
@@ -125,11 +251,16 @@ def create_analysis_result(data: dict = None, db: Session = Depends(get_db)):
|
||||
async def delete_analysis_result(
|
||||
result_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""删除分析结论"""
|
||||
"""删除分析结论 — 归属校验:非本账套 或 无归属一律 **404**(不引入 403,避免探测面)"""
|
||||
result = db.query(AnalysisResult).filter(AnalysisResult.id == result_id).first()
|
||||
if not result:
|
||||
raise HTTPException(404, "分析结论不存在")
|
||||
owners = _row_owners(db, result, _kpi_owners(db, [result.kpi_code]))
|
||||
if entity_id not in owners:
|
||||
# 他账套 id 与无归属行走同一文案(不暴露"该行存在但不属于你")
|
||||
raise HTTPException(404, "分析结论不存在")
|
||||
db.delete(result)
|
||||
db.commit()
|
||||
return {"message": "已删除"}
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -107,7 +107,11 @@ def _query_items(
|
||||
items: list[dict] = []
|
||||
|
||||
if not data_type or data_type == "subject":
|
||||
q = db.query(Subject).filter(Subject.is_active == 1)
|
||||
# 账套分层(2026-09-23):全局标准准则科目 ∪ 本账套私有明细(与 KPI 侧同一账套口径)
|
||||
q = db.query(Subject).filter(
|
||||
Subject.is_active == 1,
|
||||
(Subject.entity_id.is_(None)) | (Subject.entity_id == entity_id),
|
||||
)
|
||||
if data_level:
|
||||
q = q.filter(Subject.data_level == data_level)
|
||||
if important == 1:
|
||||
@@ -181,9 +185,13 @@ def get_stats(
|
||||
current_user = Depends(require_auth),
|
||||
):
|
||||
"""数据分类分级统计概览(评估前准备看板)"""
|
||||
subject_total = db.query(Subject).filter(Subject.is_active == 1).count()
|
||||
subject_total = db.query(Subject).filter(
|
||||
Subject.is_active == 1,
|
||||
(Subject.entity_id.is_(None)) | (Subject.entity_id == entity_id),
|
||||
).count()
|
||||
subject_marked = db.query(Subject).filter(
|
||||
Subject.is_active == 1, Subject.important_flag == 1
|
||||
Subject.is_active == 1, Subject.important_flag == 1,
|
||||
(Subject.entity_id.is_(None)) | (Subject.entity_id == entity_id),
|
||||
).count()
|
||||
kpi_total = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.status == "active", KPIDefinition.entity_id == entity_id
|
||||
|
||||
@@ -45,12 +45,22 @@ def trace_ontology(
|
||||
)
|
||||
|
||||
# 第3层: 每个 KPI 依赖的科目 (kpi_subject_map)
|
||||
# ⚠️ 护栏(2026-09-23 账套维度收口批 ②,零结构改动、零读取逻辑改动):
|
||||
# 该表是唯一一张「语义上必然账套相关、却无账套列」的表 —— **账套维度经 `kpi_id` 继承**
|
||||
# (`kpi_definitions.entity_id`,本函数先按 `Objective.entity_id` 过滤目标、再按 kpi_id 取映射)。
|
||||
# **禁止新增按 `subject_code` 反查的读口,禁止按 `subject_code` 批量写的入口**:
|
||||
# 科目码在标准准则体系里两账套同义(同一 code 可属不同账套),按科目反查/批量写立刻跨账套。
|
||||
subject_by_code = {
|
||||
s.subject_code: s.subject_name
|
||||
for s in db.query(Subject).filter(Subject.is_active == 1).all()
|
||||
for s in db.query(Subject).filter(
|
||||
Subject.is_active == 1,
|
||||
# 账套分层(2026-09-23 subjects 加 entity_id 列):全局标准科目 ∪ 本账套私有明细
|
||||
(Subject.entity_id.is_(None)) | (Subject.entity_id == entity_id),
|
||||
).all()
|
||||
}
|
||||
kpi_layer = []
|
||||
for link, kpi in links:
|
||||
# kpi_id 已由上方 Objective.entity_id 过滤链继承账套维度(见护栏注释),故此处不再按科目口径过滤
|
||||
maps = (
|
||||
db.query(KPISubjectMap)
|
||||
.filter(KPISubjectMap.kpi_id == kpi.id)
|
||||
|
||||
@@ -5,6 +5,7 @@ from sqlalchemy import func
|
||||
from typing import Optional, List
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_role
|
||||
from app.deps import get_entity_id
|
||||
from app.models import Subject
|
||||
import logging
|
||||
|
||||
@@ -20,9 +21,19 @@ def list_subjects(
|
||||
category: Optional[str] = Query(None, description="新30号准则分类过滤"),
|
||||
keyword: Optional[str] = Query(None, description="科目名称/编码搜索"),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""科目列表 — 支持新30号准则分类筛选"""
|
||||
query = db.query(Subject).filter(Subject.is_active == 1)
|
||||
"""科目列表 — 支持新30号准则分类筛选
|
||||
|
||||
账套分层(2026-09-23 `subjects` 加 `entity_id` 列):返回**全局标准准则科目**
|
||||
(`entity_id IS NULL`)∪ **本账套私有明细**(`entity_id = 会话账套`)。
|
||||
行为变更(预期):博海会话不再看到酣客智享通明细(`100106 酒窖备用金` / `100108 友赞商户` 等)。
|
||||
返回体字段不删不加必填(前端契约不变)。
|
||||
"""
|
||||
query = db.query(Subject).filter(
|
||||
Subject.is_active == 1,
|
||||
(Subject.entity_id.is_(None)) | (Subject.entity_id == entity_id), # 分层读取口径
|
||||
)
|
||||
|
||||
if category:
|
||||
query = query.filter(Subject.new_standard_category == category)
|
||||
|
||||
@@ -8,6 +8,7 @@ from datetime import datetime
|
||||
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_role
|
||||
from app.deps import get_entity_id
|
||||
from app.models import KPITemplate, KPIDefinition, OperationLog
|
||||
from app.api.kpi_governance import validate_kpi_payload, kpi_issues_message
|
||||
|
||||
@@ -107,8 +108,16 @@ def delete_template(template_id: int, db: Session = Depends(get_db), user=WRITE_
|
||||
|
||||
|
||||
@router.post("/{template_id}/instantiate")
|
||||
def instantiate_template(template_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
"""从模板实例化创建KPI,复制模板快照到kpi_definitions"""
|
||||
def instantiate_template(template_id: int, data: dict, db: Session = Depends(get_db),
|
||||
user=WRITE_ROLES, entity_id: int = Depends(get_entity_id)):
|
||||
"""从模板实例化创建KPI,复制模板快照到kpi_definitions
|
||||
|
||||
账套维度(2026-09-23 账套维度收口批 ①):新 KPI 显式挂**会话账套**,不做静默兜底。
|
||||
历史缺陷:本构造点未传 `entity_id` ⇒ 模型 `default=1` 把任意账套实例化出的 KPI
|
||||
静默挂到酣客(e1),与「建预算未写账套」同源。
|
||||
判重口径:`kpi_code` 唯一性按账套判定(库内 `uk_entity_kpi(UNIQUE entity_id,kpi_code)`);
|
||||
全表判重会让博海无法建与酣客同码的 KPI(反之则放过同账套重复)。
|
||||
"""
|
||||
t = db.query(KPITemplate).filter(KPITemplate.id == template_id).first()
|
||||
if not t:
|
||||
raise HTTPException(404, "模板不存在")
|
||||
@@ -116,10 +125,13 @@ def instantiate_template(template_id: int, data: dict, db: Session = Depends(get
|
||||
kpi_code = data.get("kpi_code", t.kpi_code)
|
||||
kpi_name = data.get("kpi_name", t.kpi_name)
|
||||
|
||||
# 检查编码唯一性
|
||||
existing = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code).first()
|
||||
# 检查编码唯一性(按账套收紧:仅本账套内判重)
|
||||
existing = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.kpi_code == kpi_code,
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(400, f"KPI编码 {kpi_code} 已存在,请修改")
|
||||
raise HTTPException(400, f"KPI编码 {kpi_code} 在本账套已存在,请修改")
|
||||
|
||||
# 数据治理:入库必检(规则1) + 编码规范(规则3)
|
||||
merged = {
|
||||
@@ -136,6 +148,7 @@ def instantiate_template(template_id: int, data: dict, db: Session = Depends(get
|
||||
raise HTTPException(422, detail={"message": "数据校验不通过", "errors": kpi_issues_message(gov_issues)})
|
||||
|
||||
kpi = KPIDefinition(
|
||||
entity_id=entity_id, # 账套维度:显式挂会话账套(不得依赖模型 default=1)
|
||||
template_id=t.id,
|
||||
is_system=0, # 从模板实例化的KPI不是系统预置
|
||||
kpi_code=kpi_code,
|
||||
|
||||
Reference in New Issue
Block a user