diff --git a/app/modules/expense/router.py b/app/modules/expense/router.py index 9dab588..1dcc350 100644 --- a/app/modules/expense/router.py +++ b/app/modules/expense/router.py @@ -402,12 +402,52 @@ async def approved_page(request: Request) -> HTMLResponse: ) +def _xl_display_len(value: Any) -> int: + """엑셀 열너비 산정용 표시 폭. 한글/전각은 2칸으로 계산.""" + s = "" if value is None else str(value) + width = 0 + for ch in s: + # CJK(한글/한자/가나) 및 전각 문자 → 2칸 + width += 2 if ord(ch) > 0x2E7F else 1 + return width + + +def _autofit_columns(ws: Any, ncols: int, *, min_w: int = 6, max_w: int = 60) -> None: + """A~열 내용 기준 자동 너비. 헤더 포함 최댓값 + 여유.""" + for col in range(1, ncols + 1): + longest = 0 + for cell in ws[ws.cell(row=1, column=col).column_letter]: + longest = max(longest, _xl_display_len(cell.value)) + ws.column_dimensions[ws.cell(row=1, column=col).column_letter].width = ( + max(min_w, min(max_w, longest + 2)) + ) + + +def _style_header(ws: Any, ncols: int) -> None: + """제목 행: 파랑 배경, 흰 굵은 글씨, 가운데 정렬.""" + from openpyxl.styles import Alignment, Font, PatternFill + + fill = PatternFill(start_color="FF4472C4", end_color="FF4472C4", fill_type="solid") + font = Font(color="FFFFFFFF", bold=True) + align = Alignment(horizontal="center", vertical="center") + for col in range(1, ncols + 1): + cell = ws.cell(row=1, column=col) + cell.fill = fill + cell.font = font + cell.alignment = align + + def _build_approved_xlsx( items: list[dict[str, Any]], names: dict[str, str] ) -> io.BytesIO: - """승인완료 목록 + 직원별합계 시트 xlsx 바이트 생성.""" + """승인완료 목록 + 직원별합계 시트 xlsx 바이트 생성. + + 서식: 제목행 파랑/흰굵은글씨/가운데, 금액 쉼표(#,##0), 열너비 내용 자동. + """ from openpyxl import Workbook # 지연 import + money_fmt = "#,##0" + wb = Workbook() ws = wb.active ws.title = "승인완료" @@ -416,6 +456,7 @@ def _build_approved_xlsx( "금액", "메모", "상태", "승인자", "결재일시", ] ws.append(header) + amount_col = header.index("금액") + 1 # 7 (G열) for r in items: owner = r.get("owner", "") ws.append([ @@ -431,17 +472,22 @@ def _build_approved_xlsx( r.get("approver_email", "") or "", r.get("decided_at", "") or "", ]) - widths = [12, 14, 28, 10, 12, 24, 12, 30, 10, 24, 22] - for col, w in enumerate(widths, start=1): - ws.column_dimensions[ws.cell(row=1, column=col).column_letter].width = w + # 금액 열 쉼표 표시형식 + for row in range(2, ws.max_row + 1): + ws.cell(row=row, column=amount_col).number_format = money_fmt + _style_header(ws, len(header)) + _autofit_columns(ws, len(header)) # 직원별 합계 시트 ws2 = wb.create_sheet("직원별합계") - ws2.append(["이름", "이메일", "건수", "합계금액"]) + header2 = ["이름", "이메일", "건수", "합계금액"] + ws2.append(header2) for row in _approved_aggregate(items, names): ws2.append([row["name"], row["owner"], row["count"], row["total"]]) - for col, w in enumerate([14, 28, 8, 16], start=1): - ws2.column_dimensions[ws2.cell(row=1, column=col).column_letter].width = w + for row in range(2, ws2.max_row + 1): + ws2.cell(row=row, column=4).number_format = money_fmt # 합계금액 + _style_header(ws2, len(header2)) + _autofit_columns(ws2, len(header2)) buf = io.BytesIO() wb.save(buf)