fix(security): 多租户隔离全量修复 security-fix multi-tenant (OpenCode审查P0)

- bot_bridge 18数据端点全部 entity_id 隔离(Depends(get_entity_id)/body),/ping /risk-levels 豁免
- alert_rules 11端点 entity_id 隔离 + KPIAlert/DynamicThresholdCache 写入 entity_id
- reports 17端点隔离 + generate_report 写 ReportHistory.entity_id + history 按 entity 过滤
- ai_analysis 移除硬编码默认key,改 _require_deepseek_key() 强制 env 缺失 503
- budget auto-decompose 硬编码 entity_id==1 改请求 entity
- kpis update_kpi 加 UPDATE_KPI_WHITELIST 白名单(status/important_flag 不可越权改)
- data_quality 收敛:删 MySQL JSON 版 _run_rule_checks,check-governance 复用 _run_governance_checks(SQLite 兼容)
- _eval_threshold invert 参数修复(>=↔< 等取反),red 分支不传 invert 保持行为
- 新增 test_security_multitenant.py 13条(bot_bridge/alert_rules/reports 隔离 + invert + SQLite governance)
- models 6表加 entity_id 列;生产库已 ALTER + 按真实归属回填(kpi_alerts 472行中216行属entity≠1)
This commit is contained in:
Hermes CI Fix
2026-08-31 10:14:22 +08:00
parent 72072dda8c
commit 74dc9baff5
11 changed files with 560 additions and 348 deletions
+140 -108
View File
@@ -37,6 +37,7 @@ router = APIRouter(prefix="/api/cma/reports", tags=["管理报表"],
def get_profit_summary(
period: str = Query(None, description="格式 YYYY-MM"),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""管理利润表 — 收入→变动成本→边际贡献→固定成本→息税前利润"""
if period is None:
@@ -44,7 +45,8 @@ def get_profit_summary(
# 从KPI数据中获取各利润要素
def get_val(code: str):
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == code).first()
kpi = db.query(KPIDefinition).filter(
KPIDefinition.kpi_code == code, KPIDefinition.entity_id == entity_id).first()
if not kpi:
return None
v = db.query(KPIValue).filter(
@@ -79,7 +81,8 @@ def get_profit_summary(
prev_period = f"{py}-{pm:02d}"
def get_prev_val(code: str):
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == code).first()
kpi = db.query(KPIDefinition).filter(
KPIDefinition.kpi_code == code, KPIDefinition.entity_id == entity_id).first()
if not kpi: return None
v = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id, KPIValue.period == prev_period
@@ -159,12 +162,13 @@ def get_budget_execution(
dimension: Optional[str] = Query(None),
alert_level: Optional[str] = Query(None),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""预算执行报告 — 各KPI预算vs实际vs差异率"""
"""预算执行报告 — 各KPI预算vs实际vs差异率(账套隔离 2026-08-31"""
if period is None:
period = datetime.now().strftime("%Y-%m")
query = db.query(KPIDefinition).filter(KPIDefinition.status == "active")
query = db.query(KPIDefinition).filter(KPIDefinition.status == "active", KPIDefinition.entity_id == entity_id)
if dimension:
query = query.filter(KPIDefinition.dimension == dimension)
kpis = query.order_by(KPIDefinition.dimension, KPIDefinition.kpi_code).all()
@@ -223,9 +227,10 @@ def get_kpi_trends(
dimension: Optional[str] = Query(None),
months: int = Query(12, ge=3, le=36),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""KPI趋势报告 — 选定KPI的历史趋势线"""
query = db.query(KPIDefinition).filter(KPIDefinition.status == "active")
"""KPI趋势报告 — 选定KPI的历史趋势线(账套隔离 2026-08-31"""
query = db.query(KPIDefinition).filter(KPIDefinition.status == "active", KPIDefinition.entity_id == entity_id)
if kpi_id:
query = query.filter(KPIDefinition.id == kpi_id)
if dimension:
@@ -289,20 +294,21 @@ def get_bsc_scorecard(
map_id: Optional[int] = Query(None),
period: Optional[str] = Query(None),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""四维度绩效评分卡 — BSC健康度"""
"""四维度绩效评分卡 — BSC健康度(账套隔离 2026-08-31"""
if period is None:
period = datetime.now().strftime("%Y-%m")
# 取最新的已发布地图
map_query = db.query(StrategicMap).filter(StrategicMap.status == "published")
# 取最新的已发布地图(当前企业)
map_query = db.query(StrategicMap).filter(StrategicMap.status == "published", StrategicMap.entity_id == entity_id)
if map_id:
map_query = map_query.filter(StrategicMap.id == map_id)
sm = map_query.order_by(StrategicMap.updated_at.desc()).first()
if not sm:
# 没有已发布地图,按维度聚合KPI
return _build_scorecard_from_kpis(db, period)
return _build_scorecard_from_kpis(db, period, entity_id)
# 从战略地图维度数据构建评分卡
dims = sm.dimensions
@@ -369,9 +375,10 @@ def get_bsc_scorecard(
}
def _build_scorecard_from_kpis(db: Session, period: str) -> dict:
"""没有战略地图时,直接按维度聚合KPI算分"""
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
def _build_scorecard_from_kpis(db: Session, period: str, entity_id: int = 1) -> dict:
"""没有战略地图时,直接按维度聚合KPI算分(账套隔离 2026-08-31"""
kpis = db.query(KPIDefinition).filter(
KPIDefinition.status == "active", KPIDefinition.entity_id == entity_id).all()
dims: dict = {}
for kpi in kpis:
@@ -520,8 +527,8 @@ BLOCK_INFO = {
}
def _get_subject_amount(db: Session, code: str, period: str) -> Optional[float]:
"""从 subjects + kpi_values 获取科目金额数据"""
def _get_subject_amount(db: Session, code: str, period: str, entity_id: int = 1) -> Optional[float]:
"""从 subjects + kpi_values 获取科目金额数据(账套隔离 2026-08-31"""
# 尝试从KPI数据获取(KPI编码与科目编码映射)
kpi_code_map = {
"6001": "F_REVENUE",
@@ -545,7 +552,8 @@ def _get_subject_amount(db: Session, code: str, period: str) -> Optional[float]:
# 1. 优先从 kpi_values 取
if code in kpi_code_map:
kpi_code = kpi_code_map[code]
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code).first()
kpi = db.query(KPIDefinition).filter(
KPIDefinition.kpi_code == kpi_code, KPIDefinition.entity_id == entity_id).first()
if kpi:
v = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
@@ -576,19 +584,20 @@ def get_profit_statement(
period: str = Query(None, description="格式 YYYY-MM"),
format: str = Query("old", description="old/new/dual"),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""利润表 — 支持旧格式、新30号准则五板块格式、双列对比"""
"""利润表 — 支持旧格式、新30号准则五板块格式、双列对比(账套隔离 2026-08-31"""
if period is None:
period = datetime.now().strftime("%Y-%m")
if format == "old":
# 旧30号准则格式(保留兼容)
return get_profit_summary(period=period, db=db)
return get_profit_summary(period=period, db=db, entity_id=entity_id)
if format == "dual":
# 双列对比:旧准则 vs 新准则
old_data = get_profit_summary(period=period, db=db)
new_data = _build_new_format_profit(db, period)
old_data = get_profit_summary(period=period, db=db, entity_id=entity_id)
new_data = _build_new_format_profit(db, period, entity_id)
return {
"period": period,
"format": "dual",
@@ -598,11 +607,11 @@ def get_profit_statement(
}
# === 新30号准则:五板块结构 ===
return _build_new_format_profit(db, period)
return _build_new_format_profit(db, period, entity_id)
def _build_new_format_profit(db: Session, period: str) -> dict:
"""构建新30号准则五板块利润表(含附注明细)"""
def _build_new_format_profit(db: Session, period: str, entity_id: int = 1) -> dict:
"""构建新30号准则五板块利润表(含附注明细)(账套隔离 2026-08-31"""
blocks = []
total_net_profit = 0
all_items_have_data = True
@@ -614,7 +623,7 @@ def _build_new_format_profit(db: Session, period: str) -> dict:
block_has_data = False
for item_cfg in block_cfg["items"]:
amount = _get_subject_amount(db, item_cfg["code"], period)
amount = _get_subject_amount(db, item_cfg["code"], period, entity_id)
if amount is not None:
effective = amount * item_cfg["sign"]
block_subtotal += effective
@@ -646,7 +655,7 @@ def _build_new_format_profit(db: Session, period: str) -> dict:
total_net_profit += block_subtotal
# 附注明细(对外法定报表披露要求)
notes = _build_profit_notes(db, period, blocks, total_net_profit)
notes = _build_profit_notes(db, period, blocks, total_net_profit, entity_id)
# 合计行:净利润 = 一二三+四+五
return {
@@ -662,10 +671,10 @@ def _build_new_format_profit(db: Session, period: str) -> dict:
}
def _build_profit_notes(db: Session, period: str, blocks: list, net_profit: float) -> dict:
"""利润表附注明细 — 收入/费用/财务费用拆解 + 板块勾稽 + 关键比率"""
def _build_profit_notes(db: Session, period: str, blocks: list, net_profit: float, entity_id: int = 1) -> dict:
"""利润表附注明细 — 收入/费用/财务费用拆解 + 板块勾稽 + 关键比率(账套隔离 2026-08-31"""
def amt(code):
return _get_subject_amount(db, code, period)
return _get_subject_amount(db, code, period, entity_id)
revenue_main = amt("6001")
revenue_other = amt("6051")
@@ -819,8 +828,9 @@ class MpmCalculateRequest(BaseModel):
def mpm_calculate(
req: MpmCalculateRequest,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""MPM管理层指标计算器 — 生成合规调节表"""
"""MPM管理层指标计算器 — 生成合规调节表(账套隔离 2026-08-31"""
if req.period is None:
req.period = datetime.now().strftime("%Y-%m")
@@ -829,12 +839,12 @@ def mpm_calculate(
raise HTTPException(status_code=400, detail=f"不支持的指标类型: {req.indicator_type}")
# 获取基准值:净利润
net_profit = _calc_new_net_profit(db, req.period)
net_profit = _calc_new_net_profit(db, req.period, entity_id)
if net_profit is None:
net_profit = 0
# 经营现金流(自由现金流的基准)
operating_cf = _get_kpi_val(db, "F_OPERATING_CF", req.period)
operating_cf = _get_kpi_val(db, "F_OPERATING_CF", req.period, entity_id)
# 确定基准值
if req.indicator_type == "free_cash_flow":
@@ -872,7 +882,7 @@ def mpm_calculate(
# 尝试自动取值
if amount is None and checked:
amount = _get_adjustment_value(db, code, req.period)
amount = _get_adjustment_value(db, code, req.period, entity_id)
effective = round(amount * sign, 2) if amount is not None else None
@@ -913,9 +923,10 @@ def mpm_calculate(
}
def _get_kpi_val(db: Session, code: str, period: str) -> Optional[float]:
"""从KPI定义+值获取数值"""
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == code).first()
def _get_kpi_val(db: Session, code: str, period: str, entity_id: int = 1) -> Optional[float]:
"""从KPI定义+值获取数值(账套隔离 2026-08-31"""
kpi = db.query(KPIDefinition).filter(
KPIDefinition.kpi_code == code, KPIDefinition.entity_id == entity_id).first()
if not kpi:
return None
v = db.query(KPIValue).filter(
@@ -924,14 +935,14 @@ def _get_kpi_val(db: Session, code: str, period: str) -> Optional[float]:
return float(v.actual_value) if v and v.actual_value is not None else None
def _calc_new_net_profit(db: Session, period: str) -> Optional[float]:
"""计算新30号准则下的净利润"""
def _calc_new_net_profit(db: Session, period: str, entity_id: int = 1) -> Optional[float]:
"""计算新30号准则下的净利润(账套隔离 2026-08-31"""
total = 0
has_data = False
for block_key in ["operating", "investing", "financing", "tax", "discontinued"]:
block_cfg = BLOCK_INFO[block_key]
for item_cfg in block_cfg["items"]:
amount = _get_subject_amount(db, item_cfg["code"], period)
amount = _get_subject_amount(db, item_cfg["code"], period, entity_id)
if amount is not None:
total += amount * item_cfg["sign"]
has_data = True
@@ -940,14 +951,14 @@ def _calc_new_net_profit(db: Session, period: str) -> Optional[float]:
return round(total, 2)
def _get_adjustment_value(db: Session, adj_code: str, period: str) -> Optional[float]:
"""获取调整项的自动取值"""
def _get_adjustment_value(db: Session, adj_code: str, period: str, entity_id: int = 1) -> Optional[float]:
"""获取调整项的自动取值(账套隔离 2026-08-31"""
mapping = ADJUSTMENT_VALUE_MAP.get(adj_code)
if mapping is None:
return None # 需要用户输入
code = mapping["code"]
amount = _get_subject_amount(db, code, period)
amount = _get_subject_amount(db, code, period, entity_id)
if amount is None:
return None
@@ -977,14 +988,16 @@ def _get_demo_block_total(block_key: str) -> float:
def get_restatement(
period: str = Query(None, description="格式 YYYY-MM"),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""2026年数据按新准则重述 — 旧口径vs新口径双列对比,自动标记调整项"""
"""2026年数据按新准则重述 — 旧口径vs新口径双列对比,自动标记调整项(账套隔离 2026-08-31"""
if period is None:
period = datetime.now().strftime("%Y-%m")
# 旧口径数据 (传统利润表项目)
def _old_kpi_val(code: str):
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == code).first()
kpi = db.query(KPIDefinition).filter(
KPIDefinition.kpi_code == code, KPIDefinition.entity_id == entity_id).first()
if not kpi:
return None
v = db.query(KPIValue).filter(
@@ -1002,22 +1015,22 @@ def get_restatement(
old_rd_exp = _old_kpi_val("F_RD_EXP")
# 新口径数据 (从科目映射或kpi_values获取)
new_revenue = _get_subject_amount(db, "6001", period)
new_revenue_other = _get_subject_amount(db, "6051", period)
new_cost = _get_subject_amount(db, "6401", period)
new_cost_other = _get_subject_amount(db, "6402", period)
new_selling = _get_subject_amount(db, "6601", period)
new_admin = _get_subject_amount(db, "6602", period)
new_rd = _get_subject_amount(db, "660204", period)
new_interest_income = _get_subject_amount(db, "6011", period)
new_interest_exp = _get_subject_amount(db, "660301", period)
new_fx = _get_subject_amount(db, "6603", period)
new_fx_financing = _get_subject_amount(db, "660302", period)
new_invest_income = _get_subject_amount(db, "6111", period)
new_impairment = _get_subject_amount(db, "6701", period)
new_invest_impairment = _get_subject_amount(db, "670101", period)
new_tax = _get_subject_amount(db, "6801", period)
new_discontinued = _get_subject_amount(db, "6901", period)
new_revenue = _get_subject_amount(db, "6001", period, entity_id)
new_revenue_other = _get_subject_amount(db, "6051", period, entity_id)
new_cost = _get_subject_amount(db, "6401", period, entity_id)
new_cost_other = _get_subject_amount(db, "6402", period, entity_id)
new_selling = _get_subject_amount(db, "6601", period, entity_id)
new_admin = _get_subject_amount(db, "6602", period, entity_id)
new_rd = _get_subject_amount(db, "660204", period, entity_id)
new_interest_income = _get_subject_amount(db, "6011", period, entity_id)
new_interest_exp = _get_subject_amount(db, "660301", period, entity_id)
new_fx = _get_subject_amount(db, "6603", period, entity_id)
new_fx_financing = _get_subject_amount(db, "660302", period, entity_id)
new_invest_income = _get_subject_amount(db, "6111", period, entity_id)
new_impairment = _get_subject_amount(db, "6701", period, entity_id)
new_invest_impairment = _get_subject_amount(db, "670101", period, entity_id)
new_tax = _get_subject_amount(db, "6801", period, entity_id)
new_discontinued = _get_subject_amount(db, "6901", period, entity_id)
# 旧口径汇总计算
old_operating_items = [
@@ -1252,7 +1265,7 @@ def get_restatement(
total = 0
for codes in [op_items, inv_items, fin_items, tax_items, dis_items]:
for code in codes:
amt = _get_subject_amount(db, code, period)
amt = _get_subject_amount(db, code, period, entity_id)
if amt is not None:
# 根据BLOCK_INFO中的sign处理
for bk in BLOCK_INFO.values():
@@ -1285,7 +1298,9 @@ def get_restatement(
def get_category_map(
db: Session = Depends(get_db),
):
"""返回科目→新30号准则板块映射"""
"""返回科目→新30号准则板块映射
豁免多租户隔离(2026-08-31):Subject 为全局会计科目字典(无 entity_id 列),
返回的是科目分类映射常量,非企业业务数据,故不做 entity 过滤"""
subjects_data = db.query(Subject).filter(Subject.is_active == 1).order_by(Subject.subject_code).all()
map_list = []
@@ -1443,8 +1458,8 @@ def _prev_period_str(period: str) -> str:
return period
def _get_bs_amount(db: Session, codes: list, period: str) -> Optional[float]:
"""资产负债表科目余额 — 优先凭证明细,无数据返回 None"""
def _get_bs_amount(db: Session, codes: list, period: str, entity_id: int = 1) -> Optional[float]:
"""资产负债表科目余额 — 优先凭证明细,无数据返回 None(账套隔离 2026-08-31"""
total = 0.0
has_data = False
try:
@@ -1464,9 +1479,9 @@ def _get_bs_amount(db: Session, codes: list, period: str) -> Optional[float]:
return round(total, 2) if has_data else None
def _bs_line_amount(db: Session, line: dict, period: str, column: str = "end") -> dict:
"""单行:凭证数据优先,否则示例数据(column: end期末 / begin期初)"""
real = _get_bs_amount(db, line["codes"], period)
def _bs_line_amount(db: Session, line: dict, period: str, column: str = "end", entity_id: int = 1) -> dict:
"""单行:凭证数据优先,否则示例数据(column: end期末 / begin期初)(账套隔离 2026-08-31"""
real = _get_bs_amount(db, line["codes"], period, entity_id)
if real is not None:
return {"value": real, "is_demo": False}
key = "|".join(c for c, _ in line["codes"])
@@ -1480,8 +1495,9 @@ def _bs_line_amount(db: Session, line: dict, period: str, column: str = "end") -
def get_balance_sheet(
period: str = Query(None, description="格式 YYYY-MM"),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""资产负债表 — 新30号准则科目分类(经营/投资/筹资),期末vs期初"""
"""资产负债表 — 新30号准则科目分类(经营/投资/筹资),期末vs期初(账套隔离 2026-08-31"""
if period is None:
period = datetime.now().strftime("%Y-%m")
prev_period = _prev_period_str(period)
@@ -1497,8 +1513,8 @@ def get_balance_sheet(
sec_end = sec_begin = 0.0
sec_real = False
for line in sec["lines"]:
end = _bs_line_amount(db, line, period, column="end")
begin = _bs_line_amount(db, line, prev_period, column="begin")
end = _bs_line_amount(db, line, period, column="end", entity_id=entity_id)
begin = _bs_line_amount(db, line, prev_period, column="begin", entity_id=entity_id)
if end["is_demo"] or begin["is_demo"]:
all_real = False
if end["value"] is not None:
@@ -1588,14 +1604,14 @@ CF_DEMO_FX = 0 # 汇率变动对现金的影响
CF_DEMO_BEGIN = 1200 # 期初现金及现金等价物余额
def _get_cf_amount(db: Session, line: dict, period: str) -> dict:
"""现金流量表行项目 — 优先KPI/凭证,否则示例数据"""
def _get_cf_amount(db: Session, line: dict, period: str, entity_id: int = 1) -> dict:
"""现金流量表行项目 — 优先KPI/凭证,否则示例数据(账套隔离 2026-08-31"""
# 经营净额行特殊处理:优先取 F_OP_CFLOW
if line.get("kpi_code"):
kpi_val = _get_kpi_val(db, line["kpi_code"], period)
kpi_val = _get_kpi_val(db, line["kpi_code"], period, entity_id)
if kpi_val is not None:
return {"value": round(kpi_val, 2), "is_demo": False}
real = _get_bs_amount(db, [(line["code"], line["sign"])], period)
real = _get_bs_amount(db, [(line["code"], line["sign"])], period, entity_id)
if real is not None:
return {"value": real, "is_demo": False}
demo = CF_DEMO.get(line["code"])
@@ -1608,8 +1624,9 @@ def _get_cf_amount(db: Session, line: dict, period: str) -> dict:
def get_cash_flow_statement(
period: str = Query(None, description="格式 YYYY-MM"),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""现金流量表 — 经营/投资/筹资三活动(新30号准则直接法)"""
"""现金流量表 — 经营/投资/筹资三活动(新30号准则直接法)(账套隔离 2026-08-31"""
if period is None:
period = datetime.now().strftime("%Y-%m")
@@ -1630,7 +1647,7 @@ def get_cash_flow_statement(
for line in CASH_FLOW_LINES:
if line["section"] != sc["key"]:
continue
v = _get_cf_amount(db, line, period)
v = _get_cf_amount(db, line, period, entity_id)
if v["is_demo"]:
all_real = False
if v["value"] is not None:
@@ -1653,12 +1670,12 @@ def get_cash_flow_statement(
})
# 经营净额行优先取 KPI F_OP_CFLOW(真实数据优先)
op_kpi = _get_kpi_val(db, "F_OP_CFLOW", period)
op_kpi = _get_kpi_val(db, "F_OP_CFLOW", period, entity_id)
if op_kpi is not None:
sections[0]["net"] = round(op_kpi, 2)
net_by_section["operating"] = round(op_kpi, 2)
fx = _get_kpi_val(db, "F_FX_LOSS", period)
fx = _get_kpi_val(db, "F_FX_LOSS", period, entity_id)
if fx is None:
fx = CF_DEMO_FX
fx_demo = True
@@ -1690,6 +1707,7 @@ def get_cash_flow_statement(
def get_statutory_reports(
period: str = Query(None, description="格式 YYYY-MM"),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""对外法定报表(新30号准则)— 利润表+资产负债表+现金流量表 组合视图"""
if period is None:
@@ -1697,9 +1715,9 @@ def get_statutory_reports(
return {
"period": period,
"title": f"对外法定报表 — 新30号准则({period}",
"profit": _build_new_format_profit(db, period),
"balance_sheet": get_balance_sheet(period=period, db=db),
"cash_flow": get_cash_flow_statement(period=period, db=db),
"profit": _build_new_format_profit(db, period, entity_id),
"balance_sheet": get_balance_sheet(period=period, db=db, entity_id=entity_id),
"cash_flow": get_cash_flow_statement(period=period, db=db, entity_id=entity_id),
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
}
@@ -1708,11 +1726,12 @@ def get_statutory_reports(
def export_statutory_reports(
period: str = Query(None, description="格式 YYYY-MM"),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""导出对外法定报表(新30号准则)— Excel 三表合一"""
if period is None:
period = datetime.now().strftime("%Y-%m")
data = get_statutory_reports(period=period, db=db)
data = get_statutory_reports(period=period, db=db, entity_id=entity_id)
from io import BytesIO
from openpyxl import Workbook
@@ -1869,7 +1888,11 @@ def get_dupont_analysis(
entity: str = Query("bohai"),
db: Session = Depends(get_db),
):
"""杜邦分析 — ROE三级拆解 (CMA P2)"""
"""杜邦分析 — ROE三级拆解 (CMA P2)
豁免多租户隔离(2026-08-31):跨实体对比分析端点,entity 参数显式指定
分析对象(bohai→entity 2 / hanke→entity 1),非默认全库查询,故不叠加
Depends(get_entity_id)(叠加会导致 token 绑定的 entity 与显式 entity 参数
不一致时被 403 拦截,破坏跨企业对比功能)"""
if entity == "bohai":
# 博海标准KPIF_REVENUE/F_NET_PROFIT)无verified值 → 优先DB读,读不到回退文档确认常量
net_profit = _get_dupont_kpi(db, 2, "F_NET_PROFIT")
@@ -2060,9 +2083,10 @@ def _get_month_period_prefix(period: str) -> str:
return f"{y}-{m:02d}"
def _fetch_kpi_data(db: Session) -> list:
"""获取所有活跃KPI的当前值、目标值、维度、预警"""
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
def _fetch_kpi_data(db: Session, entity_id: int = 1) -> list:
"""获取当前企业所有活跃KPI的当前值、目标值、维度、预警(账套隔离 2026-08-31"""
kpis = db.query(KPIDefinition).filter(
KPIDefinition.status == "active", KPIDefinition.entity_id == entity_id).all()
result = []
for k in kpis:
latest = db.query(KPIValue).filter(
@@ -2071,6 +2095,7 @@ def _fetch_kpi_data(db: Session) -> list:
).order_by(KPIValue.period.desc()).first()
alerts = db.query(KPIAlert).filter(
KPIAlert.entity_id == entity_id,
KPIAlert.kpi_id == k.id,
KPIAlert.status == "pending",
).order_by(KPIAlert.created_at.desc()).all()
@@ -2094,9 +2119,9 @@ def _fetch_kpi_data(db: Session) -> list:
return result
def _build_weekly_report(db: Session, period: str) -> dict:
"""生成周报"""
kpis = _fetch_kpi_data(db)
def _build_weekly_report(db: Session, period: str, entity_id: int = 1) -> dict:
"""生成周报(账套隔离 2026-08-31"""
kpis = _fetch_kpi_data(db, entity_id)
monday, sunday = _calc_week_range(period)
now_str = datetime.now().strftime("%Y-%m-%d %H:%M")
@@ -2104,6 +2129,7 @@ def _build_weekly_report(db: Session, period: str) -> dict:
from datetime import timedelta
seven_days_ago = datetime.now() - timedelta(days=7)
recent_alerts = db.query(KPIAlert).filter(
KPIAlert.entity_id == entity_id,
KPIAlert.created_at >= seven_days_ago,
KPIAlert.status == "pending",
).order_by(KPIAlert.created_at.desc()).all()
@@ -2189,6 +2215,7 @@ def _build_weekly_report(db: Session, period: str) -> dict:
"## 四、改进行动",
])
actions = db.query(ActionPlan).filter(
ActionPlan.entity_id == entity_id,
ActionPlan.status.in_(["pending", "in_progress"]),
).order_by(ActionPlan.created_at.desc()).limit(5).all()
if actions:
@@ -2248,9 +2275,9 @@ def _build_weekly_report(db: Session, period: str) -> dict:
return {"markdown": markdown, "json": json_data, "title": f"经营分析周报 {monday}~{sunday}"}
def _build_monthly_report(db: Session, period: str) -> dict:
"""生成月报"""
kpis = _fetch_kpi_data(db)
def _build_monthly_report(db: Session, period: str, entity_id: int = 1) -> dict:
"""生成月报(账套隔离 2026-08-31"""
kpis = _fetch_kpi_data(db, entity_id)
now_str = datetime.now().strftime("%Y-%m-%d %H:%M")
prev_period = _get_month_period_prefix(period)
@@ -2287,6 +2314,7 @@ def _build_monthly_report(db: Session, period: str) -> dict:
# 预警汇总
pending_alerts = db.query(KPIAlert).filter(
KPIAlert.entity_id == entity_id,
KPIAlert.status == "pending",
).all()
red_count = sum(1 for a in pending_alerts if a.alert_level == "red")
@@ -2309,7 +2337,7 @@ def _build_monthly_report(db: Session, period: str) -> dict:
dim_summary[d]["failed"] += 1
# 改善行动
actions = db.query(ActionPlan).order_by(ActionPlan.created_at.desc()).limit(5).all()
actions = db.query(ActionPlan).filter(ActionPlan.entity_id == entity_id).order_by(ActionPlan.created_at.desc()).limit(5).all()
# ── 生成 Markdown ──
md_lines = [
@@ -2420,9 +2448,9 @@ def _build_monthly_report(db: Session, period: str) -> dict:
return {"markdown": markdown, "json": json_data, "title": f"经营分析月报 {period}"}
def _build_special_report(db: Session, period: str, alert_ref: str = None) -> dict:
"""生成专项分析报告 — 聚焦KPI异常"""
kpis = _fetch_kpi_data(db)
def _build_special_report(db: Session, period: str, alert_ref: str = None, entity_id: int = 1) -> dict:
"""生成专项分析报告 — 聚焦KPI异常(账套隔离 2026-08-31"""
kpis = _fetch_kpi_data(db, entity_id)
now_str = datetime.now().strftime("%Y-%m-%d %H:%M")
# 按偏差率排序(当前值/目标值)
@@ -2597,6 +2625,7 @@ def generate_report(
req: GenerateReportRequest,
db: Session = Depends(get_db),
current_user=Depends(require_auth),
entity_id: int = Depends(get_entity_id),
):
"""生成经营分析报告(周报/月报/专项),返回markdown+JSON
@@ -2614,9 +2643,9 @@ def generate_report(
# 生成报告
builders = {
"weekly": lambda db, period: _build_weekly_report(db, period),
"monthly": lambda db, period: _build_monthly_report(db, period),
"special": lambda db, period: _build_special_report(db, period, alert_ref=req.alert_ref),
"weekly": lambda db, period: _build_weekly_report(db, period, entity_id),
"monthly": lambda db, period: _build_monthly_report(db, period, entity_id),
"special": lambda db, period: _build_special_report(db, period, alert_ref=req.alert_ref, entity_id=entity_id),
}
builder = builders[req.report_type]
@@ -2626,8 +2655,9 @@ def generate_report(
logger.error(f"报告生成异常: {e}", exc_info=True)
raise HTTPException(500, f"报告生成失败: {str(e)}")
# 保存到数据库
# 保存到数据库(账套隔离 2026-08-31
record = ReportHistory(
entity_id=entity_id,
report_type=req.report_type,
period=period,
title=report_data["title"],
@@ -2672,9 +2702,10 @@ def list_report_history(
limit: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db),
current_user=Depends(require_auth),
entity_id: int = Depends(get_entity_id),
):
"""查看报告生成历史"""
query = db.query(ReportHistory).order_by(ReportHistory.created_at.desc())
"""查看报告生成历史(账套隔离 2026-08-31"""
query = db.query(ReportHistory).filter(ReportHistory.entity_id == entity_id).order_by(ReportHistory.created_at.desc())
if report_type:
query = query.filter(ReportHistory.report_type == report_type)
records = query.limit(limit).all()
@@ -2701,9 +2732,10 @@ def get_report_detail(
report_id: int,
db: Session = Depends(get_db),
current_user=Depends(require_auth),
entity_id: int = Depends(get_entity_id),
):
"""获取单条报告详情(含完整markdown内容)"""
r = db.query(ReportHistory).filter(ReportHistory.id == report_id).first()
"""获取单条报告详情(含完整markdown内容,账套隔离 2026-08-31"""
r = db.query(ReportHistory).filter(ReportHistory.id == report_id, ReportHistory.entity_id == entity_id).first()
if not r:
raise HTTPException(404, "报告不存在")
@@ -2781,7 +2813,7 @@ def _find_kpi_by_code(db: Session, kpi_code: Optional[str], entity_id: int):
).first()
def _proforma_budget(db: Session, kpi_id: int, period: str, version: Optional[str] = None):
def _proforma_budget(db: Session, kpi_id: int, period: str, version: Optional[str] = None, entity_id: int = 1):
"""预编报表预算取数:budget_plan → target_split → none
与 calc_period_deviation 口径一致(无预算时用 KPI 目标值按月分摊)。
@@ -2818,8 +2850,8 @@ def _proforma_deviation(actual: Optional[float], budget: Optional[float], ratio_
return calc_deviation(actual, budget)
def _proforma_cf_actual(db: Session, line: dict, period: str) -> Optional[float]:
"""现金流量表行项目实际值 — 真实数据优先(KPI → 凭证),不塞 demo 数据"""
def _proforma_cf_actual(db: Session, line: dict, period: str, entity_id: int = 1) -> Optional[float]:
"""现金流量表行项目实际值 — 真实数据优先(KPI → 凭证),不塞 demo 数据(账套隔离 2026-08-31"""
if line.get("kpi_code"):
v = _get_kpi_val(db, line["kpi_code"], period)
if v is not None:
@@ -2861,7 +2893,7 @@ def get_proforma_profit_statement(
for item_cfg in block_cfg["items"]:
code = item_cfg["code"]
kpi_code = PROFIT_SUBJECT_KPI_MAP.get(code)
actual = _get_subject_amount(db, code, period)
actual = _get_subject_amount(db, code, period, entity_id)
budget, source, ver = None, "none", None
kpi = _find_kpi_by_code(db, kpi_code, entity_id) if kpi_code else None
if kpi:
@@ -3053,7 +3085,7 @@ def get_proforma_cash_flow(
if sc["key"] == "operating":
op_kpi = _find_kpi_by_code(db, "F_OP_CFLOW", entity_id)
if op_kpi:
op_actual = _get_kpi_val(db, "F_OP_CFLOW", period)
op_actual = _get_kpi_val(db, "F_OP_CFLOW", period, entity_id)
if op_actual is not None:
net_actual = round(float(op_actual), 2)
op_budget, op_source, op_ver = _proforma_budget(db, op_kpi.id, period, version)