diff --git a/backend/app/api/budget.py b/backend/app/api/budget.py index 1b335872..ab82bd02 100644 --- a/backend/app/api/budget.py +++ b/backend/app/api/budget.py @@ -150,9 +150,12 @@ def update_budget_plan( plan_id: int, data: dict, db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id), ): - """更新预算计划""" - plan = db.query(BudgetPlan).filter(BudgetPlan.id == plan_id).first() + """更新预算计划(账套隔离 2026-09-21:跨账套 id → 404)""" + plan = db.query(BudgetPlan).filter( + BudgetPlan.id == plan_id, BudgetPlan.entity_id == entity_id, + ).first() if not plan: raise HTTPException(404, "预算计划不存在") @@ -173,9 +176,12 @@ def update_budget_plan( def delete_budget_plan( plan_id: int, db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id), ): - """删除预算计划""" - plan = db.query(BudgetPlan).filter(BudgetPlan.id == plan_id).first() + """删除预算计划(账套隔离 2026-09-21:跨账套 id → 404)""" + plan = db.query(BudgetPlan).filter( + BudgetPlan.id == plan_id, BudgetPlan.entity_id == entity_id, + ).first() if not plan: raise HTTPException(404, "预算计划不存在") db.delete(plan) @@ -311,7 +317,9 @@ def auto_decompose_budget( if annual_budget is None: raise HTTPException(400, "缺少必要参数: annual_budget(单KPI分解需传年度预算值)") - kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first() + kpi = db.query(KPIDefinition).filter( + KPIDefinition.id == kpi_id, KPIDefinition.entity_id == entity_id, + ).first() if not kpi: raise HTTPException(404, "KPI不存在") @@ -338,6 +346,7 @@ def auto_decompose_budget( existing = db.query(BudgetPlan).filter( BudgetPlan.kpi_id == kpi_id, + BudgetPlan.entity_id == entity_id, BudgetPlan.period == period, BudgetPlan.version == version, BudgetPlan.status == "active", @@ -347,6 +356,7 @@ def auto_decompose_budget( existing.budget_value = monthly_value else: bp = BudgetPlan( + entity_id=entity_id, kpi_id=kpi_id, period=period, budget_value=monthly_value, budget_year=year, budget_month=m, version=version, status="active", @@ -362,6 +372,7 @@ def auto_decompose_budget( period = f"{year}-{m:02d}" existing = db.query(BudgetPlan).filter( BudgetPlan.kpi_id == kpi_id, + BudgetPlan.entity_id == entity_id, BudgetPlan.period == period, BudgetPlan.version == version, BudgetPlan.status == "active", @@ -370,6 +381,7 @@ def auto_decompose_budget( existing.budget_value = monthly else: bp = BudgetPlan( + entity_id=entity_id, kpi_id=kpi_id, period=period, budget_value=monthly, budget_year=year, budget_month=m, version=version, status="active", @@ -1119,10 +1131,14 @@ def update_deviation_alert( alert_id: int, data: dict, db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id), ): - """更新偏差预警(如标记已解决)""" + """更新偏差预警(如标记已解决)(账套隔离 2026-09-21:跨账套 id → 404)""" from app.models import BudgetDeviationAlert - alert = db.query(BudgetDeviationAlert).filter(BudgetDeviationAlert.id == alert_id).first() + alert = db.query(BudgetDeviationAlert).filter( + BudgetDeviationAlert.id == alert_id, + BudgetDeviationAlert.entity_id == entity_id, + ).first() if not alert: raise HTTPException(404, "预警记录不存在") if "status" in data: diff --git a/backend/app/api/data_quality.py b/backend/app/api/data_quality.py index 9fcb2cee..92c1b187 100644 --- a/backend/app/api/data_quality.py +++ b/backend/app/api/data_quality.py @@ -177,9 +177,19 @@ def list_quality_logs( check_type: Optional[str] = None, status: Optional[str] = None, db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id), ): - """获取数据质量日志""" - query = db.query(KpiDataQualityLog) + """获取数据质量日志(账套隔离 2026-09-21) + + kpi_data_quality_log 表无 entity_id 列(见本文件 :235 说明),按本单选项(1) + 经 kpi_id join kpi_definitions 以 kpi_definitions.entity_id 收口(零迁移)。 + join 为内连接:kpi_id 无对应 KPI 行的孤儿日志无法归属账套,一律不返回。 + """ + query = ( + db.query(KpiDataQualityLog) + .join(KPIDefinition, KPIDefinition.id == KpiDataQualityLog.kpi_id) + .filter(KPIDefinition.entity_id == entity_id) + ) if kpi_id: query = query.filter(KpiDataQualityLog.kpi_id == kpi_id) if severity: @@ -201,10 +211,22 @@ def list_quality_logs( return {"data": result, "total": len(result)} +def _scoped_quality_log(db: Session, log_id: int, entity_id: int): + """按会话账套取质量日志行(表无 entity_id 列 → 经 kpi_id join kpi_definitions 归属)""" + return ( + db.query(KpiDataQualityLog) + .join(KPIDefinition, KPIDefinition.id == KpiDataQualityLog.kpi_id) + .filter(KpiDataQualityLog.id == log_id, + KPIDefinition.entity_id == entity_id) + .first() + ) + + @router.put("/logs/{log_id}") -def update_quality_log(log_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES): - """更新质量日志(解决/忽略)""" - log = db.query(KpiDataQualityLog).filter(KpiDataQualityLog.id == log_id).first() +def update_quality_log(log_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES, + entity_id: int = Depends(get_entity_id)): + """更新质量日志(解决/忽略)(账套隔离 2026-09-21:跨账套 → 404)""" + log = _scoped_quality_log(db, log_id, entity_id) if not log: raise HTTPException(404, "日志不存在") if "status" in data: @@ -218,14 +240,18 @@ def update_quality_log(log_id: int, data: dict, db: Session = Depends(get_db), u @router.delete("/logs/{log_id}") -def delete_quality_log(log_id: int, db: Session = Depends(get_db), user=WRITE_ROLES): - log = db.query(KpiDataQualityLog).filter(KpiDataQualityLog.id == log_id).first() - if log: - db.delete(log) - db.commit() +def delete_quality_log(log_id: int, db: Session = Depends(get_db), user=WRITE_ROLES, + entity_id: int = Depends(get_entity_id)): + """删除质量日志(账套隔离 2026-09-21:跨账套 → 404;不存在 → 404,对齐 PUT 语义)""" + log = _scoped_quality_log(db, log_id, entity_id) + if not log: + raise HTTPException(404, "日志不存在") + db.delete(log) + db.commit() return {"message": "已删除"} + # ============================================================ # 数据质量看板统计 # ============================================================ diff --git a/backend/app/api/expenses.py b/backend/app/api/expenses.py index 89d9507d..4123a1df 100644 --- a/backend/app/api/expenses.py +++ b/backend/app/api/expenses.py @@ -404,8 +404,9 @@ def list_reimbursements( entity_id: int = Depends(get_entity_id), _=Depends(require_auth), ): - """查询报销单列表(支持状态/类型/申请人/关键字筛选)""" - q = db.query(ExpenseReimbursement) + """查询报销单列表(支持状态/类型/申请人/关键字筛选)(账套隔离 2026-09-21:列表按会话账套)""" + q = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.entity_id == entity_id) + if status: q = q.filter(ExpenseReimbursement.status == status) if expense_type: @@ -427,20 +428,29 @@ def list_reimbursements( def get_reimbursement( reimb_id: int, db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id), _=Depends(require_auth), ): - """报销单详情""" - r = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.id == reimb_id).first() + """报销单详情(账套隔离 2026-09-21:跨账套 → 404)""" + r = db.query(ExpenseReimbursement).filter( + ExpenseReimbursement.id == reimb_id, + ExpenseReimbursement.entity_id == entity_id, + ).first() if not r: raise HTTPException(404, "报销单不存在") return _reimb_to_dict(r) -def _do_approve(db: Session, reimb_id: int, action: str, comment: str, approver: str): - """执行审批动作: approve/reject/return""" - r = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.id == reimb_id).first() +def _do_approve(db: Session, reimb_id: int, action: str, comment: str, approver: str, + entity_id: int = None): + """执行审批动作: approve/reject/return(账套隔离 2026-09-21:跨账套 → 404)""" + q = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.id == reimb_id) + if entity_id is not None: + q = q.filter(ExpenseReimbursement.entity_id == entity_id) + r = q.first() if not r: raise HTTPException(404, "报销单不存在") + if r.status not in ("pending", "returned"): raise HTTPException(400, f"当前状态({r.status})不可审批") @@ -464,10 +474,11 @@ def approve_reimbursement( data: dict = None, db: Session = Depends(get_db), current_user=Depends(require_role("ceo", "finance")), + entity_id: int = Depends(get_entity_id), ): - """审批通过""" + """审批通过(账套隔离 2026-09-21:跨账套 → 404)""" approver = current_user.name if hasattr(current_user, "name") else current_user.username - r = _do_approve(db, reimb_id, "approve", (data or {}).get("comment", ""), approver) + r = _do_approve(db, reimb_id, "approve", (data or {}).get("comment", ""), approver, entity_id) return {"message": "已审批通过", "data": _reimb_to_dict(r)} @@ -477,13 +488,14 @@ def reject_reimbursement( data: dict = None, db: Session = Depends(get_db), current_user=Depends(require_role("ceo", "finance")), + entity_id: int = Depends(get_entity_id), ): - """审批拒绝""" + """审批拒绝(账套隔离 2026-09-21:跨账套 → 404)""" comment = (data or {}).get("comment", "") if not comment: raise HTTPException(400, "拒绝时必须填写审批意见") approver = current_user.name if hasattr(current_user, "name") else current_user.username - r = _do_approve(db, reimb_id, "reject", comment, approver) + r = _do_approve(db, reimb_id, "reject", comment, approver, entity_id) return {"message": "已拒绝", "data": _reimb_to_dict(r)} @@ -493,10 +505,11 @@ def return_reimbursement( data: dict = None, db: Session = Depends(get_db), current_user=Depends(require_role("ceo", "finance")), + entity_id: int = Depends(get_entity_id), ): - """人工打回""" + """人工打回(账套隔离 2026-09-21:跨账套 → 404)""" approver = current_user.name if hasattr(current_user, "name") else current_user.username - r = _do_approve(db, reimb_id, "return", (data or {}).get("comment", "人工打回"), approver) + r = _do_approve(db, reimb_id, "return", (data or {}).get("comment", "人工打回"), approver, entity_id) return {"message": "已打回", "data": _reimb_to_dict(r)} diff --git a/backend/app/api/kpis.py b/backend/app/api/kpis.py index b9000a8a..be5fdc11 100644 --- a/backend/app/api/kpis.py +++ b/backend/app/api/kpis.py @@ -918,9 +918,16 @@ def _log(db, user_id, action, target_type, target_id, detail): @router.get("/{kpi_id}/objectives") -def get_kpi_objectives(kpi_id: int, db: Session = Depends(get_db)): - """查看KPI所属的目标和战略地图""" - kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first() +def get_kpi_objectives(kpi_id: int, db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id)): + """查看KPI所属的目标和战略地图(账套隔离 2026-09-21:跨账套 id → 404) + + 鉴权口径:本 router 级 `dependencies=[Depends(require_role(...))]` 已覆盖本端点 + (无 token → 401),此处不再重复挂 require_auth,只补缺失的账套收口。 + """ + kpi = db.query(KPIDefinition).filter( + KPIDefinition.id == kpi_id, KPIDefinition.entity_id == entity_id, + ).first() if not kpi: raise HTTPException(404, "KPI不存在") @@ -933,15 +940,19 @@ def get_kpi_objectives(kpi_id: int, db: Session = Depends(get_db)): } if kpi.map_id: - m = db.query(StrategicMap).filter(StrategicMap.id == kpi.map_id).first() + m = db.query(StrategicMap).filter( + StrategicMap.id == kpi.map_id, StrategicMap.entity_id == entity_id, + ).first() if m: result["map"] = {"id": m.id, "title": m.title, "status": m.status} if kpi.objective: objs = db.query(MapObjective).filter( MapObjective.map_id == kpi.map_id, + MapObjective.entity_id == entity_id, MapObjective.name == kpi.objective, ).all() result["objectives"] = [{"id": o.id, "name": o.name, "dimension_key": o.dimension_key} for o in objs] return result + diff --git a/backend/app/api/predict.py b/backend/app/api/predict.py index d1371a23..87c26968 100644 --- a/backend/app/api/predict.py +++ b/backend/app/api/predict.py @@ -10,7 +10,7 @@ from app.utils.cash_forecast_engine import ( calculate_accuracy, generate_scenario_suggestion, ) from app.database import get_db -from app.deps import get_entity_id, resolve_entity_for_request +from app.deps import get_entity_id, resolve_entity_for_request, assert_entity_scope from app.auth_middleware import require_auth_401 from sqlalchemy.orm import Session @@ -724,95 +724,182 @@ def _get_dim_detail_indicators(dimension: str, entity: dict) -> list: return indicators +# ── 增长质量诊断取数(账套隔离 2026-09-21)──────────────────────── +# 输入 ← 库内 KPI 实际值(kpi_values),编码口径沿用 kpi_definitions 既有编码 +# (同 app/api/growth_quality.py 的 KPI_CODES),不新增计算口径。 +_GQ_KPI_CODE_MAP = { + "C_REBATE_RATE": "rebateRate", # 渠补率(%) + "F_GROSS_MARGIN": "trueGrossMargin", # 毛利率(%) + "F_REVENUE_GROWTH": "revenueGrowthRate", # 收入增长率(%) +} + +_GQ_DIM_SCORERS = { + "revenueStructure": _score_revenue_structure, + "profitStructure": _score_profit_structure, + "cashAssets": _score_cash_assets, + "growthDriver": _score_growth_driver, + "orgEfficiency": _score_org_efficiency, +} + +# 各维度所需输入键。缺任一输入 → 该维度不评分(避免用缺省 0 编造低分) +_GQ_DIM_INPUTS = { + "revenueStructure": ("rebateRate",), + "profitStructure": ("trueGrossMargin",), + "cashAssets": ("cashRatio",), + "growthDriver": ("expenseGrowthRate", "revenueGrowthRate"), + "orgEfficiency": ("mgmtRatio",), +} + + +def _growth_quality_entity_from_db(db: Session, entity_id: int, period=None): + """按会话账套从库内 KPI 实际值取增长质量输入。 + + 返回 (entity_dict|None, collected): + - entity_dict 只含库内真实取到的指标(缺的键不填,绝不用请求体/硬编码兜底) + - collected = 本账套是否存在增长质量相关 KPI 实际值 + """ + from app.models import Entity, KPIDefinition, KPIValue + + ent = db.query(Entity).filter(Entity.id == entity_id).first() + if not ent: + return None, False + q = ( + db.query(KPIDefinition.kpi_code, KPIValue.actual_value, KPIValue.period) + .join(KPIValue, KPIValue.kpi_id == KPIDefinition.id) + .filter(KPIDefinition.entity_id == entity_id, + KPIDefinition.kpi_code.in_(list(_GQ_KPI_CODE_MAP.keys())), + KPIValue.actual_value.isnot(None)) + ) + if period: + q = q.filter(KPIValue.period == period) + rows = q.order_by(KPIValue.period.desc()).all() + + entity = {"entity": ent.name} + seen = set() + for code, value, _period in rows: + if code in seen: + continue + seen.add(code) + entity[_GQ_KPI_CODE_MAP[code]] = float(value) + return entity, bool(seen) + + +def _growth_quality_score(entity: dict): + """计算「有输入的维度」评分 + 明细 + 缺失维度清单(缺输入的维度不评分)。""" + scores, detail, missing = {}, {}, [] + for dim, inputs in _GQ_DIM_INPUTS.items(): + if any(k not in entity for k in inputs): + missing.append(dim) + detail[dim] = {"score": None, "data_missing": True, + "indicators": [], "suggestions": []} + continue + score = _GQ_DIM_SCORERS[dim](entity) + scores[dim] = score + detail[dim] = { + "score": score, + "indicators": _get_dim_detail_indicators(dim, entity), + "suggestions": _generate_improvement_suggestions(dim, score, entity), + } + return scores, detail, missing + + +def _gq_level_of(overall: float): + if overall is None: + return None, None + if overall >= 4: + return "🟢 越增长越轻", "excellent" + if overall >= 3: + return "🟡 增长质量中等", "medium" + if overall >= 2: + return "🟠 需关注", "warning" + return "🔴 越增长越重", "danger" + + @router.post("/growth-quality") -def api_growth_quality(request: Request, data: dict): - """增长质量诊断 — 五维度评分+综合评分+诊断结论""" +def api_growth_quality(request: Request, data: dict, db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id)): + """增长质量诊断 — 五维度评分+综合评分+诊断结论(账套隔离 2026-09-21) + + 取数:按会话账套从库内 KPI 实际值取,移除请求体 `data.get("entity", data)` 回退 + (原回退可用客户端可控的「经营数据」伪造诊断结论);库内无数据时如实返回 + collected=false + 空维度,不编造评分。请求体显式 entity_id ≠ 会话账套 → 403。 + """ try: - entity_id = resolve_entity_for_request(request, data.get("entity_id")) - ENTITY_DATA = { - 1: {"entity":"陕西酣客文化传媒","rebateRate":82.8,"trueGrossMargin":18.6,"cashRatio":0.6,"expenseGrowthRate":2.2,"revenueGrowthRate":1.0,"mgmtRatio":447}, - 2: {"entity":"陕西博海网络科技","rebateRate":0,"trueGrossMargin":13.1,"cashRatio":6.7,"expenseGrowthRate":0.8,"revenueGrowthRate":1.0,"mgmtRatio":1.4}, - } - entity = ENTITY_DATA.get(entity_id, data.get("entity", data)) - entity_name = entity.get("entity", entity.get("name", "该企业")) - period = entity.get("period", data.get("period", "当前")) + data = data or {} + # 显式 body entity_id 与会话账套不一致 → 403(文案同 app/deps.py) + assert_entity_scope(request, data.get("entity_id"), db) + period = data.get("period") + entity, collected = _growth_quality_entity_from_db(db, entity_id, period) + if entity is None: + raise HTTPException(404, f"实体 {entity_id} 不存在") + entity_name = entity.get("entity", "该企业") + period_label = period or "当前" - # 五维度评分 - dim_scores = { - "revenueStructure": _score_revenue_structure(entity), - "profitStructure": _score_profit_structure(entity), - "cashAssets": _score_cash_assets(entity), - "growthDriver": _score_growth_driver(entity), - "orgEfficiency": _score_org_efficiency(entity), - } - - overall = round(sum(dim_scores.values()) / 5, 1) - - # 综合等级 - if overall >= 4: - level = "🟢 越增长越轻" - level_type = "excellent" - elif overall >= 3: - level = "🟡 增长质量中等" - level_type = "medium" - elif overall >= 2: - level = "🟠 需关注" - level_type = "warning" - else: - level = "🔴 越增长越重" - level_type = "danger" - - # 诊断结论 - dimensions_payload = {} - detail_payload = {} - for dim, score in dim_scores.items(): - dimensions_payload[dim] = {"score": score, "weight": 20} - detail_payload[dim] = { - "score": score, - "indicators": _get_dim_detail_indicators(dim, entity), - "suggestions": _generate_improvement_suggestions(dim, score, entity), + if not collected: + return { + "entity_id": entity_id, + "entity_name": entity_name, + "period": period_label, + "collected": False, + "data_source": "kpi_values", + "message": "该账套暂无增长质量相关 KPI 实际值(无数据不评分)", + "overall": None, + "level": None, + "level_type": None, + "dimensions": {}, + "detail": {}, + "dimensions_missing": list(_GQ_DIM_INPUTS.keys()), + "missing_metrics": sorted(set(_GQ_KPI_CODE_MAP.values())), + "diagnosis": None, + "compare": None, } - diagnosis = _diagnosis_text(overall, dimensions_payload, entity_name) + dim_scores, detail_payload, dims_missing = _growth_quality_score(entity) + dimensions_payload = {dim: {"score": s, "weight": 20} for dim, s in dim_scores.items()} + overall = round(sum(dim_scores.values()) / len(dim_scores), 1) if dim_scores else None + level, level_type = _gq_level_of(overall) + diagnosis = _diagnosis_text(overall, dimensions_payload, entity_name) if overall is not None else None - # 对比数据(如果请求中包含多个实体) - compare = data.get("compare", None) + # 对比:只接受账套内 compare_entity_id 并从库内取数(原请求体 compare 数据不再采信) compare_result = None - if compare: - compare_entity = compare - compare_name = compare_entity.get("entity", compare_entity.get("name", "对比企业")) - cdims = { - "revenueStructure": _score_revenue_structure(compare_entity), - "profitStructure": _score_profit_structure(compare_entity), - "cashAssets": _score_cash_assets(compare_entity), - "growthDriver": _score_growth_driver(compare_entity), - "orgEfficiency": _score_org_efficiency(compare_entity), - } - compare_overall = round(sum(cdims.values()) / 5, 1) - compare_result = { - "entity_name": compare_name, - "overall": compare_overall, - "dimensions": {k: {"score": v, "weight": 20} for k, v in cdims.items()}, - "level": ("🟢 越增长越轻" if compare_overall >= 4 else - "🟡 增长质量中等" if compare_overall >= 3 else - "🟠 需关注" if compare_overall >= 2 else "🔴 越增长越重"), - } + compare_entity_id = data.get("compare_entity_id") + if compare_entity_id: + assert_entity_scope(request, int(compare_entity_id), db) + c_entity, c_collected = _growth_quality_entity_from_db(db, int(compare_entity_id), period) + if c_entity is not None and c_collected: + cdims, _c_detail, _c_missing = _growth_quality_score(c_entity) + if cdims: + c_overall = round(sum(cdims.values()) / len(cdims), 1) + compare_result = { + "entity_id": int(compare_entity_id), + "entity_name": c_entity.get("entity", "对比企业"), + "overall": c_overall, + "dimensions": {k: {"score": v, "weight": 20} for k, v in cdims.items()}, + "level": _gq_level_of(c_overall)[0], + } return { + "entity_id": entity_id, "entity_name": entity_name, - "period": period, + "period": period_label, + "collected": True, + "data_source": "kpi_values", "overall": overall, "level": level, "level_type": level_type, "dimensions": dimensions_payload, "detail": detail_payload, + "dimensions_missing": dims_missing, "diagnosis": diagnosis, "compare": compare_result, } + except HTTPException: + raise except Exception as e: raise HTTPException(400, f"增长质量诊断失败: {str(e)}") + # ── KPI趋势预测(预测性成本智能 MVP) ──────────────────────────── from app.utils.kpi_forecast_engine import ( # noqa: E402 MODELS, forecast_kpi, forecast_finance_kpis, diff --git a/backend/app/api/tax_compliance.py b/backend/app/api/tax_compliance.py index 06bee411..d2d21cb8 100644 --- a/backend/app/api/tax_compliance.py +++ b/backend/app/api/tax_compliance.py @@ -831,7 +831,7 @@ def seed_demo_data( exists = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.reimb_no == d["reimb_no"]).first() if exists: continue - db.add(ExpenseReimbursement(**d)) + db.add(ExpenseReimbursement(entity_id=eid, **d)) # 发票: 正常2张(金额匹配) + 格式错误1张 + 金额不符1张 + 供应商与合同不符1张 invoice_demo = [ @@ -854,7 +854,11 @@ def seed_demo_data( ) if exists: continue - inv = InvoiceCheck(entity_id=eid, **d) + inv_data = dict(d) + # 演示日期按既有写入口径解析为 datetime(字符串日期在 SQLite 下无法绑定 DateTime 列; + # MySQL 表现不变——同文件 :402 正常写入路径亦经 _parse_date) + inv_data["invoice_date"] = _parse_date(inv_data.get("invoice_date")) + inv = InvoiceCheck(entity_id=eid, **inv_data) _check_invoice(db, inv) db.add(inv) created["invoice"] += 1 diff --git a/backend/tests/test_cross_entity_isolation_batch5_20260921.py b/backend/tests/test_cross_entity_isolation_batch5_20260921.py new file mode 100644 index 00000000..c5d5a4fb --- /dev/null +++ b/backend/tests/test_cross_entity_isolation_batch5_20260921.py @@ -0,0 +1,625 @@ +"""CMA 跨实体隔离残余收口(第五批)账套隔离回归测试 — 2026-09-21 + +任务:project-20260921-cma-entity-isolation-batch5(项目Bot 派单; + 来源 = batch4 执行中反向推送的残余单 fullstack-20260921-batch5-residual-entity-isolation.md) + +本单范围(逐条): + P0 budget.py update_budget_plan / delete_budget_plan / update_deviation_alert + 无账套校验 → 本账套会话可改/删他企预算与预警(跨租户写) + P1 kpis.py get_kpi_objectives 无账套收口(KPI/StrategicMap/MapObjective 按 id 直取) + P1 data_quality.py 质量日志族 GET/PUT/DELETE /logs 无账套收口(表无 entity_id 列, + 按选项(1) 经 kpi_id join kpi_definitions 按 kpi_definitions.entity_id 收口) + P2 budget.py auto_decompose 单 KPI 分支不写 entity_id(落默认 1) + P2 predict.py growth-quality 硬编码 ENTITY_DATA + 请求体 data.get("entity", data) 回退 + P2 expenses.py 列表假隔离 + 详情/审批无归属校验;tax_compliance.py 演示播种不写 entity_id + +每条均「跨账套负例 + 本账套正对照」。改前 RED 原文见回报 §三。 +本文件只跑内存 SQLite(tests/conftest.py 机制),零生产库 / 零生产 Redis 连接。 +""" +from datetime import datetime + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from app.models import ( + BudgetDeviationAlert, + BudgetPlan, + Entity, + ExpenseReimbursement, + KPIDefinition, + KPIValue, + KpiDataQualityLog, + MapObjective, + StrategicMap, +) +from tests.conftest import TEST_USER_PASSWORD, auth_header, create_test_user + +BUDGET = "/api/cma/budget" +KPIS = "/api/cma/kpis" +DQ = "/api/cma/data-quality" +PREDICT = "/api/cma/predict" +EXPENSES = "/api/cma/expenses" +TAX = "/api/cma/tax" + +# 两账套资源编码前缀(kpi_code 库内唯一) +E1_KPI = "ZZ_B5_E1_KPI_001" +E2_KPI = "ZZ_B5_E2_KPI_001" +E1_KPI_TREE = "ZZ_B5_E1_KPI_TREE" +E1_KPI_OTHER_MAP = "ZZ_B5_E1_KPI_XMAP" +E1_GQ_KPI = "ZZ_B5_E1_GQ" +E2_GQ_KPI = "ZZ_B5_E2_GQ" + + +# ────────────────────────────────────────────── +# 夹具 +# ────────────────────────────────────────────── + +def _ensure_entity(db: Session, eid: int, name: str) -> Entity: + ent = db.query(Entity).filter(Entity.id == eid).first() + if not ent: + ent = Entity(id=eid, name=name, short_name=name, status="active") + db.add(ent) + db.commit() + db.refresh(ent) + return ent + + +def _mk_kpi(db: Session, code: str, eid: int, **kw) -> KPIDefinition: + kpi = KPIDefinition( + entity_id=eid, + kpi_code=code, + kpi_name=f"{code}-名称", + dimension="finance", + category="revenue_growth", + formula="-", + data_source_type="excel", + data_source="Excel导入", + data_owner="财务部", + frequency="monthly", + unit="元", + target_value=0, + kpi_level="operational", + status="active", + **kw, + ) + db.add(kpi) + db.commit() + db.refresh(kpi) + return kpi + + +def _mk_plan(db: Session, kpi_id: int, eid: int, period: str = "2026-09", + value: float = 100.0, status: str = "active") -> BudgetPlan: + bp = BudgetPlan(entity_id=eid, kpi_id=kpi_id, period=period, budget_value=value, + budget_year=2026, budget_month=9, version="v1.0", status=status) + db.add(bp) + db.commit() + db.refresh(bp) + return bp + + +def _mk_alert(db: Session, kpi_id: int, eid: int, status: str = "open") -> BudgetDeviationAlert: + a = BudgetDeviationAlert(entity_id=eid, kpi_id=kpi_id, period="2026-09", + budget_value=100.0, actual_value=130.0, + deviation_rate=30.0, deviation_value=30.0, + alert_level="warning", status=status) + db.add(a) + db.commit() + db.refresh(a) + return a + + +def _mk_map(db: Session, eid: int, title: str) -> StrategicMap: + m = StrategicMap(entity_id=eid, title=title, status="draft", + dimensions=[], canvas_data={"connections": []}) + db.add(m) + db.commit() + db.refresh(m) + return m + + +def _mk_objective(db: Session, eid: int, map_id: int, name: str, dimension: str = "finance") -> MapObjective: + o = MapObjective(entity_id=eid, map_id=map_id, dimension_key=dimension, name=name) + db.add(o) + db.commit() + db.refresh(o) + return o + + +def _mk_log(db: Session, kpi_id: int, status: str = "open") -> KpiDataQualityLog: + lg = KpiDataQualityLog(kpi_id=kpi_id, check_type="flat_data", severity="warning", + detail={"note": "batch5"}, suggestion="检查采集", status=status) + db.add(lg) + db.commit() + db.refresh(lg) + return lg + + +def _mk_reimb(db: Session, eid: int, no: str, amount: float = 100.0, + status: str = "pending") -> ExpenseReimbursement: + r = ExpenseReimbursement(entity_id=eid, reimb_no=no, applicant="张三", department="财务部", + expense_type="office", title=f"{no}-报销", amount=amount, + expense_date=datetime(2026, 9, 10), status=status, + check_result="pass") + db.add(r) + db.commit() + db.refresh(r) + return r + + +def _login(client: TestClient, entity_id: int) -> str: + resp = client.post("/api/cma/auth/login", json={ + "username": "testadmin", + "password": TEST_USER_PASSWORD, + "entity_id": entity_id, + }) + assert resp.status_code == 200, f"登录 entity{entity_id} 失败: {resp.text[:300]}" + return resp.json()["token"] + + +def _seed_two_entities(db: Session): + _ensure_entity(db, 1, "测试企业1") + _ensure_entity(db, 2, "测试企业2") + create_test_user(db) + + +# ────────────────────────────────────────────── +# P0. budget.py 预算/预警写端点按账套(跨账套 404) +# ────────────────────────────────────────────── + +def test_budget_update_plan_cross_entity_404(client: TestClient, db: Session): + """e1 会话改 e2 预算计划 → 404 且对端计划未被改动""" + _seed_two_entities(db) + k2 = _mk_kpi(db, E2_KPI, 2) + other = _mk_plan(db, k2.id, 2, value=50.0) + token1 = _login(client, 1) + + r = client.put(f"{BUDGET}/plans/{other.id}", headers=auth_header(token1), + json={"budget_value": 999.0, "status": "inactive"}) + assert r.status_code == 404, f"跨账套改预算应 404,实际 {r.status_code} {r.text[:200]}" + + db.expire_all() + row = db.query(BudgetPlan).filter(BudgetPlan.id == other.id).first() + assert row is not None, "对端预算行被删除" + assert row.budget_value == 50.0, f"跨账套改预算生效了:budget_value={row.budget_value}" + assert row.status == "active", f"跨账套改了 status={row.status}" + + +def test_budget_update_plan_own_entity_ok(client: TestClient, db: Session): + """本账套改自家预算计划 → 200 且落库""" + _seed_two_entities(db) + k1 = _mk_kpi(db, E1_KPI, 1) + mine = _mk_plan(db, k1.id, 1, value=50.0) + token1 = _login(client, 1) + + r = client.put(f"{BUDGET}/plans/{mine.id}", headers=auth_header(token1), + json={"budget_value": 88.0}) + assert r.status_code == 200, r.text[:200] + db.expire_all() + assert db.query(BudgetPlan).filter(BudgetPlan.id == mine.id).first().budget_value == 88.0 + + +def test_budget_delete_plan_cross_entity_404(client: TestClient, db: Session): + """e1 会话删 e2 预算计划 → 404 且对端计划仍在""" + _seed_two_entities(db) + k2 = _mk_kpi(db, E2_KPI, 2) + other = _mk_plan(db, k2.id, 2) + token1 = _login(client, 1) + + r = client.delete(f"{BUDGET}/plans/{other.id}", headers=auth_header(token1)) + assert r.status_code == 404, f"跨账套删预算应 404,实际 {r.status_code} {r.text[:200]}" + + db.expire_all() + assert db.query(BudgetPlan).filter(BudgetPlan.id == other.id).first() is not None, \ + "跨账套删除竟生效(对端预算行已消失)" + + +def test_budget_delete_plan_own_entity_ok(client: TestClient, db: Session): + """本账套删自家预算计划 → 200 且行消失""" + _seed_two_entities(db) + k1 = _mk_kpi(db, E1_KPI, 1) + mine = _mk_plan(db, k1.id, 1) + token1 = _login(client, 1) + + r = client.delete(f"{BUDGET}/plans/{mine.id}", headers=auth_header(token1)) + assert r.status_code == 200, r.text[:200] + db.expire_all() + assert db.query(BudgetPlan).filter(BudgetPlan.id == mine.id).first() is None + + +def test_budget_update_deviation_alert_cross_entity_404(client: TestClient, db: Session): + """e1 会话改 e2 偏差预警 → 404 且对端预警状态未变""" + _seed_two_entities(db) + k2 = _mk_kpi(db, E2_KPI, 2) + other = _mk_alert(db, k2.id, 2) + token1 = _login(client, 1) + + r = client.put(f"{BUDGET}/deviation-alerts/{other.id}", headers=auth_header(token1), + json={"status": "resolved"}) + assert r.status_code == 404, f"跨账套改预警应 404,实际 {r.status_code} {r.text[:200]}" + + db.expire_all() + row = db.query(BudgetDeviationAlert).filter(BudgetDeviationAlert.id == other.id).first() + assert row is not None, "对端预警行被删除" + assert row.status == "open", f"跨账套改预警生效了:status={row.status}" + + +def test_budget_update_deviation_alert_own_entity_ok(client: TestClient, db: Session): + """本账套改自家预警 → 200 且落库""" + _seed_two_entities(db) + k1 = _mk_kpi(db, E1_KPI, 1) + mine = _mk_alert(db, k1.id, 1) + token1 = _login(client, 1) + + r = client.put(f"{BUDGET}/deviation-alerts/{mine.id}", headers=auth_header(token1), + json={"status": "resolved"}) + assert r.status_code == 200, r.text[:200] + db.expire_all() + assert db.query(BudgetDeviationAlert).filter( + BudgetDeviationAlert.id == mine.id).first().status == "resolved" + + +# ────────────────────────────────────────────── +# P1. kpis.py get_kpi_objectives 账套收口 +# ────────────────────────────────────────────── + +def test_kpi_objectives_requires_auth(client: TestClient, db: Session): + """无 token → 401(鉴权面口径)""" + _seed_two_entities(db) + k1 = _mk_kpi(db, E1_KPI_TREE, 1) + + r = client.get(f"{KPIS}/{k1.id}/objectives") + assert r.status_code == 401, f"未鉴权访问 KPI 目标应 401,实际 {r.status_code} {r.text[:200]}" + + +def test_kpi_objectives_cross_entity_404(client: TestClient, db: Session): + """e1 会话取 e2 KPI 的目标 → 404""" + _seed_two_entities(db) + k2 = _mk_kpi(db, E2_KPI, 2) + token1 = _login(client, 1) + + r = client.get(f"{KPIS}/{k2.id}/objectives", headers=auth_header(token1)) + assert r.status_code == 404, f"跨账套取 KPI 目标应 404,实际 {r.status_code} {r.text[:200]}" + + +def test_kpi_objectives_own_entity_ok(client: TestClient, db: Session): + """本账套 KPI 的目标/地图正常返回(正对照)""" + _seed_two_entities(db) + m1 = _mk_map(db, 1, "e1地图") + _mk_objective(db, 1, m1.id, "提升收入") + k1 = _mk_kpi(db, E1_KPI_TREE, 1, map_id=m1.id, objective="提升收入") + token1 = _login(client, 1) + + r = client.get(f"{KPIS}/{k1.id}/objectives", headers=auth_header(token1)) + assert r.status_code == 200, r.text[:300] + body = r.json() + assert body["map"] and body["map"]["id"] == m1.id, f"本账套地图未返回: {body}" + assert len(body["objectives"]) == 1 and body["objectives"][0]["name"] == "提升收入" + + +def test_kpi_objectives_map_and_objectives_scoped_by_entity(client: TestClient, db: Session): + """本账套 KPI 挂了他账套地图/目标 → 不得带出他账套地图与目标""" + _seed_two_entities(db) + m2 = _mk_map(db, 2, "e2地图") + _mk_objective(db, 2, m2.id, "e2目标") + # e1 的 KPI,但 map_id 指向 e2 地图(历史脏挂)→ 不应带出 e2 地图/目标 + k1 = _mk_kpi(db, E1_KPI_OTHER_MAP, 1, map_id=m2.id, objective="e2目标") + token1 = _login(client, 1) + + r = client.get(f"{KPIS}/{k1.id}/objectives", headers=auth_header(token1)) + assert r.status_code == 200, r.text[:300] + body = r.json() + assert not body.get("map"), f"带出了他账套战略地图: {body.get('map')}" + assert body["objectives"] == [], f"带出了他账套战略目标: {body['objectives']}" + + +# ────────────────────────────────────────────── +# P1. data_quality.py 质量日志族按 KPI 归属收口 +# ────────────────────────────────────────────── + +def test_quality_logs_list_cross_entity_invisible(client: TestClient, db: Session): + """e1 会话列表看不到 e2 KPI 的质量日志;e2 会话看得到自己的""" + _seed_two_entities(db) + k1 = _mk_kpi(db, E1_KPI, 1) + k2 = _mk_kpi(db, E2_KPI, 2) + mine = _mk_log(db, k1.id) + other = _mk_log(db, k2.id) + token1 = _login(client, 1) + token2 = _login(client, 2) + + r1 = client.get(f"{DQ}/logs", headers=auth_header(token1)) + assert r1.status_code == 200, r1.text[:200] + ids1 = {x["id"] for x in r1.json()["data"]} + assert other.id not in ids1, f"e1 会话看到 e2 账套质量日志(id={other.id})" + assert mine.id in ids1, f"e1 会话未看到本账套日志(id={mine.id})" + + r2 = client.get(f"{DQ}/logs", headers=auth_header(token2)) + ids2 = {x["id"] for x in r2.json()["data"]} + assert other.id in ids2, "e2 会话看不到本账套日志" + + +def test_quality_logs_list_kpi_filter_cross_entity_empty(client: TestClient, db: Session): + """e1 会话用他账套 kpi_id 过滤 → 空结果""" + _seed_two_entities(db) + k2 = _mk_kpi(db, E2_KPI, 2) + other = _mk_log(db, k2.id) + token1 = _login(client, 1) + + r = client.get(f"{DQ}/logs?kpi_id={k2.id}", headers=auth_header(token1)) + assert r.status_code == 200, r.text[:200] + assert {x["id"] for x in r.json()["data"]} == set(), \ + f"按他账套 kpi_id 过滤竟返回日志: {r.json()['data']}" + + # 同一过滤器在 e2 会话下命中(正对照,证明过滤本身有效) + token2 = _login(client, 2) + r2 = client.get(f"{DQ}/logs?kpi_id={k2.id}", headers=auth_header(token2)) + assert other.id in {x["id"] for x in r2.json()["data"]} + + +def test_quality_log_update_cross_entity_404(client: TestClient, db: Session): + """e1 会话改 e2 KPI 的质量日志 → 404 且状态未变""" + _seed_two_entities(db) + k2 = _mk_kpi(db, E2_KPI, 2) + other = _mk_log(db, k2.id) + token1 = _login(client, 1) + + r = client.put(f"{DQ}/logs/{other.id}", headers=auth_header(token1), + json={"status": "resolved"}) + assert r.status_code == 404, f"跨账套改质量日志应 404,实际 {r.status_code} {r.text[:200]}" + + db.expire_all() + row = db.query(KpiDataQualityLog).filter(KpiDataQualityLog.id == other.id).first() + assert row is not None and row.status == "open", f"跨账套改日志生效了:{row and row.status}" + + +def test_quality_log_delete_cross_entity_404(client: TestClient, db: Session): + """e1 会话删 e2 KPI 的质量日志 → 404 且行仍在""" + _seed_two_entities(db) + k2 = _mk_kpi(db, E2_KPI, 2) + other = _mk_log(db, k2.id) + token1 = _login(client, 1) + + r = client.delete(f"{DQ}/logs/{other.id}", headers=auth_header(token1)) + assert r.status_code == 404, f"跨账套删质量日志应 404,实际 {r.status_code} {r.text[:200]}" + + db.expire_all() + assert db.query(KpiDataQualityLog).filter(KpiDataQualityLog.id == other.id).first() is not None, \ + "跨账套删除竟生效(对端日志行已消失)" + + +def test_quality_log_update_delete_own_entity_ok(client: TestClient, db: Session): + """本账套改/删自家质量日志 → 200(正对照)""" + _seed_two_entities(db) + k1 = _mk_kpi(db, E1_KPI, 1) + a = _mk_log(db, k1.id) + b = _mk_log(db, k1.id) + token1 = _login(client, 1) + + r = client.put(f"{DQ}/logs/{a.id}", headers=auth_header(token1), json={"status": "resolved"}) + assert r.status_code == 200, r.text[:200] + db.expire_all() + assert db.query(KpiDataQualityLog).filter(KpiDataQualityLog.id == a.id).first().status == "resolved" + + r2 = client.delete(f"{DQ}/logs/{b.id}", headers=auth_header(token1)) + assert r2.status_code == 200, r2.text[:200] + db.expire_all() + assert db.query(KpiDataQualityLog).filter(KpiDataQualityLog.id == b.id).first() is None + + +# ────────────────────────────────────────────── +# P2. budget.py auto_decompose 单 KPI 分支账套归属 +# ────────────────────────────────────────────── + +def test_auto_decompose_single_kpi_writes_session_entity(client: TestClient, db: Session): + """e2 会话单 KPI 分解 → 12 行月度预算归属 e2(改前落默认 1)""" + _seed_two_entities(db) + k2 = _mk_kpi(db, E2_KPI, 2) + token2 = _login(client, 2) + + r = client.post(f"{BUDGET}/auto-decompose", headers=auth_header(token2), + json={"kpi_id": k2.id, "annual_budget": 120000, "year": 2026, "method": "equal"}) + assert r.status_code == 200, r.text[:300] + + db.expire_all() + rows = db.query(BudgetPlan).filter(BudgetPlan.kpi_id == k2.id).all() + assert len(rows) == 12, f"应生成 12 行月度预算,实际 {len(rows)}" + bad = [x.id for x in rows if x.entity_id != 2] + assert not bad, f"单 KPI 分支未写会话账套 entity_id(错归属行 id={bad})" + + +def test_auto_decompose_single_kpi_own_entity_ok(client: TestClient, db: Session): + """e1 会话单 KPI 分解 → 200 且 12 行归属 e1(正对照)""" + _seed_two_entities(db) + k1 = _mk_kpi(db, E1_KPI, 1) + token1 = _login(client, 1) + + r = client.post(f"{BUDGET}/auto-decompose", headers=auth_header(token1), + json={"kpi_id": k1.id, "annual_budget": 120000, "year": 2026, "method": "equal"}) + assert r.status_code == 200, r.text[:300] + db.expire_all() + rows = db.query(BudgetPlan).filter(BudgetPlan.kpi_id == k1.id).all() + assert len(rows) == 12 and all(x.entity_id == 1 for x in rows) + + +def test_auto_decompose_single_kpi_cross_entity_404(client: TestClient, db: Session): + """e1 会话对他账套 KPI 做单 KPI 分解 → 404 且不落任何行""" + _seed_two_entities(db) + k2 = _mk_kpi(db, E2_KPI, 2) + token1 = _login(client, 1) + + r = client.post(f"{BUDGET}/auto-decompose", headers=auth_header(token1), + json={"kpi_id": k2.id, "annual_budget": 120000, "year": 2026, "method": "equal"}) + assert r.status_code == 404, f"跨账套单 KPI 分解应 404,实际 {r.status_code} {r.text[:200]}" + + db.expire_all() + assert db.query(BudgetPlan).filter(BudgetPlan.kpi_id == k2.id).count() == 0, \ + "跨账套分解竟写出了预算行" + + +# ────────────────────────────────────────────── +# P2. predict.py growth-quality 按会话账套读库 +# ────────────────────────────────────────────── + +def _mk_kpi_gq(db: Session, eid: int, code: str, value: float, period: str = "2026-09"): + kpi = _mk_kpi(db, code, eid) + db.add(KPIValue(kpi_id=kpi.id, entity_id=eid, period=period, actual_value=value)) + db.commit() + return kpi + + +def test_growth_quality_body_entity_ignored(client: TestClient, db: Session): + """请求体注入 entity 经营数据不得被采信(原 data.get("entity", data) 回退)""" + _seed_two_entities(db) + token1 = _login(client, 1) + + r = client.post(f"{PREDICT}/growth-quality", headers=auth_header(token1), json={ + "period": "2026-09", + "entity": {"entity": "伪造企业", "rebateRate": 82.8, "trueGrossMargin": 18.6, + "cashRatio": 0.6, "expenseGrowthRate": 2.2, "revenueGrowthRate": 1.0, + "mgmtRatio": 447}, + }) + assert r.status_code == 200, r.text[:300] + body = r.json() + assert body.get("entity_name") != "伪造企业", f"请求体 entity 被采信: {body.get('entity_name')}" + assert body.get("collected") is False, f"库内无数据却给出评分: {body}" + assert body.get("dimensions") == {}, f"未采集却输出维度评分: {body.get('dimensions')}" + assert body.get("overall") is None, f"未采集却输出综合评分: {body.get('overall')}" + + # e1 已有同名账套实体 → entity_name 来自库内 Entity 表 + ent1 = db.query(Entity).filter(Entity.id == 1).first() + assert body.get("entity_name") == ent1.name + + +def test_growth_quality_body_explicit_other_entity_403(client: TestClient, db: Session): + """请求体显式 entity_id 与会话账套不一致 → 403""" + _seed_two_entities(db) + token1 = _login(client, 1) + + r = client.post(f"{PREDICT}/growth-quality", headers=auth_header(token1), + json={"entity_id": 2}) + assert r.status_code == 403, f"跨账套显式 entity_id 应 403,实际 {r.status_code} {r.text[:200]}" + + +def test_growth_quality_reads_own_entity_kpi(client: TestClient, db: Session): + """库内存在本账套 KPI 实际值 → collected=true 且维度评分来自库内数据""" + _seed_two_entities(db) + # 库内既有编码口径:C_REBATE_RATE / F_GROSS_MARGIN / F_REVENUE_GROWTH + _mk_kpi_gq(db, 1, "C_REBATE_RATE", 82.8) + _mk_kpi_gq(db, 1, "F_GROSS_MARGIN", 18.6) + _mk_kpi_gq(db, 1, "F_REVENUE_GROWTH", 1.0) + token1 = _login(client, 1) + + r = client.post(f"{PREDICT}/growth-quality", headers=auth_header(token1), json={"period": "2026-09"}) + assert r.status_code == 200, r.text[:300] + body = r.json() + assert body.get("collected") is True, f"库内有数据却判未采集: {body}" + assert body.get("overall") is not None, f"未给出综合评分: {body}" + assert "revenueStructure" in body.get("dimensions", {}), f"营收结构维度缺失: {body.get('dimensions')}" + + +def test_growth_quality_compare_body_not_trusted(client: TestClient, db: Session): + """请求体 compare 数据不得被采信""" + _seed_two_entities(db) + token1 = _login(client, 1) + + r = client.post(f"{PREDICT}/growth-quality", headers=auth_header(token1), json={ + "compare": {"entity": "伪造对比企业", "rebateRate": 0, "mgmtRatio": 1.4}, + }) + assert r.status_code == 200, r.text[:300] + body = r.json() + assert not body.get("compare") or body["compare"].get("entity_name") != "伪造对比企业", \ + f"请求体 compare 被采信: {body.get('compare')}" + + +# ────────────────────────────────────────────── +# P2. expenses.py 列表/详情/审批账套归属 +# ────────────────────────────────────────────── + +def test_expenses_list_cross_entity_invisible(client: TestClient, db: Session): + """e1 会话列表看不到 e2 报销单(原声明了 entity_id 依赖但未生效)""" + _seed_two_entities(db) + mine = _mk_reimb(db, 1, "ZZB5E1BX001") + other = _mk_reimb(db, 2, "ZZB5E2BX001") + token1 = _login(client, 1) + + r = client.get(f"{EXPENSES}/reimbursements", headers=auth_header(token1)) + assert r.status_code == 200, r.text[:200] + ids = {x["id"] for x in r.json()["data"]} + assert other.id not in ids, f"e1 会话看到 e2 报销单(id={other.id})" + assert mine.id in ids, "e1 会话看不到本账套报销单" + + # 正对照:e2 会话看得到自己的 + token2 = _login(client, 2) + r2 = client.get(f"{EXPENSES}/reimbursements", headers=auth_header(token2)) + assert other.id in {x["id"] for x in r2.json()["data"]} + + +def test_expenses_detail_cross_entity_404(client: TestClient, db: Session): + """e1 会话读 e2 报销单详情 → 404""" + _seed_two_entities(db) + other = _mk_reimb(db, 2, "ZZB5E2BX002") + token1 = _login(client, 1) + + r = client.get(f"{EXPENSES}/reimbursements/{other.id}", headers=auth_header(token1)) + assert r.status_code == 404, f"跨账套读报销单详情应 404,实际 {r.status_code} {r.text[:200]}" + + +def test_expenses_detail_own_entity_ok(client: TestClient, db: Session): + """本账套报销单详情 → 200(正对照)""" + _seed_two_entities(db) + mine = _mk_reimb(db, 1, "ZZB5E1BX002") + token1 = _login(client, 1) + + r = client.get(f"{EXPENSES}/reimbursements/{mine.id}", headers=auth_header(token1)) + assert r.status_code == 200, r.text[:200] + assert r.json()["reimb_no"] == "ZZB5E1BX002" + + +@pytest.mark.parametrize("action,payload", [ + ("approve", {}), + ("reject", {"comment": "不合规"}), + ("return", {"comment": "请补充材料"}), +]) +def test_expenses_approval_cross_entity_404(client: TestClient, db: Session, action: str, payload: dict): + """e1 会话审批 e2 报销单(approve/reject/return)→ 404 且状态未变""" + _seed_two_entities(db) + other = _mk_reimb(db, 2, f"ZZB5E2BX_{action.upper()}") + token1 = _login(client, 1) + + r = client.post(f"{EXPENSES}/reimbursements/{other.id}/{action}", + headers=auth_header(token1), json=payload) + assert r.status_code == 404, f"跨账套{action}应 404,实际 {r.status_code} {r.text[:200]}" + + db.expire_all() + row = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.id == other.id).first() + assert row is not None and row.status == "pending", f"跨账套{action}生效了:{row and row.status}" + + +def test_expenses_approve_own_entity_ok(client: TestClient, db: Session): + """本账套审批自家报销单 → 200(正对照)""" + _seed_two_entities(db) + mine = _mk_reimb(db, 1, "ZZB5E1BX003") + token1 = _login(client, 1) + + r = client.post(f"{EXPENSES}/reimbursements/{mine.id}/approve", + headers=auth_header(token1), json={}) + assert r.status_code == 200, r.text[:300] + db.expire_all() + assert db.query(ExpenseReimbursement).filter( + ExpenseReimbursement.id == mine.id).first().status == "approved" + + +def test_tax_demo_seed_reimbursement_entity_id(client: TestClient, db: Session): + """tax_compliance 演示播种的报销单须写会话账套 entity_id""" + _seed_two_entities(db) + token2 = _login(client, 2) + + r = client.post(f"{TAX}/demo-data", headers=auth_header(token2)) + assert r.status_code == 200, r.text[:300] + + db.expire_all() + rows = db.query(ExpenseReimbursement).all() + assert rows, "演示播种未产生报销单" + bad = [(x.id, x.entity_id) for x in rows if x.entity_id != 2] + assert not bad, f"演示报销单未写会话账套(落默认 1): {bad}" diff --git a/backend/tests/test_predict.py b/backend/tests/test_predict.py index dd2b1dbe..13aed6d0 100644 --- a/backend/tests/test_predict.py +++ b/backend/tests/test_predict.py @@ -252,16 +252,27 @@ class TestGrowthQuality: assert "overall" in data or "level" in data or "score" in data def test_growth_quality_entity2(self, client: TestClient, db: Session): - """博海增长质量(需绑定 entity2 的会话 token,鉴权收口后不再靠 body entity_id)""" + """博海增长质量:企业名取库内 Entity.name,且不采信请求体指标(账套隔离 2026-09-21) + + 契约变更:原断言硬编码 ENTITY_DATA 的 "陕西博海网络科技"(该硬编码兜底已按 + batch5 派单要求移除)→ 改为断言库内来源;无 kpi_values 时如实返回未采集。 + """ token2 = _token_for_entity(client, db, 2) - resp = client.post(f"{BASE}/growth-quality", json={"entity_id": 2}, + resp = client.post(f"{BASE}/growth-quality", json={"entity_id": 2, "rebateRate": 99}, headers=auth_header(token2)) assert resp.status_code == 200 data = resp.json() - assert data["entity_name"] == "陕西博海网络科技" + assert data["entity_id"] == 2 + assert data["entity_name"] == "企业2", "企业名应取库内 Entity.name,而非硬编码" + assert data["collected"] is False + assert data["overall"] is None, "库内无数据不得编造评分" def test_growth_quality_custom(self, client: TestClient, db: Session): - """自定义企业数据(低分场景)""" + """自定义企业数据(低分场景)→ 契约变更:请求体指标不再被采信 + + 原用例靠请求体 rebateRate/mgmtRatio 等打分得 danger;该回退已按 batch5 派单 + 要求移除 → 改为断言请求体指标被忽略、无库内数据即未采集(不编造评分)。 + """ token99 = _token_for_entity(client, db, 99) resp = client.post(f"{BASE}/growth-quality", json={ "entity_id": 99, @@ -271,8 +282,7 @@ class TestGrowthQuality: }, headers=auth_header(token99)) assert resp.status_code == 200 data = resp.json() - assert data["entity_name"] == "测试企业" - assert data["overall"] < 2 - assert data["level_type"] == "danger" - # 低分诊断 - assert "越增长越重" in data["diagnosis"] + assert data["entity_name"] == "企业99", "企业名取库内,不取请求体 name" + assert data["collected"] is False + assert data["overall"] is None and data["level_type"] is None + assert data["dimensions"] == {} and data["diagnosis"] is None diff --git a/backend/tests/test_tax_compliance.py b/backend/tests/test_tax_compliance.py index 2ebaf789..e0be84a9 100644 --- a/backend/tests/test_tax_compliance.py +++ b/backend/tests/test_tax_compliance.py @@ -222,9 +222,8 @@ class TestDashboardDemo: data = resp.json() assert "burden" in data and "invoice" in data and "ss" in data - @pytest.mark.xfail(reason="SQLite DateTime 不接受字符串日期(生产MySQL可隐式转换);demo-data 为MySQL-only端点", strict=False) def test_demo_data(self, client: TestClient, db: Session): - """生成演示数据(MySQL-only:demo日期为字符串,SQLite DateTime严格模式不兼容)""" + """生成演示数据(batch5 起 invoice_date 经 _parse_date,SQLite 亦可跑通)""" create_test_user(db) token = get_token_for_user(client) resp = client.post(f"{BASE}/demo-data", headers=auth_header(token), json={})