Files
dpb/backend/app/utils/excel_util.py
T

143 lines
5.1 KiB
Python
Raw Normal View History

import io
from datetime import datetime, timezone
from typing import Any
from openpyxl import Workbook, load_workbook
from openpyxl.styles import Alignment, PatternFill
from openpyxl.utils import get_column_letter
from openpyxl.worksheet.datavalidation import DataValidation
def _xlsx_safe_value(value: Any) -> Any:
"""把 openpyxl 无法直接序列化的值归一为可写值。
- 带时区的 datetime(如审计列 created_time/updated_timeDB 为 timezone=True
归一为 UTC naive,否则 openpyxl 保存时抛 TypeError,导致导出整表失败。
"""
if isinstance(value, datetime) and value.tzinfo is not None:
return value.astimezone(timezone.utc).replace(tzinfo=None)
return value
class ExcelUtil:
"""Excel 模板生成与列表导出(openpyxl)。"""
@staticmethod
def read_excel_to_dicts(contents: bytes) -> list[dict[str, Any]]:
"""读取 Excel 文件字节,返回字典列表(首行为列名)。"""
wb = load_workbook(io.BytesIO(contents), read_only=True, data_only=True)
ws = wb.active
if not ws:
raise ValueError("工作簿没有活动工作表")
headers = [cell.value for cell in next(ws.iter_rows(min_row=1, max_row=1))]
result: list[dict[str, Any]] = []
for row in ws.iter_rows(min_row=2, values_only=True):
if all(cell is None for cell in row):
continue
row_dict: dict[str, Any] = {}
for i, val in enumerate(row):
if i < len(headers) and headers[i] is not None:
row_dict[str(headers[i])] = val
if row_dict:
result.append(row_dict)
wb.close()
return result
@classmethod
def __mapping_list(cls, list_data: list[dict[str, Any]], mapping_dict: dict) -> list[dict[str, Any]]:
"""将列表数据中的字段名映射为对应的中文字段名。
参数:
- list_data: 数据列表。
- mapping_dict: 字段名映射字典 {英文key: 中文表头}。
返回:
- list[dict]: 映射后的数据列表 [{中文表头: value}]。
"""
return [{str(mapping_dict.get(key)): item.get(key) for key in mapping_dict} for item in list_data]
@classmethod
def get_excel_template(
cls,
header_list: list[str],
selector_header_list: list[str],
option_list: list[dict[str, list[str]]],
) -> bytes:
"""生成 Excel 模板文件。
参数:
- header_list: 表头列表。
- selector_header_list: 需要设置下拉选择的表头列表。
- option_list: 下拉选项配置列表。
返回:
- bytes: Excel 文件的二进制数据。
"""
wb = Workbook()
ws = wb.active
if not ws:
raise ValueError("不存在活动工作表")
header_fill = PatternFill(start_color="ababab", end_color="ababab", fill_type="solid")
for col_num, header in enumerate(header_list, 1):
cell = ws.cell(row=1, column=col_num)
cell.value = header # pyright: ignore[reportAttributeAccessIssue]
cell.fill = header_fill
cell.alignment = Alignment(horizontal="center")
ws.column_dimensions[get_column_letter(col_num)].width = 12
for selector_header in selector_header_list:
col_idx = header_list.index(selector_header) + 1
header_options = next(
(opt.get(selector_header) for opt in option_list if selector_header in opt),
[],
)
if header_options:
dv = DataValidation(type="list", formula1=f'"{",".join(header_options)}"')
dv.add(f"{get_column_letter(col_idx)}2:{get_column_letter(col_idx)}1048576")
ws.add_data_validation(dv)
buffer = io.BytesIO()
wb.save(buffer)
buffer.seek(0)
return buffer.getvalue()
@classmethod
def export_list2excel(cls, list_data: list[dict[str, Any]], mapping_dict: dict) -> bytes:
"""将列表数据导出为 Excel 文件。
参数:
- list_data: 要导出的数据列表。
- mapping_dict: 字段名映射字典 {英文key: 中文表头}。
返回:
- bytes: Excel 文件的二进制数据。
限制:
- 最多导出 100000 条记录,超出部分截断。
"""
max_rows = 100000
if len(list_data) > max_rows:
list_data = list_data[:max_rows]
mapping_data = cls.__mapping_list(list_data, mapping_dict)
headers = list(mapping_dict.values())
wb = Workbook()
ws = wb.active
if not ws:
raise ValueError("不存在活动工作表")
for col_num, header in enumerate(headers, 1):
ws.cell(row=1, column=col_num, value=header)
for row_num, row_data in enumerate(mapping_data, 2):
for col_num, header in enumerate(headers, 1):
ws.cell(row=row_num, column=col_num, value=_xlsx_safe_value(row_data.get(header)))
buffer = io.BytesIO()
wb.save(buffer)
buffer.seek(0)
return buffer.getvalue()