| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173 |
- """Excel 导出工具:集中处理字典 code → label(对齐 Java ExcelDictConvert / ExcelDictPreloadConvert)。"""
- from __future__ import annotations
- import io
- from dataclasses import dataclass
- from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional, Sequence
- from urllib.parse import quote
- from fastapi.responses import StreamingResponse
- from sqlalchemy.orm import Session
- from app.models.models import SysDictData
- CellFormatter = Callable[[Any], Any]
- EXPORT_MAX_ROWS = 50000
- class ExportValidationError(ValueError):
- """导出业务校验失败(空数据 / 超限等),路由层转为 400。"""
- @dataclass(frozen=True)
- class ExportColumn:
- """导出列定义,dict_type 非空时自动将字典 value 转为 label。"""
- title: str
- field: str
- dict_type: Optional[str] = None
- formatter: Optional[CellFormatter] = None
- def load_dict_label_maps(
- db: Session,
- dict_types: Iterable[str],
- tenant_no: Optional[str] = None,
- ) -> Dict[str, Dict[str, str]]:
- """预加载多个字典类型的 value→label 映射(单次查询,避免 N+1)。"""
- types = sorted({t.strip() for t in dict_types if t and str(t).strip()})
- if not types:
- return {}
- q = db.query(SysDictData).filter(
- SysDictData.del_flag == 0,
- SysDictData.dict_type.in_(types),
- )
- if tenant_no:
- q = q.filter(SysDictData.tenant_no == tenant_no)
- maps: Dict[str, Dict[str, str]] = {t: {} for t in types}
- for row in q.all():
- dtype = row.dict_type
- if dtype not in maps:
- continue
- value = row.dict_value
- if value is None:
- continue
- maps[dtype][str(value)] = row.dict_label or str(value)
- return maps
- class DictLabelResolver:
- """导出单元格解析:按列配置做字典翻译或自定义格式化。"""
- def __init__(
- self,
- db: Session,
- columns: Sequence[ExportColumn],
- tenant_no: Optional[str] = None,
- label_maps: Optional[Mapping[str, Mapping[str, str]]] = None,
- ):
- dict_types = [c.dict_type for c in columns if c.dict_type]
- self._maps = dict(label_maps) if label_maps is not None else load_dict_label_maps(
- db, dict_types, tenant_no
- )
- def resolve(self, row: Mapping[str, Any], column: ExportColumn) -> Any:
- raw = row.get(column.field, "")
- if column.formatter is not None:
- return column.formatter(raw)
- if column.dict_type and raw not in (None, ""):
- label = self._maps.get(column.dict_type, {}).get(str(raw))
- if label is not None:
- return label
- if raw is None:
- return ""
- return raw
- def row_to_cells(self, row: Mapping[str, Any], columns: Sequence[ExportColumn]) -> List[Any]:
- return [self.resolve(row, col) for col in columns]
- def _build_workbook(
- sheet_title: str,
- columns: Sequence[ExportColumn],
- rows: Sequence[Mapping[str, Any]],
- resolver: DictLabelResolver,
- ):
- from openpyxl import Workbook
- wb = Workbook()
- ws = wb.active
- ws.title = sheet_title
- ws.append([c.title for c in columns])
- for row in rows:
- ws.append(resolver.row_to_cells(row, columns))
- return wb
- def workbook_to_streaming_response(
- wb,
- *,
- filename_stem: str,
- include_legacy_filename: bool = True,
- ) -> StreamingResponse:
- output = io.BytesIO()
- wb.save(output)
- output.seek(0)
- encoded = quote(f"{filename_stem}.xlsx")
- disposition = f"attachment; filename*=UTF-8''{encoded}"
- if include_legacy_filename:
- disposition = f"attachment; filename={encoded}; {disposition}"
- return StreamingResponse(
- output,
- media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
- headers={"Content-Disposition": disposition},
- )
- def filter_export_columns(
- all_columns: Sequence[ExportColumn],
- requested: Optional[Sequence[str]],
- ) -> List[ExportColumn]:
- """按前端可见列 prop 裁剪;未知 field 忽略;requested 为空则返回全部默认列。"""
- if not requested:
- return list(all_columns)
- by_field = {c.field: c for c in all_columns}
- out: List[ExportColumn] = []
- for key in requested:
- col = by_field.get(str(key))
- if col is not None:
- out.append(col)
- return out if out else list(all_columns)
- def assert_export_row_count(count: int) -> None:
- if count <= 0:
- raise ExportValidationError("没有可导出的数据")
- if count > EXPORT_MAX_ROWS:
- raise ExportValidationError(f"导出超过 {EXPORT_MAX_ROWS} 条,请缩小筛选范围")
- def export_rows_to_xlsx_response(
- db: Session,
- *,
- tenant_no: Optional[str],
- sheet_title: str,
- columns: Sequence[ExportColumn],
- rows: Sequence[Mapping[str, Any]],
- filename_stem: str,
- include_legacy_filename: bool = True,
- ) -> StreamingResponse:
- """CRM 列表导出统一入口:表头 + 行数据 + 字典 label 转换 + 文件流响应。"""
- assert_export_row_count(len(rows))
- if not columns:
- raise ExportValidationError("没有可导出的列")
- resolver = DictLabelResolver(db, columns, tenant_no=tenant_no)
- wb = _build_workbook(sheet_title, columns, rows, resolver)
- return workbook_to_streaming_response(
- wb,
- filename_stem=filename_stem,
- include_legacy_filename=include_legacy_filename,
- )
|