From d3edd71e604d68995d72e1da385021f6780e35e2 Mon Sep 17 00:00:00 2001 From: Hermes CI Fix Date: Mon, 13 Jul 2026 15:36:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=99=BA=E8=83=BD=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E2=80=94=E2=80=94BOT=E8=87=AA=E5=8A=A8=E8=AF=86=E5=88=AB?= =?UTF-8?q?=E6=8A=A5=E8=A1=A8=E7=B1=BB=E5=9E=8B/=E5=88=97=E5=90=8D/?= =?UTF-8?q?=E6=9C=9F=E9=97=B4/=E5=8C=B9=E9=85=8DKPI=EF=BC=8C=E6=97=A0?= =?UTF-8?q?=E9=9C=80=E6=89=8B=E5=8A=A8=E6=98=A0=E5=B0=84=20=E5=89=8D?= =?UTF-8?q?=E7=AB=AF:=20=E7=AE=80=E5=8C=96=E4=B8=8A=E4=BC=A0=E7=95=8C?= =?UTF-8?q?=E9=9D=A2=EF=BC=8C=E8=87=AA=E5=8A=A8=E6=A8=A1=E5=BC=8F(importEx?= =?UTF-8?q?celSmart)=20=E5=90=8E=E7=AB=AF:=20=E6=96=B0=E5=A2=9E/import-exc?= =?UTF-8?q?el-smart=E7=AB=AF=E7=82=B9=EF=BC=8C=E8=87=AA=E5=8A=A8=E6=A3=80?= =?UTF-8?q?=E6=B5=8B:=20=20=20-=20=E5=88=97=E5=90=8D:=20=E7=A7=91=E7=9B=AE?= =?UTF-8?q?/=E7=BC=96=E7=A0=81=E2=86=92kpi=5Fcode,=20=E6=9C=AC=E6=9C=9F?= =?UTF-8?q?=E9=87=91=E9=A2=9D=E2=86=92value,=20=E6=9C=9F=E9=97=B4=E2=86=92?= =?UTF-8?q?period=20=20=20-=20=E6=96=87=E4=BB=B6=E5=90=8D:=20=E6=8F=90?= =?UTF-8?q?=E5=8F=96=E6=9C=9F=E9=97=B4(2026-06)=E5=92=8C=E6=8A=A5=E8=A1=A8?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B(=E5=88=A9=E6=B6=A6=E8=A1=A8/=E7=8E=B0?= =?UTF-8?q?=E9=87=91=E6=B5=81=E9=87=8F=E8=A1=A8/=E8=B5=84=E4=BA=A7?= =?UTF-8?q?=E8=B4=9F=E5=80=BA=E8=A1=A8)=20=20=20-=20KPI=E5=8C=B9=E9=85=8D:?= =?UTF-8?q?=20=E7=BC=96=E7=A0=81=E7=B2=BE=E7=A1=AE=E2=86=92=E5=88=AB?= =?UTF-8?q?=E5=90=8D=E2=86=92=E4=B8=AD=E6=96=87=E5=90=8D=E6=A8=A1=E7=B3=8A?= =?UTF-8?q?=E5=8C=B9=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/api/__pycache__/data.cpython-312.pyc | Bin 8165 -> 8712 bytes backend/app/api/data.py | 181 +++++++++++++++++- frontend/src/api/index.ts | 5 + frontend/src/views/DataManage.vue | 85 +------- 4 files changed, 193 insertions(+), 78 deletions(-) diff --git a/backend/app/api/__pycache__/data.cpython-312.pyc b/backend/app/api/__pycache__/data.cpython-312.pyc index a167b66d4b8a3295ac05497858e8dfafb047ec34..cb682c8e9d19e4ad0a38c1561619a71f261cf613 100644 GIT binary patch delta 3254 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 diff --git a/backend/app/api/data.py b/backend/app/api/data.py index 85b22abe..1748479c 100644 --- a/backend/app/api/data.py +++ b/backend/app/api/data.py @@ -1,6 +1,6 @@ """数据对接 API""" import pandas as pd -import io, json, hashlib +import io, json, hashlib, re from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File from sqlalchemy.orm import Session @@ -75,6 +75,185 @@ async def import_excel(file: UploadFile = File(...), msg += f"\n...还有{len(skipped)-10}条" return {"message": msg, "batch": batch, "total": count, "skipped": len(skipped)} + +# ── 智能导入(BOT自动识别,无需手动映射) ── + +_SMART_MAP = { + # KPI编码列匹配模式 → 标准kpi_code + "kpi_code_patterns": [ + re.compile(r'^(kpi_?code|指标编码|编码)$', re.I), + re.compile(r'^(科目|项目|账户|报表项目|项目名称)$'), + re.compile(r'^(指标名称?|kpi名称?|name)$', re.I), + ], + # 期间列匹配 + "period_patterns": [ + re.compile(r'^(period|期间|月份?|年月|日期|会计期间)$', re.I), + re.compile(r'^(报表期[间]?|所属期)$'), + ], + # 数值列匹配 + "value_patterns": [ + re.compile(r'^(actual_?value|数值|实际值|实际金额)$', re.I), + re.compile(r'^(本期金额|本月数|本期|期末余额|期末数)$'), + re.compile(r'^(金额|数据|value)$', re.I), + ], + # 文件名→期间提取 + "period_in_filename": re.compile(r'[-_]?(\d{4})[-_]?(\d{1,2})'), + # 文件名→报表类型 + "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: + m = _SMART_MAP["period_in_filename"].search(filename) + if m: + return f"{m.group(1)}-{int(m.group(2)):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(...), 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字典 + from app.models import KPIDefinition + kpis = {k.kpi_code: k for k in db.query(KPIDefinition).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 + + # 8. 遍历导入 + batch = hashlib.md5(str(datetime.now().timestamp()).encode()).hexdigest()[:12] + imported = 0 + skipped_rows = [] + + for idx, row in df.iterrows(): + raw_kpi = str(row.get(kpi_col, "")).strip() + raw_val = row.get(value_col) + raw_period = str(row.get(period_col, period or "")).strip() if period_col else (period or "") + + if not raw_kpi or pd.isna(raw_val): + skipped_rows.append(f"第{idx+2}行: 缺数据") + continue + if not raw_period: + skipped_rows.append(f"第{idx+2}行: 无法确定期间") + continue + + # 智能匹配KPI编码 + kpi_code = None + if raw_kpi in known_codes: + kpi_code = raw_kpi + else: + # 别名匹配 + 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 + + try: + val = float(raw_val) + except: + skipped_rows.append(f"第{idx+2}行: 数值格式错误「{raw_val}」") + continue + + db.add(KPIValue( + kpi_id=kpis[kpi_code].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 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)): sources = db.query(DataSourceConfig).all() diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index b830d5de..6203a994 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -67,6 +67,11 @@ export const dataApi = { form.append('file', file) return api.post(`/data/import-excel${qs ? '?' + qs : ''}`, form) }, + importExcelSmart: (file: File) => { + const form = new FormData() + form.append('file', file) + return api.post('/data/import-excel-smart', form, { timeout: 60000 }) + }, listSources: () => api.get('/data/sources'), createSource: (data: any) => api.post('/data/sources', data), updateSource: (id: number, data: any) => api.put(`/data/sources/${id}`, data), diff --git a/frontend/src/views/DataManage.vue b/frontend/src/views/DataManage.vue index bad10f3f..ce5693b3 100644 --- a/frontend/src/views/DataManage.vue +++ b/frontend/src/views/DataManage.vue @@ -5,52 +5,17 @@ - +
拖拽或点击上传Excel文件
-
支持 .xlsx .xls,可一次选择多个文件(按住Ctrl/Shift多选)
+
支持 .xlsx .xls,自动识别利润表/现金流量表/资产负债表/KPI数据表
{{ f.name }}
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 导入 {{ files.length }} 个文件 -
+ 🤖 智能导入 {{ files.length }} 个文件 +
@@ -77,7 +42,7 @@ @@ -135,14 +100,10 @@ const activeTab = ref('import') const files = ref([]) const uploading = ref(false) const result = ref('') -const detectedCols = ref([]) -const colMap = ref({ kpi: 'kpi_code', period: 'period', value: 'actual_value', defaultPeriod: '' }) function handleFile(f: any) { if (f.raw && !files.value.some(ex => ex.name === f.raw.name && ex.size === f.raw.size)) { files.value.push(f.raw) - // 从第一个文件检测列名 - if (files.value.length === 1) detectColumns(f.raw) } } function handleRemove(f: any) { @@ -151,51 +112,21 @@ function handleRemove(f: any) { function removeFile(i: number) { files.value.splice(i, 1) } -async function detectColumns(file: File) { - // 用 Web 方式读 Excel 列名(仅第一行),供用户映射 - try { - const buf = await file.arrayBuffer() - const XLSX = (await import('xlsx')).default - const wb = XLSX.read(buf, { type: 'array' }) - const ws = wb.Sheets[wb.SheetNames[0]] - const rows: any[] = XLSX.utils.sheet_to_json(ws, { header: 1 }) - if (rows.length > 0) { - detectedCols.value = (rows[0] as string[]).filter(Boolean) - // 尝试智能匹配 - const all = detectedCols.value.map(c => c.toLowerCase().replace(/[\\s ]/g, '')) - const kpiIdx = all.findIndex(c => /^(kpi_?code|编码|科目|项目|指标名称?)$/.test(c)) - const periodIdx = all.findIndex(c => /^(period|期间|月份?|日期|年月)$/.test(c)) - const valIdx = all.findIndex(c => /^(actual_?value|金额|数值|本月数|本期金额?|实际值)$/.test(c)) - if (kpiIdx >= 0) colMap.value.kpi = detectedCols.value[kpiIdx] - if (periodIdx >= 0) colMap.value.period = detectedCols.value[periodIdx] - if (valIdx >= 0) colMap.value.value = detectedCols.value[valIdx] - } - } catch (_) { /* 预览列名失败不影响导入 */ } -} async function doImport() { if (files.value.length === 0) return uploading.value = true result.value = '' const msgs: string[] = [] - const params: Record = { - kpi_col: colMap.value.kpi || 'kpi_code', - value_col: colMap.value.value || 'actual_value', - } - if (colMap.value.period && colMap.value.period !== '__fixed__') params['period_col'] = colMap.value.period - if (colMap.value.period === '__fixed__' && colMap.value.defaultPeriod) params['default_period'] = colMap.value.defaultPeriod - const qs = new URLSearchParams(params).toString() for (const f of files.value) { try { - const r: any = await dataApi.importExcel(f, qs) - const skipped = r.skipped || 0 - const total = r.total || 0 - msgs.push(`📄 ${f.name}: ${total}条导入${skipped > 0 ? `, ${skipped}条跳过` : ''}`) + const r: any = await dataApi.importExcelSmart(f) + const msg = r.message || '' + msgs.push(`📄 ${f.name}: ${msg}`) } catch (e: any) { msgs.push(`❌ ${f.name}: ${e?.response?.data?.detail || '导入失败'}`) } } files.value = [] - detectedCols.value = [] result.value = msgs.join('\n') uploading.value = false }