excel_export.py 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  1. """Excel 导出工具:集中处理字典 code → label(对齐 Java ExcelDictConvert / ExcelDictPreloadConvert)。"""
  2. from __future__ import annotations
  3. import io
  4. from dataclasses import dataclass
  5. from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional, Sequence
  6. from urllib.parse import quote
  7. from fastapi.responses import StreamingResponse
  8. from sqlalchemy.orm import Session
  9. from app.models.models import SysDictData
  10. CellFormatter = Callable[[Any], Any]
  11. EXPORT_MAX_ROWS = 50000
  12. class ExportValidationError(ValueError):
  13. """导出业务校验失败(空数据 / 超限等),路由层转为 400。"""
  14. @dataclass(frozen=True)
  15. class ExportColumn:
  16. """导出列定义,dict_type 非空时自动将字典 value 转为 label。"""
  17. title: str
  18. field: str
  19. dict_type: Optional[str] = None
  20. formatter: Optional[CellFormatter] = None
  21. def load_dict_label_maps(
  22. db: Session,
  23. dict_types: Iterable[str],
  24. tenant_no: Optional[str] = None,
  25. ) -> Dict[str, Dict[str, str]]:
  26. """预加载多个字典类型的 value→label 映射(单次查询,避免 N+1)。"""
  27. types = sorted({t.strip() for t in dict_types if t and str(t).strip()})
  28. if not types:
  29. return {}
  30. q = db.query(SysDictData).filter(
  31. SysDictData.del_flag == 0,
  32. SysDictData.dict_type.in_(types),
  33. )
  34. if tenant_no:
  35. q = q.filter(SysDictData.tenant_no == tenant_no)
  36. maps: Dict[str, Dict[str, str]] = {t: {} for t in types}
  37. for row in q.all():
  38. dtype = row.dict_type
  39. if dtype not in maps:
  40. continue
  41. value = row.dict_value
  42. if value is None:
  43. continue
  44. maps[dtype][str(value)] = row.dict_label or str(value)
  45. return maps
  46. class DictLabelResolver:
  47. """导出单元格解析:按列配置做字典翻译或自定义格式化。"""
  48. def __init__(
  49. self,
  50. db: Session,
  51. columns: Sequence[ExportColumn],
  52. tenant_no: Optional[str] = None,
  53. label_maps: Optional[Mapping[str, Mapping[str, str]]] = None,
  54. ):
  55. dict_types = [c.dict_type for c in columns if c.dict_type]
  56. self._maps = dict(label_maps) if label_maps is not None else load_dict_label_maps(
  57. db, dict_types, tenant_no
  58. )
  59. def resolve(self, row: Mapping[str, Any], column: ExportColumn) -> Any:
  60. raw = row.get(column.field, "")
  61. if column.formatter is not None:
  62. return column.formatter(raw)
  63. if column.dict_type and raw not in (None, ""):
  64. label = self._maps.get(column.dict_type, {}).get(str(raw))
  65. if label is not None:
  66. return label
  67. if raw is None:
  68. return ""
  69. return raw
  70. def row_to_cells(self, row: Mapping[str, Any], columns: Sequence[ExportColumn]) -> List[Any]:
  71. return [self.resolve(row, col) for col in columns]
  72. def _build_workbook(
  73. sheet_title: str,
  74. columns: Sequence[ExportColumn],
  75. rows: Sequence[Mapping[str, Any]],
  76. resolver: DictLabelResolver,
  77. ):
  78. from openpyxl import Workbook
  79. wb = Workbook()
  80. ws = wb.active
  81. ws.title = sheet_title
  82. ws.append([c.title for c in columns])
  83. for row in rows:
  84. ws.append(resolver.row_to_cells(row, columns))
  85. return wb
  86. def workbook_to_streaming_response(
  87. wb,
  88. *,
  89. filename_stem: str,
  90. include_legacy_filename: bool = True,
  91. ) -> StreamingResponse:
  92. output = io.BytesIO()
  93. wb.save(output)
  94. output.seek(0)
  95. encoded = quote(f"{filename_stem}.xlsx")
  96. disposition = f"attachment; filename*=UTF-8''{encoded}"
  97. if include_legacy_filename:
  98. disposition = f"attachment; filename={encoded}; {disposition}"
  99. return StreamingResponse(
  100. output,
  101. media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
  102. headers={"Content-Disposition": disposition},
  103. )
  104. def filter_export_columns(
  105. all_columns: Sequence[ExportColumn],
  106. requested: Optional[Sequence[str]],
  107. ) -> List[ExportColumn]:
  108. """按前端可见列 prop 裁剪;未知 field 忽略;requested 为空则返回全部默认列。"""
  109. if not requested:
  110. return list(all_columns)
  111. by_field = {c.field: c for c in all_columns}
  112. out: List[ExportColumn] = []
  113. for key in requested:
  114. col = by_field.get(str(key))
  115. if col is not None:
  116. out.append(col)
  117. return out if out else list(all_columns)
  118. def assert_export_row_count(count: int) -> None:
  119. if count <= 0:
  120. raise ExportValidationError("没有可导出的数据")
  121. if count > EXPORT_MAX_ROWS:
  122. raise ExportValidationError(f"导出超过 {EXPORT_MAX_ROWS} 条,请缩小筛选范围")
  123. def export_rows_to_xlsx_response(
  124. db: Session,
  125. *,
  126. tenant_no: Optional[str],
  127. sheet_title: str,
  128. columns: Sequence[ExportColumn],
  129. rows: Sequence[Mapping[str, Any]],
  130. filename_stem: str,
  131. include_legacy_filename: bool = True,
  132. ) -> StreamingResponse:
  133. """CRM 列表导出统一入口:表头 + 行数据 + 字典 label 转换 + 文件流响应。"""
  134. assert_export_row_count(len(rows))
  135. if not columns:
  136. raise ExportValidationError("没有可导出的列")
  137. resolver = DictLabelResolver(db, columns, tenant_no=tenant_no)
  138. wb = _build_workbook(sheet_title, columns, rows, resolver)
  139. return workbook_to_streaming_response(
  140. wb,
  141. filename_stem=filename_stem,
  142. include_legacy_filename=include_legacy_filename,
  143. )