Преглед на файлове

fix(results): CSV export endpoint + restart note; CSV button was pointing at plan-JSON download

- new GET /api/plans/{id}/export-csv sharing _collect_export_rows with xlsx
  (single Chinese header row, utf-8-sig for Excel)
- frontend: download CSV button now targets export-csv (was /download which
  returns plan definition JSON for the executor, never results)
- tests: +7 csv checks (37 total, all pass); live-verified plan 33 via vite
  proxy (xlsx 7664B/5x55, csv 2504B)
carlin преди 15 часа
родител
ревизия
90f1e252e7
променени са 5 файла, в които са добавени 126 реда и са изтрити 28 реда
  1. 8 0
      docs/CONVERSATION_LOG.md
  2. 8 0
      docs/TEST_RECORDS.md
  3. 21 0
      scripts/test_xlsx_export.py
  4. 85 27
      web/backend/app/routers/plans.py
  5. 4 1
      web/frontend/src/views/PlanDetail.vue

+ 8 - 0
docs/CONVERSATION_LOG.md

@@ -1179,3 +1179,11 @@ ambient_temperature=25.0)` 单点端到端 PASS——7 项热指标落盘,温
 **验证**:test_xlsx_export.py 30 项全过;真实库 plan 31 导出 56 列(电磁 23 + 热 1)、plan 23(80 FAILED)29 列;vue-tsc 0 错误。
 **坑**:Edit 工具对 ASCII 化文件需按 \u 转义字面量匹配,直接写中文匹配不上(改用 python heredoc 补丁)。
 **遗留**:无。
+
+## 2026-09-04 — 导出功能上线报障修复(TEST-066 续)
+
+**用户报障**:前端"下载 CSV"/"导出 Excel"都点不动。
+**根因**:① 常驻后端 PID 31124 无 --reload,新路由未加载(export-xlsx 404);② "下载 CSV"历史误标——指向 `/plans/{id}/download`,返回的是方案 JSON 而非结果 CSV。
+**修复**:新增 `GET /api/plans/{id}/export-csv`(共享 `_collect_export_rows`,utf-8-sig);前端按钮改指 export-csv;重启后端(8000)。
+**验证**:CSV 测试 7 项新增(共 37 全过);plan 33 经 5173 代理实测导出 xlsx/csv 均 200、内容正确。vue-tsc 0 错误。
+**运维教训**:改后端代码后常驻 uvicorn(无 --reload)必须重启才生效。

+ 8 - 0
docs/TEST_RECORDS.md

@@ -2383,6 +2383,14 @@ solver.run_single_point(thermal_mode)(coupled 分支调 do_magnetic_thermal_ca
 - 真实库验证:plan 31(3 点 OK 结果)导出 56 列,含电磁性能 23 列 + 热性能(磁钢温度);plan 23(80 点全 FAILED)导出 29 列仅扫描信息,失败行错误信息完整。
 - vue-tsc 0 错误。
 
+### 上线修复(同日 14:00,用户报障)
+
+- **报障**:前端"下载 CSV"/"导出 Excel"均失败。
+- **根因**:① 常驻后端(PID 31124,无 --reload)未加载新代码,export-xlsx 404;② "下载 CSV"按钮指向 `/api/plans/{id}/download`——该接口返回的是方案定义 JSON(给执行器拉方案用),从未返回过结果 CSV,属历史误标。
+- **修复**:新增 `GET /api/plans/{id}/export-csv`(与 xlsx 同一套列组装 `_collect_export_rows`,单行中文表头,utf-8-sig 供 Excel 直开);前端"下载 CSV"改指 export-csv;重启后端。
+- **验证**:补 7 项 CSV 测试(共 37 项全过);真实链路 plan 33 经 vite 代理(5173)导出 xlsx 7664B(5 行 55 列,三类目分组)+ csv 2504B(BOM 正确、中文表头正常)。
+- **注意**:plan 33 第 1 行 status=OK 但带历史错误信息(Baseline reload failed),为脏数据原样导出,非本次引入。
+
 ### 结论
 
 工程师最关心指标(转矩/脉动/效率/损耗分解/电气/温度)在 Web 表格默认可视,完整结果一键导出 xlsx,格式与 torqrippswap 一致。

+ 21 - 0
scripts/test_xlsx_export.py

@@ -223,6 +223,27 @@ def api_tests() -> None:
     check("api: 400 when no results",
           client.get(f"/api/plans/{empty_pk}/export-xlsx").status_code == 400)
 
+    # 4) CSV export (same columns as xlsx, single header row, utf-8-sig).
+    resp3 = client.get(f"/api/plans/{plan_pk}/export-csv")
+    check("csv: export 200", resp3.status_code == 200)
+    check("csv: content-type", "text/csv" in resp3.headers.get("content-type", ""))
+    disp3 = resp3.headers.get("content-disposition", "")
+    check("csv: attachment disposition", "attachment" in disp3 and "filename*=" in disp3)
+    text = resp3.content.decode("utf-8-sig")
+    import csv as _csv
+    import io as _io
+    rows3 = list(_csv.reader(_io.StringIO(text)))
+    check("csv: header + 2 data rows", len(rows3) == 3)
+    check("csv: header has seq label", rows3[0][0] == "\u5e8f\u53f7")
+    check("csv: header has winding temp",
+          "\u7ed5\u7ec4\u6e29\u5ea6[C]" in rows3[0])
+    check("csv: row1 winding temp value",
+          abs(float(rows3[1][rows3[0].index("\u7ed5\u7ec4\u6e29\u5ea6[C]")]) - 52.59) < 1e-6)
+    check("csv: 404 for unknown plan",
+          client.get("/api/plans/99999/export-csv").status_code == 404)
+    check("csv: 400 when no results",
+          client.get(f"/api/plans/{empty_pk}/export-csv").status_code == 400)
+
 
 if __name__ == "__main__":
     unit_tests()

+ 85 - 27
web/backend/app/routers/plans.py

@@ -377,7 +377,7 @@ def export_plan_results_xlsx(plan_id: int, db: Session = Depends(get_db)):
     from starlette.background import BackgroundTask
 
     from ..metrics_constants import METRIC_KEYS as _METRIC_KEYS  # noqa: F401
-    from afmcore.xlsx_report import build_export_columns, write_summary_xlsx
+    from afmcore.xlsx_report import write_summary_xlsx
 
     plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
     if not plan:
@@ -392,7 +392,50 @@ def export_plan_results_xlsx(plan_id: int, db: Session = Depends(get_db)):
     if not results:
         raise HTTPException(status_code=400, detail="No simulation results to export")
 
-    # Parameter names + Chinese labels from the plan's scan variables.
+    col_keys, col_categories, col_headers, data_rows = _collect_export_rows(
+        plan, results
+    )
+
+    ts = datetime.now().strftime("%Y%m%d_%H%M%S")
+    tmp = tempfile.NamedTemporaryFile(
+        prefix=f"scan_results_{ts}_", suffix=".xlsx", delete=False
+    )
+    tmp.close()
+    write_summary_xlsx(tmp.name, col_keys, col_categories, col_headers, data_rows)
+
+    plan_name = (plan.name or f"plan_{plan_id}").strip() or f"plan_{plan_id}"
+    # RFC 5987: ASCII fallback + UTF-8 filename* for Chinese plan names.
+    ascii_fallback = f"scan_results_{plan_id}_{ts}.xlsx"
+    utf8_name = f"scan_results_{plan_name}_{ts}.xlsx"
+    disposition = (
+        f"attachment; filename=\"{ascii_fallback}\"; "
+        f"filename*=UTF-8''{quote(utf8_name)}"
+    )
+    return FileResponse(
+        tmp.name,
+        media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+        headers={"Content-Disposition": disposition},
+        background=BackgroundTask(os_remove_quiet, tmp.name),
+    )
+
+
+def os_remove_quiet(path: str) -> None:
+    """Best-effort temp-file cleanup after FileResponse streaming."""
+    import os
+
+    try:
+        os.remove(path)
+    except OSError:
+        pass
+
+
+def _collect_export_rows(plan, results):
+    """Shared row/column assembly for xlsx and csv exports.
+
+    Returns (col_keys, col_categories, col_headers, data_rows).
+    """
+    from afmcore.xlsx_report import build_export_columns
+
     param_labels: dict = {}
     try:
         plan_dict = plan.get_plan_dict()
@@ -426,47 +469,62 @@ def export_plan_results_xlsx(plan_id: int, db: Session = Depends(get_db)):
         row.update(metrics)
         data_rows.append(row)
 
-    # Keep only metric keys the platform knows about; unknown keys are still
-    # exported via the CAT_OTHER fallback inside build_export_columns.
     present_metrics = {k for k in present_metrics if isinstance(k, str)}
-
     col_keys, col_categories, col_headers = build_export_columns(
         param_names, sorted(present_metrics), param_labels
     )
+    return col_keys, col_categories, col_headers, data_rows
 
-    ts = datetime.now().strftime("%Y%m%d_%H%M%S")
-    tmp = tempfile.NamedTemporaryFile(
-        prefix=f"scan_results_{ts}_", suffix=".xlsx", delete=False
+
+@router.get("/{plan_id}/export-csv")
+def export_plan_results_csv(plan_id: int, db: Session = Depends(get_db)):
+    """Export plan simulation results as CSV (same columns as the xlsx export).
+
+    Single header row (Chinese labels with units); utf-8-sig BOM so Excel
+    opens the file with correct encoding.
+    """
+    import csv
+    import io
+    from urllib.parse import quote
+
+    from fastapi.responses import Response
+
+    plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
+    if not plan:
+        raise HTTPException(status_code=404, detail="Plan not found")
+
+    results = (
+        db.query(SimulationResult)
+        .filter(SimulationResult.plan_id == plan_id)
+        .order_by(SimulationResult.run_index.asc())
+        .all()
     )
-    tmp.close()
-    write_summary_xlsx(tmp.name, col_keys, col_categories, col_headers, data_rows)
+    if not results:
+        raise HTTPException(status_code=400, detail="No simulation results to export")
 
+    col_keys, _cats, col_headers, data_rows = _collect_export_rows(plan, results)
+
+    buf = io.StringIO()
+    writer = csv.writer(buf)
+    writer.writerow(col_headers)
+    for row in data_rows:
+        writer.writerow([row.get(k, "") for k in col_keys])
+
+    ts = datetime.now().strftime("%Y%m%d_%H%M%S")
     plan_name = (plan.name or f"plan_{plan_id}").strip() or f"plan_{plan_id}"
-    # RFC 5987: ASCII fallback + UTF-8 filename* for Chinese plan names.
-    ascii_fallback = f"scan_results_{plan_id}_{ts}.xlsx"
-    utf8_name = f"scan_results_{plan_name}_{ts}.xlsx"
+    ascii_fallback = f"scan_results_{plan_id}_{ts}.csv"
+    utf8_name = f"scan_results_{plan_name}_{ts}.csv"
     disposition = (
         f"attachment; filename=\"{ascii_fallback}\"; "
         f"filename*=UTF-8''{quote(utf8_name)}"
     )
-    return FileResponse(
-        tmp.name,
-        media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+    return Response(
+        content=buf.getvalue().encode("utf-8-sig"),
+        media_type="text/csv",
         headers={"Content-Disposition": disposition},
-        background=BackgroundTask(os_remove_quiet, tmp.name),
     )
 
 
-def os_remove_quiet(path: str) -> None:
-    """Best-effort temp-file cleanup after FileResponse streaming."""
-    import os
-
-    try:
-        os.remove(path)
-    except OSError:
-        pass
-
-
 # ---------------------------------------------------------------------------
 # One-click simulation start (P0-4)
 # ---------------------------------------------------------------------------

+ 4 - 1
web/frontend/src/views/PlanDetail.vue

@@ -1282,8 +1282,11 @@ const confirmUpload = async () => {
   }
 }
 
+// Download the result set as CSV (same columns as the Excel export).
+// NOTE: /api/plans/{id}/download returns the *plan definition* JSON for the
+// executor, not results — do not point this button at that endpoint.
 const downloadResults = () => {
-  window.open(`/api/plans/${planId.value}/download`, '_blank')
+  window.open(`/api/plans/${planId.value}/export-csv`, '_blank')
 }
 
 // Export the full result set (scan info + EM + thermal columns, two-row