diff --git a/app/modules/cupang/db.py b/app/modules/cupang/db.py index 0d2abf3..17ab9f0 100644 --- a/app/modules/cupang/db.py +++ b/app/modules/cupang/db.py @@ -20,6 +20,8 @@ from typing import Any from psycopg.rows import dict_row from psycopg_pool import ConnectionPool +from app.timezone import KST + from .store import STATUSES, compute_boxes @@ -558,7 +560,7 @@ class CupangDBStore: for k in ("created_at", "updated_at"): v = out.get(k) if isinstance(v, datetime): - out[k] = v.astimezone(timezone.utc).isoformat(timespec="seconds") + out[k] = v.astimezone(KST).isoformat(timespec="seconds") return out @staticmethod @@ -572,7 +574,7 @@ class CupangDBStore: for k in ("created_at", "updated_at"): v = out.get(k) if isinstance(v, datetime): - out[k] = v.astimezone(timezone.utc).isoformat(timespec="seconds") + out[k] = v.astimezone(KST).isoformat(timespec="seconds") return out @staticmethod @@ -586,7 +588,7 @@ class CupangDBStore: for k in ("created_at", "updated_at"): v = out.get(k) if isinstance(v, datetime): - out[k] = v.astimezone(timezone.utc).isoformat(timespec="seconds") + out[k] = v.astimezone(KST).isoformat(timespec="seconds") return out @staticmethod @@ -603,7 +605,7 @@ class CupangDBStore: for k in ("created_at", "updated_at"): v = out.get(k) if isinstance(v, datetime): - out[k] = v.astimezone(timezone.utc).isoformat(timespec="seconds") + out[k] = v.astimezone(KST).isoformat(timespec="seconds") return out @staticmethod @@ -620,5 +622,5 @@ class CupangDBStore: for k in ("created_at", "updated_at"): v = out.get(k) if isinstance(v, datetime): - out[k] = v.astimezone(timezone.utc).isoformat(timespec="seconds") + out[k] = v.astimezone(KST).isoformat(timespec="seconds") return out diff --git a/app/modules/cupang/router.py b/app/modules/cupang/router.py index b1b4137..d97b85b 100644 --- a/app/modules/cupang/router.py +++ b/app/modules/cupang/router.py @@ -11,9 +11,10 @@ from __future__ import annotations import calendar as _calendar import json -from datetime import date from typing import Any +from app.timezone import today_kst + from fastapi import APIRouter, Body, Depends, Form, HTTPException, Request from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse @@ -98,7 +99,7 @@ def _parse_lines(lines_json: str) -> list[dict[str, Any]]: def _ym(request: Request) -> tuple[int, int]: - today = date.today() + today = today_kst() try: year = int(request.query_params.get("year") or today.year) month = int(request.query_params.get("month") or today.month) @@ -128,7 +129,7 @@ async def index(request: Request) -> HTMLResponse: # 선택 날짜 (기본: 오늘이 이번 달이면 오늘, 아니면 1일) sel = request.query_params.get("date") or "" - today = date.today() + today = today_kst() if not sel: sel = today.isoformat() if (today.year == year and today.month == month) else f"{year:04d}-{month:02d}-01" @@ -225,7 +226,7 @@ async def new_form(request: Request) -> HTMLResponse: "page_subtitle": "공통 헤더 1개 + 품목 라인", "mode": "new", "shipment": None, - "default_date": date.today().isoformat(), + "default_date": today_kst().isoformat(), } ) return render_template(request, "cupang/form.html", ctx) diff --git a/app/modules/expense/db.py b/app/modules/expense/db.py index da9896a..1cace89 100644 --- a/app/modules/expense/db.py +++ b/app/modules/expense/db.py @@ -18,6 +18,8 @@ from typing import Any from psycopg.rows import dict_row from psycopg_pool import ConnectionPool +from app.timezone import KST + from .store import CATEGORIES, METHODS, STATUSES @@ -364,7 +366,7 @@ class ExpenseDBStore: out = dict(row) v = out.get("uploaded_at") if isinstance(v, datetime): - out["uploaded_at"] = v.astimezone(timezone.utc).isoformat(timespec="seconds") + out["uploaded_at"] = v.astimezone(KST).isoformat(timespec="seconds") out["size_bytes"] = int(out.get("size_bytes", 0)) return out @@ -468,7 +470,7 @@ class ExpenseDBStore: for k in ("created_at", "updated_at", "decided_at"): v = out.get(k) if isinstance(v, datetime): - out[k] = v.astimezone(timezone.utc).isoformat(timespec="seconds") + out[k] = v.astimezone(KST).isoformat(timespec="seconds") out["amount"] = int(out.get("amount", 0)) return out diff --git a/app/modules/expense/router.py b/app/modules/expense/router.py index 70354ed..2f879ce 100644 --- a/app/modules/expense/router.py +++ b/app/modules/expense/router.py @@ -13,10 +13,11 @@ import mimetypes import os import re import uuid -from datetime import datetime from pathlib import Path from typing import Any +from app.timezone import now_kst + from fastapi import ( APIRouter, Depends, @@ -231,11 +232,11 @@ async def reports_page(request: Request) -> HTMLResponse: return RedirectResponse(url="/login", status_code=303) store = _store(request) _require_db_store(store) - year_str = request.query_params.get("year") or str(datetime.now().year) + year_str = request.query_params.get("year") or str(now_kst().year) try: year = int(year_str) except ValueError: - year = datetime.now().year + year = now_kst().year rows = store.monthly_summary(email=user["email"], year=year) # 피벗: month → {category → total} @@ -633,7 +634,7 @@ async def export_xlsx( buf.seek(0) fname_scope = "all" if target_email is None else "mine" - fname = f"expense_{fname_scope}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" + fname = f"expense_{fname_scope}_{now_kst().strftime('%Y%m%d_%H%M%S')}.xlsx" return StreamingResponse( buf, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", diff --git a/app/modules/expense/store.py b/app/modules/expense/store.py index cd7e96e..3317c43 100644 --- a/app/modules/expense/store.py +++ b/app/modules/expense/store.py @@ -13,17 +13,18 @@ import os import tempfile import threading import uuid -from datetime import datetime, timezone from pathlib import Path from typing import Any +from app.timezone import now_kst_iso + CATEGORIES: tuple[str, ...] = ("식대", "교통", "숙박", "비품", "접대", "통신", "기타") METHODS: tuple[str, ...] = ("법인카드", "개인지출", "현금") STATUSES: tuple[str, ...] = ("작성중", "제출", "승인", "반려", "정산완료") def _now_iso() -> str: - return datetime.now(timezone.utc).isoformat(timespec="seconds") + return now_kst_iso() class ExpenseStore: diff --git a/app/modules/vacation/db.py b/app/modules/vacation/db.py index deed4ee..b2c92b8 100644 --- a/app/modules/vacation/db.py +++ b/app/modules/vacation/db.py @@ -22,6 +22,8 @@ from typing import Any from psycopg.rows import dict_row from psycopg_pool import ConnectionPool +from app.timezone import KST + from .store import EDITABLE_STATUSES, STATUSES, VACATION_TYPES, compute_days, normalize_half @@ -449,7 +451,7 @@ class VacationDBStore: for k in ("created_at", "updated_at", "decided_at"): v = out.get(k) if isinstance(v, datetime): - out[k] = v.astimezone(timezone.utc).isoformat(timespec="seconds") + out[k] = v.astimezone(KST).isoformat(timespec="seconds") out["days"] = cls._num(out.get("days", 0)) return out @@ -466,7 +468,7 @@ class VacationDBStore: for k in ("created_at", "updated_at"): v = out.get(k) if isinstance(v, datetime): - out[k] = v.astimezone(timezone.utc).isoformat(timespec="seconds") + out[k] = v.astimezone(KST).isoformat(timespec="seconds") return out @classmethod @@ -481,5 +483,5 @@ class VacationDBStore: for k in ("created_at", "updated_at"): v = out.get(k) if isinstance(v, datetime): - out[k] = v.astimezone(timezone.utc).isoformat(timespec="seconds") + out[k] = v.astimezone(KST).isoformat(timespec="seconds") return out diff --git a/app/modules/vacation/router.py b/app/modules/vacation/router.py index dc18896..a4601a3 100644 --- a/app/modules/vacation/router.py +++ b/app/modules/vacation/router.py @@ -11,7 +11,7 @@ from __future__ import annotations import calendar as _calendar import io -from datetime import date, datetime +from datetime import date from typing import Any from fastapi import APIRouter, Depends, Form, HTTPException, Request @@ -22,6 +22,8 @@ from fastapi.responses import ( StreamingResponse, ) +from app.timezone import now_kst, today_kst + from .store import HALF_LABELS, HALVES, VACATION_TYPES router = APIRouter(prefix="/vacation", tags=["vacation"]) @@ -107,7 +109,7 @@ def _guard( def _ym(request: Request) -> tuple[int, int]: - today = date.today() + today = today_kst() try: year = int(request.query_params.get("year") or today.year) month = int(request.query_params.get("month") or today.month) @@ -170,7 +172,7 @@ def _build_calendar( date_from=range_start.isoformat(), date_to=range_end.isoformat() ) - today = date.today() + today = today_kst() weeks: list[dict[str, Any]] = [] for week in weeks_dates: w_start, w_end = week[0], week[-1] @@ -234,7 +236,7 @@ async def index(request: Request) -> HTMLResponse: year, month = _ym(request) sel = request.query_params.get("date") or "" - today = date.today() + today = today_kst() if not sel: sel = ( today.isoformat() @@ -315,7 +317,7 @@ async def new_form(request: Request) -> HTMLResponse: "page_subtitle": "휴가 종류 · 기간 · 사유 입력", "mode": "new", "req": None, - "default_date": date.today().isoformat(), + "default_date": today_kst().isoformat(), } ) return render_template(request, "vacation/form.html", ctx) @@ -419,7 +421,7 @@ async def settings_page(request: Request) -> HTMLResponse: {"reason": "휴가 설정은 관리자만 접근할 수 있습니다.", "is_admin": False}, status_code=403, ) - today = date.today() + today = today_kst() try: year = int(request.query_params.get("year") or today.year) except ValueError: @@ -551,7 +553,7 @@ async def export_xlsx( if store is None: raise HTTPException(status_code=503, detail="vacation_db 미설정") - today = date.today() + today = today_kst() try: year = int(request.query_params.get("year") or today.year) month = int(request.query_params.get("month") or 0) @@ -599,7 +601,7 @@ async def export_xlsx( wb.save(buf) buf.seek(0) scope = f"{year}{('%02d' % month) if (1 <= month <= 12) else ''}" - fname = f"vacation_{scope}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" + fname = f"vacation_{scope}_{now_kst().strftime('%Y%m%d_%H%M%S')}.xlsx" return StreamingResponse( buf, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", diff --git a/app/store.py b/app/store.py index 8b3f2ae..1612b1f 100644 --- a/app/store.py +++ b/app/store.py @@ -10,10 +10,11 @@ import json import os import tempfile import threading -from datetime import datetime, timezone from pathlib import Path from typing import Any, Iterable +from .timezone import now_kst_iso + # 슈퍼 관리자 — 강등/삭제 불가 SUPER_ADMIN_EMAIL = "king@dbxcorp.co.kr" @@ -36,7 +37,7 @@ APPROVER_KEYS: tuple[str, ...] = ("expense_approver", "vacation_approver") def _now_iso() -> str: - return datetime.now(timezone.utc).isoformat(timespec="seconds") + return now_kst_iso() class UserStore: diff --git a/app/timezone.py b/app/timezone.py new file mode 100644 index 0000000..54c25fa --- /dev/null +++ b/app/timezone.py @@ -0,0 +1,38 @@ +"""프로젝트 공통 시간대 — 모든 시간은 한국 시간(KST, UTC+9)으로 표시/계산한다. + +대한민국은 서머타임(DST)이 없으므로 고정 오프셋 +9 로 충분하다. +zoneinfo/tzdata 의존 없이 어디서나 동일하게 동작한다. + +- DB 의 TIMESTAMPTZ 는 UTC 로 저장되고 psycopg 가 aware datetime(UTC)로 돌려준다. + 표시 직전에 `to_kst_iso()` 로 KST 문자열로 변환한다. +- "오늘"/"지금" 판정은 `today_kst()` / `now_kst()` 를 쓴다(서버 로컬 TZ 무관). +""" + +from __future__ import annotations + +from datetime import date, datetime, timedelta, timezone + +KST = timezone(timedelta(hours=9), name="KST") + + +def now_kst() -> datetime: + """현재 시각(KST, tz-aware).""" + return datetime.now(KST) + + +def today_kst() -> date: + """오늘 날짜(KST 기준).""" + return now_kst().date() + + +def now_kst_iso() -> str: + return now_kst().isoformat(timespec="seconds") + + +def to_kst_iso(dt: datetime | None, *, timespec: str = "seconds") -> str | None: + """datetime → KST ISO 문자열. naive 는 UTC 로 간주 후 변환.""" + if dt is None: + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(KST).isoformat(timespec=timespec)