excel_export.py 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141
  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. @dataclass(frozen=True)
  12. class ExportColumn:
  13. """导出列定义,dict_type 非空时自动将字典 value 转为 label。"""
  14. title: str
  15. field: str
  16. dict_type: Optional[str] = None
  17. formatter: Optional[CellFormatter] = None
  18. def load_dict_label_maps(
  19. db: Session,
  20. dict_types: Iterable[str],
  21. tenant_no: Optional[str] = None,
  22. ) -> Dict[str, Dict[str, str]]:
  23. """预加载多个字典类型的 value→label 映射(单次查询,避免 N+1)。"""
  24. types = sorted({t.strip() for t in dict_types if t and str(t).strip()})
  25. if not types:
  26. return {}
  27. q = db.query(SysDictData).filter(
  28. SysDictData.del_flag == 0,
  29. SysDictData.dict_type.in_(types),
  30. )
  31. if tenant_no:
  32. q = q.filter(SysDictData.tenant_no == tenant_no)
  33. maps: Dict[str, Dict[str, str]] = {t: {} for t in types}
  34. for row in q.all():
  35. dtype = row.dict_type
  36. if dtype not in maps:
  37. continue
  38. value = row.dict_value
  39. if value is None:
  40. continue
  41. maps[dtype][str(value)] = row.dict_label or str(value)
  42. return maps
  43. class DictLabelResolver:
  44. """导出单元格解析:按列配置做字典翻译或自定义格式化。"""
  45. def __init__(
  46. self,
  47. db: Session,
  48. columns: Sequence[ExportColumn],
  49. tenant_no: Optional[str] = None,
  50. label_maps: Optional[Mapping[str, Mapping[str, str]]] = None,
  51. ):
  52. dict_types = [c.dict_type for c in columns if c.dict_type]
  53. self._maps = dict(label_maps) if label_maps is not None else load_dict_label_maps(
  54. db, dict_types, tenant_no
  55. )
  56. def resolve(self, row: Mapping[str, Any], column: ExportColumn) -> Any:
  57. raw = row.get(column.field, "")
  58. if column.formatter is not None:
  59. return column.formatter(raw)
  60. if column.dict_type and raw not in (None, ""):
  61. label = self._maps.get(column.dict_type, {}).get(str(raw))
  62. if label is not None:
  63. return label
  64. if raw is None:
  65. return ""
  66. return raw
  67. def row_to_cells(self, row: Mapping[str, Any], columns: Sequence[ExportColumn]) -> List[Any]:
  68. return [self.resolve(row, col) for col in columns]
  69. def _build_workbook(
  70. sheet_title: str,
  71. columns: Sequence[ExportColumn],
  72. rows: Sequence[Mapping[str, Any]],
  73. resolver: DictLabelResolver,
  74. ):
  75. from openpyxl import Workbook
  76. wb = Workbook()
  77. ws = wb.active
  78. ws.title = sheet_title
  79. ws.append([c.title for c in columns])
  80. for row in rows:
  81. ws.append(resolver.row_to_cells(row, columns))
  82. return wb
  83. def workbook_to_streaming_response(
  84. wb,
  85. *,
  86. filename_stem: str,
  87. include_legacy_filename: bool = True,
  88. ) -> StreamingResponse:
  89. output = io.BytesIO()
  90. wb.save(output)
  91. output.seek(0)
  92. encoded = quote(f"{filename_stem}.xlsx")
  93. disposition = f"attachment; filename*=UTF-8''{encoded}"
  94. if include_legacy_filename:
  95. disposition = f"attachment; filename={encoded}; {disposition}"
  96. return StreamingResponse(
  97. output,
  98. media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
  99. headers={"Content-Disposition": disposition},
  100. )
  101. def export_rows_to_xlsx_response(
  102. db: Session,
  103. *,
  104. tenant_no: Optional[str],
  105. sheet_title: str,
  106. columns: Sequence[ExportColumn],
  107. rows: Sequence[Mapping[str, Any]],
  108. filename_stem: str,
  109. include_legacy_filename: bool = True,
  110. ) -> StreamingResponse:
  111. """CRM 列表导出统一入口:表头 + 行数据 + 字典 label 转换 + 文件流响应。"""
  112. resolver = DictLabelResolver(db, columns, tenant_no=tenant_no)
  113. wb = _build_workbook(sheet_title, columns, rows, resolver)
  114. return workbook_to_streaming_response(
  115. wb,
  116. filename_stem=filename_stem,
  117. include_legacy_filename=include_legacy_filename,
  118. )