export_service.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452
  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, PatternFill
  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. # Style for unsettled status highlighting (light orange #FFF2E8)
  16. UNSETTLED_FILL = PatternFill(start_color='FFF2E8', end_color='FFF2E8', fill_type='solid')
  17. @staticmethod
  18. def _apply_header_style(cell):
  19. """Apply header style to a cell."""
  20. cell.font = Font(bold=True)
  21. cell.alignment = Alignment(horizontal='center')
  22. thin_border = Border(
  23. left=Side(style='thin'),
  24. right=Side(style='thin'),
  25. top=Side(style='thin'),
  26. bottom=Side(style='thin')
  27. )
  28. cell.border = thin_border
  29. @staticmethod
  30. def _apply_cell_style(cell, is_number=False):
  31. """Apply standard cell style."""
  32. thin_border = Border(
  33. left=Side(style='thin'),
  34. right=Side(style='thin'),
  35. top=Side(style='thin'),
  36. bottom=Side(style='thin')
  37. )
  38. cell.border = thin_border
  39. if is_number:
  40. cell.alignment = Alignment(horizontal='right')
  41. @staticmethod
  42. def _auto_adjust_column_width(worksheet):
  43. """Auto-adjust column widths based on content."""
  44. for column_cells in worksheet.columns:
  45. max_length = 0
  46. column = column_cells[0].column_letter
  47. for cell in column_cells:
  48. try:
  49. if cell.value:
  50. cell_length = len(str(cell.value))
  51. if cell_length > max_length:
  52. max_length = cell_length
  53. except:
  54. pass
  55. adjusted_width = min(max_length + 2, 50)
  56. worksheet.column_dimensions[column].width = max(adjusted_width, 10)
  57. @staticmethod
  58. def _get_work_records_for_period(start_date, end_date):
  59. """Get work records for a date range.
  60. Args:
  61. start_date: Start date (inclusive)
  62. end_date: End date (inclusive)
  63. Returns:
  64. List of WorkRecord objects ordered by person name, date
  65. """
  66. return WorkRecord.query.filter(
  67. WorkRecord.work_date >= start_date,
  68. WorkRecord.work_date <= end_date
  69. ).join(Person).order_by(
  70. Person.name,
  71. WorkRecord.work_date
  72. ).all()
  73. @staticmethod
  74. def _create_detail_sheet(workbook, records, sheet_name='明细表'):
  75. """Create detail sheet with work records.
  76. Args:
  77. workbook: openpyxl Workbook
  78. records: List of WorkRecord objects
  79. sheet_name: Name for the sheet
  80. Returns:
  81. The created worksheet
  82. """
  83. ws = workbook.active
  84. ws.title = sheet_name
  85. # Write headers
  86. for col, header in enumerate(ExportService.DETAIL_HEADERS, 1):
  87. cell = ws.cell(row=1, column=col, value=header)
  88. ExportService._apply_header_style(cell)
  89. # Write data rows
  90. for row_idx, record in enumerate(records, 2):
  91. # Get supplier name (empty string if no supplier)
  92. supplier_name = record.item.supplier.name if record.item and record.item.supplier else ''
  93. # Get settlement status text
  94. settlement_status = '已结算' if record.is_settled else '未结算'
  95. data = [
  96. record.person.name,
  97. record.work_date.strftime('%Y-%m-%d'),
  98. supplier_name,
  99. record.item.name,
  100. record.item.unit_price,
  101. record.quantity,
  102. record.total_price,
  103. settlement_status
  104. ]
  105. for col_idx, value in enumerate(data, 1):
  106. cell = ws.cell(row=row_idx, column=col_idx, value=value)
  107. is_number = col_idx in [5, 6, 7] # unit_price, quantity, total_price
  108. ExportService._apply_cell_style(cell, is_number)
  109. # Apply unsettled fill to entire row if record is unsettled
  110. if not record.is_settled:
  111. cell.fill = ExportService.UNSETTLED_FILL
  112. ExportService._auto_adjust_column_width(ws)
  113. return ws
  114. @staticmethod
  115. def _create_monthly_summary_sheet(workbook, records):
  116. """Create monthly summary sheet grouped by person, supplier, and settlement status.
  117. Args:
  118. workbook: openpyxl Workbook
  119. records: List of WorkRecord objects
  120. Returns:
  121. The created worksheet
  122. """
  123. ws = workbook.create_sheet(title='月度汇总')
  124. # Write headers
  125. headers = ['人员', '供应商', '总金额', '结算状态']
  126. for col, header in enumerate(headers, 1):
  127. cell = ws.cell(row=1, column=col, value=header)
  128. ExportService._apply_header_style(cell)
  129. # Calculate totals by person, supplier, and settlement status
  130. person_supplier_settlement_totals = {}
  131. for record in records:
  132. person_name = record.person.name
  133. supplier_name = record.item.supplier.name if record.item and record.item.supplier else ''
  134. is_settled = record.is_settled
  135. key = (person_name, supplier_name, is_settled)
  136. if key not in person_supplier_settlement_totals:
  137. person_supplier_settlement_totals[key] = 0.0
  138. person_supplier_settlement_totals[key] += record.total_price
  139. # Write data rows (sorted by person name, then supplier name, then settlement status)
  140. row_idx = 2
  141. grand_total = 0.0
  142. settled_total = 0.0
  143. unsettled_total = 0.0
  144. for (person_name, supplier_name, is_settled) in sorted(person_supplier_settlement_totals.keys()):
  145. total = person_supplier_settlement_totals[(person_name, supplier_name, is_settled)]
  146. grand_total += total
  147. if is_settled:
  148. settled_total += total
  149. else:
  150. unsettled_total += total
  151. settlement_status = '已结算' if is_settled else '未结算'
  152. cell = ws.cell(row=row_idx, column=1, value=person_name)
  153. ExportService._apply_cell_style(cell)
  154. if not is_settled:
  155. cell.fill = ExportService.UNSETTLED_FILL
  156. cell = ws.cell(row=row_idx, column=2, value=supplier_name)
  157. ExportService._apply_cell_style(cell)
  158. if not is_settled:
  159. cell.fill = ExportService.UNSETTLED_FILL
  160. cell = ws.cell(row=row_idx, column=3, value=round(total, 2))
  161. ExportService._apply_cell_style(cell, is_number=True)
  162. if not is_settled:
  163. cell.fill = ExportService.UNSETTLED_FILL
  164. cell = ws.cell(row=row_idx, column=4, value=settlement_status)
  165. ExportService._apply_cell_style(cell)
  166. if not is_settled:
  167. cell.fill = ExportService.UNSETTLED_FILL
  168. row_idx += 1
  169. # Write grand total row
  170. cell = ws.cell(row=row_idx, column=1, value='合计')
  171. cell.font = Font(bold=True)
  172. ExportService._apply_cell_style(cell)
  173. cell = ws.cell(row=row_idx, column=2, value='')
  174. ExportService._apply_cell_style(cell)
  175. cell = ws.cell(row=row_idx, column=3, value=round(grand_total, 2))
  176. cell.font = Font(bold=True)
  177. ExportService._apply_cell_style(cell, is_number=True)
  178. cell = ws.cell(row=row_idx, column=4, value='')
  179. ExportService._apply_cell_style(cell)
  180. row_idx += 1
  181. # Write settled total row
  182. cell = ws.cell(row=row_idx, column=1, value='已结算')
  183. cell.font = Font(bold=True)
  184. ExportService._apply_cell_style(cell)
  185. cell = ws.cell(row=row_idx, column=2, value='')
  186. ExportService._apply_cell_style(cell)
  187. cell = ws.cell(row=row_idx, column=3, value=round(settled_total, 2))
  188. cell.font = Font(bold=True)
  189. ExportService._apply_cell_style(cell, is_number=True)
  190. cell = ws.cell(row=row_idx, column=4, value='')
  191. ExportService._apply_cell_style(cell)
  192. row_idx += 1
  193. # Write unsettled total row
  194. cell = ws.cell(row=row_idx, column=1, value='未结算')
  195. cell.font = Font(bold=True)
  196. ExportService._apply_cell_style(cell)
  197. cell.fill = ExportService.UNSETTLED_FILL
  198. cell = ws.cell(row=row_idx, column=2, value='')
  199. ExportService._apply_cell_style(cell)
  200. cell.fill = ExportService.UNSETTLED_FILL
  201. cell = ws.cell(row=row_idx, column=3, value=round(unsettled_total, 2))
  202. cell.font = Font(bold=True)
  203. ExportService._apply_cell_style(cell, is_number=True)
  204. cell.fill = ExportService.UNSETTLED_FILL
  205. cell = ws.cell(row=row_idx, column=4, value='')
  206. ExportService._apply_cell_style(cell)
  207. cell.fill = ExportService.UNSETTLED_FILL
  208. ExportService._auto_adjust_column_width(ws)
  209. return ws
  210. @staticmethod
  211. def _create_yearly_summary_sheet(workbook, records, year):
  212. """Create yearly summary sheet with monthly breakdown.
  213. Args:
  214. workbook: openpyxl Workbook
  215. records: List of WorkRecord objects
  216. year: The year being exported
  217. Returns:
  218. The created worksheet
  219. """
  220. ws = workbook.create_sheet(title='年度汇总')
  221. # Write headers: 人员, 1月, 2月, ..., 12月, 年度合计, 已结算, 未结算
  222. headers = ['人员'] + [f'{m}月' for m in range(1, 13)] + ['年度合计', '已结算', '未结算']
  223. for col, header in enumerate(headers, 1):
  224. cell = ws.cell(row=1, column=col, value=header)
  225. ExportService._apply_header_style(cell)
  226. # Apply unsettled fill to the "未结算" column header (column 16)
  227. if col == 16:
  228. cell.fill = ExportService.UNSETTLED_FILL
  229. # Calculate totals by person and month, plus settlement status
  230. person_monthly_totals = {}
  231. person_settlement_totals = {}
  232. for record in records:
  233. person_name = record.person.name
  234. month = record.work_date.month
  235. if person_name not in person_monthly_totals:
  236. person_monthly_totals[person_name] = {m: 0.0 for m in range(1, 13)}
  237. person_settlement_totals[person_name] = {'settled': 0.0, 'unsettled': 0.0}
  238. person_monthly_totals[person_name][month] += record.total_price
  239. # Track settlement status
  240. if record.is_settled:
  241. person_settlement_totals[person_name]['settled'] += record.total_price
  242. else:
  243. person_settlement_totals[person_name]['unsettled'] += record.total_price
  244. # Write data rows
  245. row_idx = 2
  246. monthly_grand_totals = {m: 0.0 for m in range(1, 13)}
  247. settled_grand_total = 0.0
  248. unsettled_grand_total = 0.0
  249. for person_name in sorted(person_monthly_totals.keys()):
  250. monthly_totals = person_monthly_totals[person_name]
  251. settlement_totals = person_settlement_totals[person_name]
  252. yearly_total = sum(monthly_totals.values())
  253. # Person name
  254. cell = ws.cell(row=row_idx, column=1, value=person_name)
  255. ExportService._apply_cell_style(cell)
  256. # Monthly values
  257. for month in range(1, 13):
  258. value = monthly_totals[month]
  259. monthly_grand_totals[month] += value
  260. cell = ws.cell(row=row_idx, column=month + 1, value=round(value, 2))
  261. ExportService._apply_cell_style(cell, is_number=True)
  262. # Yearly total
  263. cell = ws.cell(row=row_idx, column=14, value=round(yearly_total, 2))
  264. ExportService._apply_cell_style(cell, is_number=True)
  265. # Settled total
  266. settled = settlement_totals['settled']
  267. settled_grand_total += settled
  268. cell = ws.cell(row=row_idx, column=15, value=round(settled, 2))
  269. ExportService._apply_cell_style(cell, is_number=True)
  270. # Unsettled total
  271. unsettled = settlement_totals['unsettled']
  272. unsettled_grand_total += unsettled
  273. cell = ws.cell(row=row_idx, column=16, value=round(unsettled, 2))
  274. ExportService._apply_cell_style(cell, is_number=True)
  275. cell.fill = ExportService.UNSETTLED_FILL
  276. row_idx += 1
  277. # Write grand total row
  278. cell = ws.cell(row=row_idx, column=1, value='合计')
  279. cell.font = Font(bold=True)
  280. ExportService._apply_cell_style(cell)
  281. grand_total = 0.0
  282. for month in range(1, 13):
  283. value = monthly_grand_totals[month]
  284. grand_total += value
  285. cell = ws.cell(row=row_idx, column=month + 1, value=round(value, 2))
  286. cell.font = Font(bold=True)
  287. ExportService._apply_cell_style(cell, is_number=True)
  288. cell = ws.cell(row=row_idx, column=14, value=round(grand_total, 2))
  289. cell.font = Font(bold=True)
  290. ExportService._apply_cell_style(cell, is_number=True)
  291. # Settled grand total
  292. cell = ws.cell(row=row_idx, column=15, value=round(settled_grand_total, 2))
  293. cell.font = Font(bold=True)
  294. ExportService._apply_cell_style(cell, is_number=True)
  295. # Unsettled grand total
  296. cell = ws.cell(row=row_idx, column=16, value=round(unsettled_grand_total, 2))
  297. cell.font = Font(bold=True)
  298. ExportService._apply_cell_style(cell, is_number=True)
  299. cell.fill = ExportService.UNSETTLED_FILL
  300. ExportService._auto_adjust_column_width(ws)
  301. return ws
  302. @staticmethod
  303. def export_monthly(year, month):
  304. """Export monthly work records to Excel.
  305. Args:
  306. year: Year (e.g., 2024)
  307. month: Month (1-12)
  308. Returns:
  309. Tuple of (BytesIO containing Excel file, error_message)
  310. On success: (excel_bytes, None)
  311. On failure: (None, error_message)
  312. """
  313. # Validate inputs
  314. if not isinstance(year, int) or year < 1900 or year > 9999:
  315. return None, "年份无效,必须在 1900 到 9999 之间"
  316. if not isinstance(month, int) or month < 1 or month > 12:
  317. return None, "月份无效,必须在 1 到 12 之间"
  318. # Calculate date range
  319. start_date = date(year, month, 1)
  320. _, last_day = monthrange(year, month)
  321. end_date = date(year, month, last_day)
  322. # Get work records
  323. records = ExportService._get_work_records_for_period(start_date, end_date)
  324. # Create workbook
  325. workbook = Workbook()
  326. # Create detail sheet
  327. ExportService._create_detail_sheet(workbook, records, f'{year}年{month}月明细')
  328. # Create summary sheet
  329. ExportService._create_monthly_summary_sheet(workbook, records)
  330. # Save to BytesIO
  331. output = BytesIO()
  332. workbook.save(output)
  333. output.seek(0)
  334. return output, None
  335. @staticmethod
  336. def export_yearly(year):
  337. """Export yearly work records to Excel.
  338. Args:
  339. year: Year (e.g., 2024)
  340. Returns:
  341. Tuple of (BytesIO containing Excel file, error_message)
  342. On success: (excel_bytes, None)
  343. On failure: (None, error_message)
  344. """
  345. # Validate inputs
  346. if not isinstance(year, int) or year < 1900 or year > 9999:
  347. return None, "年份无效,必须在 1900 到 9999 之间"
  348. # Calculate date range
  349. start_date = date(year, 1, 1)
  350. end_date = date(year, 12, 31)
  351. # Get work records
  352. records = ExportService._get_work_records_for_period(start_date, end_date)
  353. # Create workbook
  354. workbook = Workbook()
  355. # Create detail sheet
  356. ExportService._create_detail_sheet(workbook, records, f'{year}年明细')
  357. # Create yearly summary sheet with monthly breakdown
  358. ExportService._create_yearly_summary_sheet(workbook, records, year)
  359. # Save to BytesIO
  360. output = BytesIO()
  361. workbook.save(output)
  362. output.seek(0)
  363. return output, None