Files
cma-management/backend/app/api/data.py
Hermes CI Fix 193061cf4d fix(data): 数据管理模块脱节修复 — Excel导入/sources端点 entity 隔离 + ERP直连通道诚实化(410) + 真实zxt链路展示
- import_excel/import_excel_smart KPI字典按 entity_id 过滤(防跨账套匹配串写,P0安全)
- import_excel_smart 自动建KPI编码防冲突(_next_code 循环取号)
- sources list/create/update/delete 全部按 entity_id 隔离(create 写入当前账套)
- sync-kpis + admin/erp-sync 下线返回410(erp-gateway DB凭据2026-08-31已删,真实通道=zxt智享通注入)
- 新增 GET /data/channel-status:展示真实zxt取数通道状态+遗留ERP数据源清单(诚实化)
- DataManage.vue:数据源tab顶部展示真实通道(最近注入period/时间/行数/落盘目录mtime)、ERP遗留源标注'非当前通道'、实际取数映射(kpi_value_sources)表格
- 新增 test_data_entity_isolation_20260908.py(8条:双账套导入隔离/CRUD隔离/sync-kpis 410/channel-status)
2026-09-08 11:19:38 +08:00

478 lines
19 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""数据对接 API"""
import pandas as pd
import io, json, hashlib, re, os
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Form
from sqlalchemy.orm import Session
from sqlalchemy import func
from typing import Optional
from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role
from app.models import KPIValue, DataSourceConfig, OperationLog, KPIDefinition
# ERP 直连同步通道停用标记(2026-08-31 OPS 安全加固:erp-gateway DB 凭据已删除)
# 陕西酣客财务原始数据的真实通道 = DAMA 读智享通网页 → 落盘 → 财务Bot 注入(source_type='zxt-sync')
# 任何 ERP API 直连同步入口(/data/sync-kpis、/admin/erp-sync)一律拒绝执行,防止写 estimated 假数据
ERP_DIRECT_CHANNEL_DISABLED = True
ERP_DIRECT_CHANNEL_REASON = ("ERP直连同步通道已于2026-08-31停用(erp-gateway 容器 DB 凭据已删除,zxbtest 账号停用)。"
"财务原始数据真实通道为智享通 zxt 通道:DAMA 读智享通网页 → 落盘 /root/WB_WS/_data/zxt-hanke → 财务Bot 注入 CMA"
"(subjects+kpi_values 双写, source_type='zxt-sync')。如需恢复 ERP API 直连,请联系运维重新配置 erp-gateway DB 凭据。")
# 智享通 zxt 落盘目录(真实通道文件来源;任总 2026-09-08 确认财务原始数据走 zxt_push)
ZXT_LANDING_DIR = "/root/WB_WS/_data/zxt-hanke"
router = APIRouter(prefix="/api/cma/data", tags=["数据对接"],
dependencies=[Depends(require_role("ceo", "finance", "it"))],
)
@router.post("/import-excel")
async def import_excel(file: UploadFile = File(...),
kpi_col: str = Query("kpi_code", description="Excel中KPI编码列名"),
period_col: str = Query("period", description="Excel中期间列名"),
value_col: str = Query("actual_value", description="Excel中实际值列名"),
default_period: str = Query(None, description="如文件无期间列,统一使用此值"),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
content = await file.read()
df = pd.read_excel(io.BytesIO(content))
required = [kpi_col, value_col]
if not default_period:
required.append(period_col)
missing = [c for c in required if c not in df.columns]
if missing:
raise HTTPException(400,
f"Excel缺少列: {missing}。当前文件列: {list(df.columns)}")
if len(df) == 0:
raise HTTPException(400, "Excel文件为空,没有数据行")
from app.models import KPIDefinition
kpi_map = {k.kpi_code: k.id for k in db.query(KPIDefinition).filter(KPIDefinition.entity_id == entity_id).all()}
kpi_entity_map = {k.kpi_code: k.entity_id for k in db.query(KPIDefinition).filter(KPIDefinition.entity_id == entity_id).all()} # 账套隔离
batch = hashlib.md5(str(datetime.now().timestamp()).encode()).hexdigest()[:12]
count = 0
skipped = []
for idx, row in df.iterrows():
kpi_code = str(row.get(kpi_col, "")).strip()
period = str(row.get(period_col, default_period or "")).strip() if period_col in df.columns else (default_period or "").strip()
value = row.get(value_col)
if not kpi_code or not period or pd.isna(value):
skipped.append(f"第{idx+2}行: 缺少必填字段")
continue
kid = kpi_map.get(kpi_code)
if not kid:
skipped.append(f"第{idx+2}行: KPI编码「{kpi_code}」不存在")
continue
db.add(KPIValue(
kpi_id=kid,
entity_id=kpi_entity_map.get(kpi_code),
period=period,
actual_value=float(value),
source_type="excel",
source_batch=batch,
data_status="verified",
))
count += 1
db.commit()
msg = f"✅ 导入成功 {count} 条数据"
if skipped:
msg += f",{len(skipped)}条跳过:\n" + "\n".join(skipped[:10])
if len(skipped) > 10:
msg += f"\n...还有{len(skipped)-10}条"
return {"message": msg, "batch": batch, "total": count, "skipped": len(skipped)}
# ── 智能导入(BOT自动识别,无需手动映射) ──
_SMART_MAP = {
# KPI名称/编码列匹配模式(顺序重要:名称类列优先,避免科目编码被当名称)
"kpi_code_patterns": [
re.compile(r'^(科目名称|项目名称|指标名称?|kpi名称?|name|名称)$', re.I),
re.compile(r'^(科目编码|科目代码|kpi_?code|指标编码|编码)$', re.I),
re.compile(r'^(科目|项目|账户|报表项目)$'),
],
# 期间列匹配
"period_patterns": [
re.compile(r'^(period|期间|月份?|年月|日期|会计期间)$', re.I),
re.compile(r'^(报表期[间]?|所属期)$'),
re.compile(r'^年|^月'), # 以"年"或"月"开头的列
],
# 数值列匹配
"value_patterns": [
re.compile(r'^(actual_?value|数值|实际值|实际金额)$', re.I),
re.compile(r'^(本期金额|本月数|本期|期末余额|期末数|余额)$'),
re.compile(r'^(金额|数据|value|本年累计|本期发生[额]?)$', re.I),
],
# 文件名→期间提取
"period_in_filename_patterns": [
re.compile(r'[-_]?(\d{4})[-_]?(\d{1,2})'), # 2026-06 / 2026_06 / 2606
re.compile(r'(\d{4})年(\d{1,2})月(?:至(\d{4})年(\d{1,2})月)?'), # 2026年01月 / 2026年01月至2026年05月
re.compile(r'(\d{4})(\d{2})'), # 202606 (纯数字6-8位)
],
# 文件名→报表类型
"statement_types": {
"利润表": "PL",
"利润": "PL",
"income": "PL",
"现金流量表": "CF",
"现金流": "CF",
"cashflow": "CF",
"cash_flow": "CF",
"资产负债表": "BS",
"资产负": "BS",
"balance": "BS",
},
}
def _smart_detect_kpi_col(cols: list[str]) -> str | None:
for pat in _SMART_MAP["kpi_code_patterns"]:
for c in cols:
if pat.match(c.strip()):
return c
return None
def _smart_detect_period_col(cols: list[str]) -> str | None:
for pat in _SMART_MAP["period_patterns"]:
for c in cols:
if pat.match(c.strip()):
return c
return None
def _smart_detect_value_col(cols: list[str]) -> str | None:
for pat in _SMART_MAP["value_patterns"]:
for c in cols:
if pat.match(c.strip()):
return c
return None
def _smart_extract_period_from_filename(filename: str) -> str | None:
for pat in _SMART_MAP["period_in_filename_patterns"]:
m = pat.search(filename)
if m:
groups = m.groups()
if len(groups) == 4 and groups[2]: # 2026年01月至2026年05月 → 取结束月
return f"{groups[2]}-{int(groups[3]):02d}"
if len(groups) >= 2: # 2026-06 或 2026年01月
return f"{int(groups[0])}-{int(groups[1]):02d}"
return None
def _smart_detect_statement_type(filename: str) -> str | None:
for kw, tp in _SMART_MAP["statement_types"].items():
if kw in filename:
return tp
return None
@router.post("/import-excel-smart")
async def import_excel_smart(
file: UploadFile = File(...),
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
):
"""智能导入 — BOT自动识别列名/期间/报表类型,无需手动映射"""
content = await file.read()
fname = file.filename or "未知文件"
try:
df = pd.read_excel(io.BytesIO(content))
except Exception as e:
raise HTTPException(400, f"无法读取Excel文件: {e}")
if len(df) == 0:
raise HTTPException(400, "Excel文件为空")
cols = list(df.columns)
if len(cols) < 2:
raise HTTPException(400, f"Excel列数过少: {cols}")
# 4. 智能检测列
kpi_col = _smart_detect_kpi_col(cols) or cols[0]
value_col = _smart_detect_value_col(cols) or cols[-1]
period_col = _smart_detect_period_col(cols)
# 5. 从文件名提取期间
period = _smart_extract_period_from_filename(fname) if not period_col else None
# 6. 检测报表类型(用于自动生成KPI编码前缀)
stype = _smart_detect_statement_type(fname)
# 7. 预加载KPI字典(★ 按当前账套过滤,防跨账套匹配 — 2026-09-08 P0 安全修复)
from app.models import KPIDefinition
kpis = {k.kpi_code: k for k in db.query(KPIDefinition).filter(KPIDefinition.entity_id == entity_id).all()}
known_codes = set(kpis.keys())
# 构建别名映射(去掉空格/大小写/特殊字符)
alias_map: dict[str, str] = {}
for code in known_codes:
clean = re.sub(r'[\s\-_()()]', '', code).lower()
alias_map[clean] = code
# 中文名映射("营业收入"→F_REVENUE)——同账套内 kpi_name 冲突时后者覆盖,保持与原逻辑一致
name_map: dict[str, str] = {}
for code, kpi_obj in kpis.items():
name_map[kpi_obj.kpi_name] = code
# 8. 遍历导入(匹配不上的自动创建KPI)
stype_prefix = {"PL": "PL_", "CF": "CF_", "BS": "BS_"}.get(stype or "", "EXT_")
batch = hashlib.md5(str(datetime.now().timestamp()).encode()).hexdigest()[:12]
imported = 0
created_kpis = 0
skipped_rows = []
code_serial = len(kpis) + 1
def _next_code() -> str:
"""生成当前账套内不冲突的新KPI编码"""
nonlocal code_serial
while True:
candidate = f"{stype_prefix}{code_serial:03d}"
code_serial += 1
if candidate not in known_codes:
return candidate
for idx, row in df.iterrows():
raw_kpi_raw = row.get(kpi_col, "")
raw_kpi = str(raw_kpi_raw).strip()
raw_val = row.get(value_col)
raw_period = str(row.get(period_col, period or "")).strip() if period_col else (period or "")
# 名称防护:NaN/空/None 或 纯数字(疑似科目编码被误当名称)→ 跳过,避免创建垃圾KPI
if raw_kpi.lower() in ("nan", "none") or not raw_kpi:
skipped_rows.append(f"第{idx+2}行: KPI名称为空")
continue
if re.fullmatch(r"\d+(\.\d+)?", raw_kpi):
skipped_rows.append(f"第{idx+2}行: KPI名称疑似科目编码「{raw_kpi}」,跳过")
continue
if pd.isna(raw_val):
skipped_rows.append(f"第{idx+2}行: 缺数据")
continue
if not raw_period:
skipped_rows.append(f"第{idx+2}行: 无法确定期间")
continue
# 清理科目名(去掉"一、""减:""加:"等前缀)
clean_name = re.sub(r'^[一二三四五六七八九十、\s\+]+', '', raw_kpi)
clean_name = re.sub(r'^[减加]?[::]\s*', '', clean_name).strip()
if not clean_name:
clean_name = raw_kpi
# 匹配KPI
kpi_code = None
# ① 精确编码匹配(极少情况)
if raw_kpi in known_codes:
kpi_code = raw_kpi
# ② 别名匹配(去符号小写)
if not kpi_code:
clean_key = re.sub(r'[\s\-_()()]', '', raw_kpi).lower()
kpi_code = alias_map.get(clean_key)
# ③ 中文名精确匹配
if not kpi_code:
kpi_code = name_map.get(clean_name)
# ④ 中文名模糊匹配
if not kpi_code:
for code, kpi_obj in kpis.items():
if clean_name in kpi_obj.kpi_name or kpi_obj.kpi_name in clean_name:
kpi_code = code
break
# ⑤ 仍未匹配 → 自动创建KPI(★ 创建在当前账套 entity_id 下)
if not kpi_code:
new_code = _next_code()
new_kpi = KPIDefinition(
entity_id=entity_id,
kpi_code=new_code,
kpi_name=clean_name,
dimension="finance",
category="financial_report",
formula="-",
data_source_type="excel",
data_source="Excel导入",
data_owner="财务部",
frequency="monthly",
unit="元",
target_value=0,
kpi_level="operational",
status="active",
)
db.add(new_kpi)
db.flush()
kpis[new_code] = new_kpi
known_codes.add(new_code)
name_map[clean_name] = new_code
kpi_code = new_code
created_kpis += 1
try:
val = float(raw_val)
except:
skipped_rows.append(f"第{idx+2}行: 数值格式错误「{raw_val}」")
continue
db.add(KPIValue(
kpi_id=kpis[kpi_code].id,
entity_id=entity_id, # 账套隔离:只写入当前账套
period=raw_period,
actual_value=val,
source_type="excel",
source_batch=batch,
data_status="verified",
))
imported += 1
db.commit()
# 9. 返回汇总
stype_label = {"PL": "利润表", "CF": "现金流量表", "BS": "资产负债表"}.get(stype or "", "数据表")
msg = f"✅ {stype_label}识别成功,导入{imported}条"
if created_kpis:
msg += f",自动创建{created_kpis}个新KPI"
if skipped_rows:
msg += f",{len(skipped_rows)}条跳过:\n" + "\n".join(skipped_rows[:8])
if len(skipped_rows) > 8:
msg += f"\n...还有{len(skipped_rows) - 8}条"
return {"message": msg, "batch": batch, "total": imported, "skipped": len(skipped_rows)}
@router.get("/sources")
def list_sources(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
sources = db.query(DataSourceConfig).filter(DataSourceConfig.entity_id == entity_id).all()
return {"data": [{c.name: getattr(s, c.name) for c in DataSourceConfig.__table__.columns} for s in sources]}
@router.post("/sources")
def create_source(data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
source = DataSourceConfig(
entity_id=entity_id, # 账套隔离:创建在当前账套下
name=data.get("name", ""),
source_type=data.get("source_type", "manual"),
api_endpoint=data.get("api_endpoint"),
api_key=data.get("api_key"),
query_sql=data.get("query_sql"),
sync_type=data.get("sync_type", "manual"),
status="active",
)
db.add(source)
db.commit()
db.refresh(source)
# 操作日志
db.add(OperationLog(action="create_source", target_type="source", detail=source.name))
db.commit()
return {"data": {c.name: getattr(source, c.name) for c in DataSourceConfig.__table__.columns}}
@router.put("/sources/{source_id}")
def update_source(source_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
source = db.query(DataSourceConfig).filter(
DataSourceConfig.id == source_id,
DataSourceConfig.entity_id == entity_id,
).first()
if not source:
raise HTTPException(404, "数据源不存在")
for key in ["name", "source_type", "api_endpoint", "api_key", "query_sql", "sync_type", "status"]:
if key in data:
setattr(source, key, data[key])
db.commit()
db.refresh(source)
db.add(OperationLog(action="update_source", target_type="source", detail=source.name))
db.commit()
return {"data": {c.name: getattr(source, c.name) for c in DataSourceConfig.__table__.columns}}
@router.delete("/sources/{source_id}")
def delete_source(source_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
source = db.query(DataSourceConfig).filter(
DataSourceConfig.id == source_id,
DataSourceConfig.entity_id == entity_id,
).first()
if not source:
raise HTTPException(404, "数据源不存在")
db.add(OperationLog(action="delete_source", target_type="source", detail=source.name))
db.delete(source)
db.commit()
return {"message": "删除成功"}
# ── 数据链路状态(2026-09-08 P0 诚实化:展示真实取数通道,替代误导性 active ERP 数据源) ──
def _zxt_channel_status(entity_id: int, db: Session) -> dict:
"""真实取数通道状态:kpi_values source_type='zxt-sync' 最近注入信息 + 落盘目录"""
latest = db.query(
func.max(KPIValue.period).label("latest_period"),
func.max(KPIValue.calculated_at).label("latest_at"),
func.count(KPIValue.id).label("row_count"),
).filter(
KPIValue.source_type == "zxt-sync",
KPIValue.entity_id == entity_id,
).first()
dir_mtime = None
dir_exists = os.path.isdir(ZXT_LANDING_DIR)
if dir_exists:
try:
newest = max(
(os.path.getmtime(os.path.join(root, f))
for root, _dirs, files in os.walk(ZXT_LANDING_DIR)
for f in files if f.endswith(".csv")),
default=0,
)
if newest:
dir_mtime = datetime.fromtimestamp(newest).strftime("%Y-%m-%d %H:%M")
except Exception:
pass
return {
"channel": "zxt(智享通→落盘→注入)",
"enabled": True,
"latest_period": latest.latest_period if latest else None,
"latest_inject_at": latest.latest_at.strftime("%Y-%m-%d %H:%M:%S") if latest and latest.latest_at else None,
"injected_rows": latest.row_count if latest else 0,
"landing_dir": ZXT_LANDING_DIR,
"landing_dir_exists": dir_exists,
"landing_dir_mtime": dir_mtime,
}
@router.get("/channel-status")
def channel_status(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""数据链路状态:真实取数通道(zxt) + 遗留 ERP 数据源说明(诚实化展示)"""
zxt = _zxt_channel_status(entity_id, db)
# 当前账套的 data_source_config 遗留 ERP 源(有端点指向 erp-gateway 且从未同步成功)
legacy = db.query(DataSourceConfig).filter(
DataSourceConfig.entity_id == entity_id,
DataSourceConfig.source_type == "erp",
).all()
legacy_rows = [{
"id": s.id,
"name": s.name,
"api_endpoint": s.api_endpoint,
"status": s.status,
"last_sync_at": s.last_sync_at.strftime("%Y-%m-%d %H:%M:%S") if s.last_sync_at else None,
} for s in legacy]
return {
"real_channel": zxt,
"erp_direct": {
"enabled": False,
"reason": ERP_DIRECT_CHANNEL_REASON,
"legacy_sources_count": len(legacy_rows),
"legacy_sources": legacy_rows,
},
}
@router.get("/sync-kpis")
def sync_kpis_from_erp(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""[已停用] 从ERP数据源同步KPI值
ERP 直连通道已于 2026-08-31 停用(erp-gateway DB 凭据删除)。
真实财务数据通道 = zxt 智享通注入(source_type='zxt-sync'),勿再调用本端点。
"""
raise HTTPException(status_code=410, detail=ERP_DIRECT_CHANNEL_REASON)