export_service.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  1. """Export service for generating Excel reports."""
  2. from datetime import date
  3. from io import BytesIO
  4. from calendar import monthrange
  5. from openpyxl import Workbook
  6. from openpyxl.styles import Font, Alignment, Border, Side
  7. from openpyxl.utils import get_column_letter
  8. from app import db
  9. from app.models.work_record import WorkRecord
  10. from app.models.person import Person
  11. class ExportService:
  12. """Service class for Excel export operations."""
  13. # Column headers for detail sheet
  14. DETAIL_HEADERS = ['人员', '日期', '物品', '单价', '数量', '总价']
  15. @staticmethod
  16. def _apply_header_style(cell):
  17. """Apply header style to a cell."""
  18. cell.font = Font(bold=True)
  19. cell.alignment = Alignment(horizontal='center')
  20. thin_border = Border(
  21. left=Side(style='thin'),
  22. right=Side(style='thin'),
  23. top=Side(style='thin'),
  24. bottom=Side(style='thin')
  25. )
  26. cell.border = thin_border
  27. @staticmethod
  28. def _apply_cell_style(cell, is_number=False):
  29. """Apply standard cell style."""
  30. thin_border = Border(
  31. left=Side(style='thin'),
  32. right=Side(style='thin'),
  33. top=Side(style='thin'),
  34. bottom=Side(style='thin')
  35. )
  36. cell.border = thin_border
  37. if is_number:
  38. cell.alignment = Alignment(horizontal='right')
  39. @staticmethod
  40. def _auto_adjust_column_width(worksheet):
  41. """Auto-adjust column widths based on content."""
  42. for column_cells in worksheet.columns:
  43. max_length = 0
  44. column = column_cells[0].column_letter
  45. for cell in column_cells:
  46. try:
  47. if cell.value:
  48. cell_length = len(str(cell.value))
  49. if cell_length > max_length:
  50. max_length = cell_length
  51. except:
  52. pass
  53. adjusted_width = min(max_length + 2, 50)
  54. worksheet.column_dimensions[column].width = max(adjusted_width, 10)
  55. @staticmethod
  56. def _get_work_records_for_period(start_date, end_date):
  57. """Get work records for a date range.
  58. Args:
  59. start_date: Start date (inclusive)
  60. end_date: End date (inclusive)
  61. Returns:
  62. List of WorkRecord objects ordered by person name, date
  63. """
  64. return WorkRecord.query.filter(
  65. WorkRecord.work_date >= start_date,
  66. WorkRecord.work_date <= end_date
  67. ).join(Person).order_by(
  68. Person.name,
  69. WorkRecord.work_date
  70. ).all()
  71. @staticmethod
  72. def _create_detail_sheet(workbook, records, sheet_name='明细表'):
  73. """Create detail sheet with work records.
  74. Args:
  75. workbook: openpyxl Workbook
  76. records: List of WorkRecord objects
  77. sheet_name: Name for the sheet
  78. Returns:
  79. The created worksheet
  80. """
  81. ws = workbook.active
  82. ws.title = sheet_name
  83. # Write headers
  84. for col, header in enumerate(ExportService.DETAIL_HEADERS, 1):
  85. cell = ws.cell(row=1, column=col, value=header)
  86. ExportService._apply_header_style(cell)
  87. # Write data rows
  88. for row_idx, record in enumerate(records, 2):
  89. data = [
  90. record.person.name,
  91. record.work_date.strftime('%Y-%m-%d'),
  92. record.item.name,
  93. record.item.unit_price,
  94. record.quantity,
  95. record.total_price
  96. ]
  97. for col_idx, value in enumerate(data, 1):
  98. cell = ws.cell(row=row_idx, column=col_idx, value=value)
  99. is_number = col_idx in [4, 5, 6] # unit_price, quantity, total_price
  100. ExportService._apply_cell_style(cell, is_number)
  101. ExportService._auto_adjust_column_width(ws)
  102. return ws
  103. @staticmethod
  104. def _create_monthly_summary_sheet(workbook, records):
  105. """Create monthly summary sheet grouped by person.
  106. Args:
  107. workbook: openpyxl Workbook
  108. records: List of WorkRecord objects
  109. Returns:
  110. The created worksheet
  111. """
  112. ws = workbook.create_sheet(title='月度汇总')
  113. # Write headers
  114. headers = ['人员', '总金额']
  115. for col, header in enumerate(headers, 1):
  116. cell = ws.cell(row=1, column=col, value=header)
  117. ExportService._apply_header_style(cell)
  118. # Calculate totals by person
  119. person_totals = {}
  120. for record in records:
  121. person_name = record.person.name
  122. if person_name not in person_totals:
  123. person_totals[person_name] = 0.0
  124. person_totals[person_name] += record.total_price
  125. # Write data rows
  126. row_idx = 2
  127. grand_total = 0.0
  128. for person_name in sorted(person_totals.keys()):
  129. total = person_totals[person_name]
  130. grand_total += total
  131. cell = ws.cell(row=row_idx, column=1, value=person_name)
  132. ExportService._apply_cell_style(cell)
  133. cell = ws.cell(row=row_idx, column=2, value=round(total, 2))
  134. ExportService._apply_cell_style(cell, is_number=True)
  135. row_idx += 1
  136. # Write grand total row
  137. cell = ws.cell(row=row_idx, column=1, value='合计')
  138. cell.font = Font(bold=True)
  139. ExportService._apply_cell_style(cell)
  140. cell = ws.cell(row=row_idx, column=2, value=round(grand_total, 2))
  141. cell.font = Font(bold=True)
  142. ExportService._apply_cell_style(cell, is_number=True)
  143. ExportService._auto_adjust_column_width(ws)
  144. return ws
  145. @staticmethod
  146. def _create_yearly_summary_sheet(workbook, records, year):
  147. """Create yearly summary sheet with monthly breakdown.
  148. Args:
  149. workbook: openpyxl Workbook
  150. records: List of WorkRecord objects
  151. year: The year being exported
  152. Returns:
  153. The created worksheet
  154. """
  155. ws = workbook.create_sheet(title='年度汇总')
  156. # Write headers: 人员, 1月, 2月, ..., 12月, 年度合计
  157. headers = ['人员'] + [f'{m}月' for m in range(1, 13)] + ['年度合计']
  158. for col, header in enumerate(headers, 1):
  159. cell = ws.cell(row=1, column=col, value=header)
  160. ExportService._apply_header_style(cell)
  161. # Calculate totals by person and month
  162. person_monthly_totals = {}
  163. for record in records:
  164. person_name = record.person.name
  165. month = record.work_date.month
  166. if person_name not in person_monthly_totals:
  167. person_monthly_totals[person_name] = {m: 0.0 for m in range(1, 13)}
  168. person_monthly_totals[person_name][month] += record.total_price
  169. # Write data rows
  170. row_idx = 2
  171. monthly_grand_totals = {m: 0.0 for m in range(1, 13)}
  172. for person_name in sorted(person_monthly_totals.keys()):
  173. monthly_totals = person_monthly_totals[person_name]
  174. yearly_total = sum(monthly_totals.values())
  175. # Person name
  176. cell = ws.cell(row=row_idx, column=1, value=person_name)
  177. ExportService._apply_cell_style(cell)
  178. # Monthly values
  179. for month in range(1, 13):
  180. value = monthly_totals[month]
  181. monthly_grand_totals[month] += value
  182. cell = ws.cell(row=row_idx, column=month + 1, value=round(value, 2))
  183. ExportService._apply_cell_style(cell, is_number=True)
  184. # Yearly total
  185. cell = ws.cell(row=row_idx, column=14, value=round(yearly_total, 2))
  186. ExportService._apply_cell_style(cell, is_number=True)
  187. row_idx += 1
  188. # Write grand total row
  189. cell = ws.cell(row=row_idx, column=1, value='合计')
  190. cell.font = Font(bold=True)
  191. ExportService._apply_cell_style(cell)
  192. grand_total = 0.0
  193. for month in range(1, 13):
  194. value = monthly_grand_totals[month]
  195. grand_total += value
  196. cell = ws.cell(row=row_idx, column=month + 1, value=round(value, 2))
  197. cell.font = Font(bold=True)
  198. ExportService._apply_cell_style(cell, is_number=True)
  199. cell = ws.cell(row=row_idx, column=14, value=round(grand_total, 2))
  200. cell.font = Font(bold=True)
  201. ExportService._apply_cell_style(cell, is_number=True)
  202. ExportService._auto_adjust_column_width(ws)
  203. return ws
  204. @staticmethod
  205. def export_monthly(year, month):
  206. """Export monthly work records to Excel.
  207. Args:
  208. year: Year (e.g., 2024)
  209. month: Month (1-12)
  210. Returns:
  211. Tuple of (BytesIO containing Excel file, error_message)
  212. On success: (excel_bytes, None)
  213. On failure: (None, error_message)
  214. """
  215. # Validate inputs
  216. if not isinstance(year, int) or year < 1900 or year > 9999:
  217. return None, "年份无效,必须在 1900 到 9999 之间"
  218. if not isinstance(month, int) or month < 1 or month > 12:
  219. return None, "月份无效,必须在 1 到 12 之间"
  220. # Calculate date range
  221. start_date = date(year, month, 1)
  222. _, last_day = monthrange(year, month)
  223. end_date = date(year, month, last_day)
  224. # Get work records
  225. records = ExportService._get_work_records_for_period(start_date, end_date)
  226. # Create workbook
  227. workbook = Workbook()
  228. # Create detail sheet
  229. ExportService._create_detail_sheet(workbook, records, f'{year}年{month}月明细')
  230. # Create summary sheet
  231. ExportService._create_monthly_summary_sheet(workbook, records)
  232. # Save to BytesIO
  233. output = BytesIO()
  234. workbook.save(output)
  235. output.seek(0)
  236. return output, None
  237. @staticmethod
  238. def export_yearly(year):
  239. """Export yearly work records to Excel.
  240. Args:
  241. year: Year (e.g., 2024)
  242. Returns:
  243. Tuple of (BytesIO containing Excel file, error_message)
  244. On success: (excel_bytes, None)
  245. On failure: (None, error_message)
  246. """
  247. # Validate inputs
  248. if not isinstance(year, int) or year < 1900 or year > 9999:
  249. return None, "年份无效,必须在 1900 到 9999 之间"
  250. # Calculate date range
  251. start_date = date(year, 1, 1)
  252. end_date = date(year, 12, 31)
  253. # Get work records
  254. records = ExportService._get_work_records_for_period(start_date, end_date)
  255. # Create workbook
  256. workbook = Workbook()
  257. # Create detail sheet
  258. ExportService._create_detail_sheet(workbook, records, f'{year}年明细')
  259. # Create yearly summary sheet with monthly breakdown
  260. ExportService._create_yearly_summary_sheet(workbook, records, year)
  261. # Save to BytesIO
  262. output = BytesIO()
  263. workbook.save(output)
  264. output.seek(0)
  265. return output, None