diff --git a/backend/app/api/data.py b/backend/app/api/data.py index 86b719d7..78c3f2d1 100644 --- a/backend/app/api/data.py +++ b/backend/app/api/data.py @@ -190,10 +190,16 @@ async def import_excel_smart(file: UploadFile = File(...), db: Session = Depends for code in known_codes: clean = re.sub(r'[\s\-_()()]', '', code).lower() alias_map[clean] = code + # 中文名映射("营业收入"→F_REVENUE) + name_map: dict[str, str] = {} + for code, kpi_obj in kpis.items(): + name_map[kpi_obj.kpi_name] = code - # 8. 遍历导入 + # 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 = [] for idx, row in df.iterrows(): @@ -208,24 +214,50 @@ async def import_excel_smart(file: UploadFile = File(...), db: Session = Depends skipped_rows.append(f"第{idx+2}行: 无法确定期间") continue - # 智能匹配KPI编码 + # 清理科目名(去掉"一、""减:""加:"等前缀) + 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 - else: - # 别名匹配 + # ② 别名匹配(去符号小写) + if not kpi_code: clean_key = re.sub(r'[\s\-_()()]', '', raw_kpi).lower() kpi_code = alias_map.get(clean_key) - # 模糊匹配(中文科目名→KPI编码) - if not kpi_code: - for code, kpi_obj in kpis.items(): - if raw_kpi in kpi_obj.kpi_name or kpi_obj.kpi_name in raw_kpi: - kpi_code = code - break - + # ③ 中文名精确匹配 if not kpi_code: - skipped_rows.append(f"第{idx+2}行: 「{raw_kpi}」未匹配到KPI") - continue + 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 + if not kpi_code: + new_code = f"{stype_prefix}{len(kpis) + created_kpis + 1:03d}" + new_kpi = KPIDefinition( + kpi_code=new_code, + kpi_name=clean_name, + dimension="finance", + category="financial_report", + data_source_type="excel", + 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) @@ -248,6 +280,8 @@ async def import_excel_smart(file: UploadFile = File(...), db: Session = Depends # 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: diff --git a/backend/app/api/kpis.py b/backend/app/api/kpis.py index ed61d8c3..8faf5156 100644 --- a/backend/app/api/kpis.py +++ b/backend/app/api/kpis.py @@ -233,6 +233,77 @@ def get_kpi_score( } +# ============================================================ +# KPI-glossary: 知识资产化 — KPI字典实时加载(供ChatBI财务Bot调用) +# ============================================================ + +@router.get("/glossary") +def get_kpi_glossary( + entity_id: int = Query(1, ge=1), + db: Session = Depends(get_db), + current_user = Depends(require_auth), +): + """KPI字典实时加载 — 返回所有KPI的定义、当前值、目标值、公式、维度、阈值 + + 供ChatBI财务Bot在分析前调用,确保口径与系统一致。 + 返回字段: kpi_code, kpi_name, current_value, target_value, formula, dimension, threshold + """ + kpis = db.query(KPIDefinition).filter( + KPIDefinition.status == "active", + KPIDefinition.entity_id == entity_id, + ).order_by(KPIDefinition.kpi_code).all() + + result = [] + for k in kpis: + # 获取最新实际值 + latest_val = db.query(KPIValue).filter( + KPIValue.kpi_id == k.id, + KPIValue.actual_value.isnot(None), + ).order_by(KPIValue.period.desc()).first() + + current_value = latest_val.actual_value if latest_val else None + latest_period = latest_val.period if latest_val else None + + # 组装阈值描述 + threshold = None + if k.threshold_green or k.threshold_yellow or k.threshold_red: + parts = [] + if k.threshold_green: + parts.append(f"绿灯:{k.threshold_green}") + if k.threshold_yellow: + parts.append(f"黄灯:{k.threshold_yellow}") + if k.threshold_red: + parts.append(f"红灯:{k.threshold_red}") + threshold = " | ".join(parts) + + result.append({ + "kpi_id": k.id, + "kpi_code": k.kpi_code, + "kpi_name": k.kpi_name, + "dimension": k.dimension, + "category": k.category, + "formula": k.formula, + "formula_desc": k.formula_desc, + "unit": k.unit, + "target_value": k.target_value, + "current_value": current_value, + "latest_period": latest_period, + "threshold": threshold, + "responsible_dept": k.responsible_dept, + "responsible_user": k.responsible_user, + "data_source": k.data_source, + "data_owner": k.data_owner, + "frequency": k.frequency, + "status": k.status, + }) + + return { + "entity_id": entity_id, + "total": len(result), + "glossary": result, + } + + # ============================================================ # KPI-6: KPI三级分解树 # ============================================================