feat(time): 프로젝트 전체 시간 KST(UTC+9) 통일

- app/timezone.py 신설: KST 고정오프셋(+9, DST 없음) + now_kst/today_kst/to_kst_iso
- DB 직렬화 created_at/updated_at/decided_at/uploaded_at 표시를 UTC→KST 로 변환
  (expense/cupang/vacation db.py)
- _now_iso(JSON 저장) KST 기준
- 라우터 date.today()/datetime.now() → today_kst()/now_kst() (달력 '오늘'/엑셀 파일명)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-05-30 08:48:28 +09:00
parent 32fc12e283
commit fa1027fa1b
9 changed files with 80 additions and 30 deletions
+7 -5
View File
@@ -20,6 +20,8 @@ from typing import Any
from psycopg.rows import dict_row from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool from psycopg_pool import ConnectionPool
from app.timezone import KST
from .store import STATUSES, compute_boxes from .store import STATUSES, compute_boxes
@@ -558,7 +560,7 @@ class CupangDBStore:
for k in ("created_at", "updated_at"): for k in ("created_at", "updated_at"):
v = out.get(k) v = out.get(k)
if isinstance(v, datetime): if isinstance(v, datetime):
out[k] = v.astimezone(timezone.utc).isoformat(timespec="seconds") out[k] = v.astimezone(KST).isoformat(timespec="seconds")
return out return out
@staticmethod @staticmethod
@@ -572,7 +574,7 @@ class CupangDBStore:
for k in ("created_at", "updated_at"): for k in ("created_at", "updated_at"):
v = out.get(k) v = out.get(k)
if isinstance(v, datetime): if isinstance(v, datetime):
out[k] = v.astimezone(timezone.utc).isoformat(timespec="seconds") out[k] = v.astimezone(KST).isoformat(timespec="seconds")
return out return out
@staticmethod @staticmethod
@@ -586,7 +588,7 @@ class CupangDBStore:
for k in ("created_at", "updated_at"): for k in ("created_at", "updated_at"):
v = out.get(k) v = out.get(k)
if isinstance(v, datetime): if isinstance(v, datetime):
out[k] = v.astimezone(timezone.utc).isoformat(timespec="seconds") out[k] = v.astimezone(KST).isoformat(timespec="seconds")
return out return out
@staticmethod @staticmethod
@@ -603,7 +605,7 @@ class CupangDBStore:
for k in ("created_at", "updated_at"): for k in ("created_at", "updated_at"):
v = out.get(k) v = out.get(k)
if isinstance(v, datetime): if isinstance(v, datetime):
out[k] = v.astimezone(timezone.utc).isoformat(timespec="seconds") out[k] = v.astimezone(KST).isoformat(timespec="seconds")
return out return out
@staticmethod @staticmethod
@@ -620,5 +622,5 @@ class CupangDBStore:
for k in ("created_at", "updated_at"): for k in ("created_at", "updated_at"):
v = out.get(k) v = out.get(k)
if isinstance(v, datetime): if isinstance(v, datetime):
out[k] = v.astimezone(timezone.utc).isoformat(timespec="seconds") out[k] = v.astimezone(KST).isoformat(timespec="seconds")
return out return out
+5 -4
View File
@@ -11,9 +11,10 @@ from __future__ import annotations
import calendar as _calendar import calendar as _calendar
import json import json
from datetime import date
from typing import Any from typing import Any
from app.timezone import today_kst
from fastapi import APIRouter, Body, Depends, Form, HTTPException, Request from fastapi import APIRouter, Body, Depends, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse 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]: def _ym(request: Request) -> tuple[int, int]:
today = date.today() today = today_kst()
try: try:
year = int(request.query_params.get("year") or today.year) year = int(request.query_params.get("year") or today.year)
month = int(request.query_params.get("month") or today.month) month = int(request.query_params.get("month") or today.month)
@@ -128,7 +129,7 @@ async def index(request: Request) -> HTMLResponse:
# 선택 날짜 (기본: 오늘이 이번 달이면 오늘, 아니면 1일) # 선택 날짜 (기본: 오늘이 이번 달이면 오늘, 아니면 1일)
sel = request.query_params.get("date") or "" sel = request.query_params.get("date") or ""
today = date.today() today = today_kst()
if not sel: if not sel:
sel = today.isoformat() if (today.year == year and today.month == month) else f"{year:04d}-{month:02d}-01" 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개 + 품목 라인", "page_subtitle": "공통 헤더 1개 + 품목 라인",
"mode": "new", "mode": "new",
"shipment": None, "shipment": None,
"default_date": date.today().isoformat(), "default_date": today_kst().isoformat(),
} }
) )
return render_template(request, "cupang/form.html", ctx) return render_template(request, "cupang/form.html", ctx)
+4 -2
View File
@@ -18,6 +18,8 @@ from typing import Any
from psycopg.rows import dict_row from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool from psycopg_pool import ConnectionPool
from app.timezone import KST
from .store import CATEGORIES, METHODS, STATUSES from .store import CATEGORIES, METHODS, STATUSES
@@ -364,7 +366,7 @@ class ExpenseDBStore:
out = dict(row) out = dict(row)
v = out.get("uploaded_at") v = out.get("uploaded_at")
if isinstance(v, datetime): 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)) out["size_bytes"] = int(out.get("size_bytes", 0))
return out return out
@@ -468,7 +470,7 @@ class ExpenseDBStore:
for k in ("created_at", "updated_at", "decided_at"): for k in ("created_at", "updated_at", "decided_at"):
v = out.get(k) v = out.get(k)
if isinstance(v, datetime): 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)) out["amount"] = int(out.get("amount", 0))
return out return out
+5 -4
View File
@@ -13,10 +13,11 @@ import mimetypes
import os import os
import re import re
import uuid import uuid
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from app.timezone import now_kst
from fastapi import ( from fastapi import (
APIRouter, APIRouter,
Depends, Depends,
@@ -231,11 +232,11 @@ async def reports_page(request: Request) -> HTMLResponse:
return RedirectResponse(url="/login", status_code=303) return RedirectResponse(url="/login", status_code=303)
store = _store(request) store = _store(request)
_require_db_store(store) _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: try:
year = int(year_str) year = int(year_str)
except ValueError: except ValueError:
year = datetime.now().year year = now_kst().year
rows = store.monthly_summary(email=user["email"], year=year) rows = store.monthly_summary(email=user["email"], year=year)
# 피벗: month → {category → total} # 피벗: month → {category → total}
@@ -633,7 +634,7 @@ async def export_xlsx(
buf.seek(0) buf.seek(0)
fname_scope = "all" if target_email is None else "mine" 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( return StreamingResponse(
buf, buf,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+3 -2
View File
@@ -13,17 +13,18 @@ import os
import tempfile import tempfile
import threading import threading
import uuid import uuid
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from app.timezone import now_kst_iso
CATEGORIES: tuple[str, ...] = ("식대", "교통", "숙박", "비품", "접대", "통신", "기타") CATEGORIES: tuple[str, ...] = ("식대", "교통", "숙박", "비품", "접대", "통신", "기타")
METHODS: tuple[str, ...] = ("법인카드", "개인지출", "현금") METHODS: tuple[str, ...] = ("법인카드", "개인지출", "현금")
STATUSES: tuple[str, ...] = ("작성중", "제출", "승인", "반려", "정산완료") STATUSES: tuple[str, ...] = ("작성중", "제출", "승인", "반려", "정산완료")
def _now_iso() -> str: def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds") return now_kst_iso()
class ExpenseStore: class ExpenseStore:
+5 -3
View File
@@ -22,6 +22,8 @@ from typing import Any
from psycopg.rows import dict_row from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool from psycopg_pool import ConnectionPool
from app.timezone import KST
from .store import EDITABLE_STATUSES, STATUSES, VACATION_TYPES, compute_days, normalize_half 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"): for k in ("created_at", "updated_at", "decided_at"):
v = out.get(k) v = out.get(k)
if isinstance(v, datetime): 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)) out["days"] = cls._num(out.get("days", 0))
return out return out
@@ -466,7 +468,7 @@ class VacationDBStore:
for k in ("created_at", "updated_at"): for k in ("created_at", "updated_at"):
v = out.get(k) v = out.get(k)
if isinstance(v, datetime): if isinstance(v, datetime):
out[k] = v.astimezone(timezone.utc).isoformat(timespec="seconds") out[k] = v.astimezone(KST).isoformat(timespec="seconds")
return out return out
@classmethod @classmethod
@@ -481,5 +483,5 @@ class VacationDBStore:
for k in ("created_at", "updated_at"): for k in ("created_at", "updated_at"):
v = out.get(k) v = out.get(k)
if isinstance(v, datetime): if isinstance(v, datetime):
out[k] = v.astimezone(timezone.utc).isoformat(timespec="seconds") out[k] = v.astimezone(KST).isoformat(timespec="seconds")
return out return out
+10 -8
View File
@@ -11,7 +11,7 @@ from __future__ import annotations
import calendar as _calendar import calendar as _calendar
import io import io
from datetime import date, datetime from datetime import date
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, Form, HTTPException, Request from fastapi import APIRouter, Depends, Form, HTTPException, Request
@@ -22,6 +22,8 @@ from fastapi.responses import (
StreamingResponse, StreamingResponse,
) )
from app.timezone import now_kst, today_kst
from .store import HALF_LABELS, HALVES, VACATION_TYPES from .store import HALF_LABELS, HALVES, VACATION_TYPES
router = APIRouter(prefix="/vacation", tags=["vacation"]) router = APIRouter(prefix="/vacation", tags=["vacation"])
@@ -107,7 +109,7 @@ def _guard(
def _ym(request: Request) -> tuple[int, int]: def _ym(request: Request) -> tuple[int, int]:
today = date.today() today = today_kst()
try: try:
year = int(request.query_params.get("year") or today.year) year = int(request.query_params.get("year") or today.year)
month = int(request.query_params.get("month") or today.month) 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() date_from=range_start.isoformat(), date_to=range_end.isoformat()
) )
today = date.today() today = today_kst()
weeks: list[dict[str, Any]] = [] weeks: list[dict[str, Any]] = []
for week in weeks_dates: for week in weeks_dates:
w_start, w_end = week[0], week[-1] w_start, w_end = week[0], week[-1]
@@ -234,7 +236,7 @@ async def index(request: Request) -> HTMLResponse:
year, month = _ym(request) year, month = _ym(request)
sel = request.query_params.get("date") or "" sel = request.query_params.get("date") or ""
today = date.today() today = today_kst()
if not sel: if not sel:
sel = ( sel = (
today.isoformat() today.isoformat()
@@ -315,7 +317,7 @@ async def new_form(request: Request) -> HTMLResponse:
"page_subtitle": "휴가 종류 · 기간 · 사유 입력", "page_subtitle": "휴가 종류 · 기간 · 사유 입력",
"mode": "new", "mode": "new",
"req": None, "req": None,
"default_date": date.today().isoformat(), "default_date": today_kst().isoformat(),
} }
) )
return render_template(request, "vacation/form.html", ctx) return render_template(request, "vacation/form.html", ctx)
@@ -419,7 +421,7 @@ async def settings_page(request: Request) -> HTMLResponse:
{"reason": "휴가 설정은 관리자만 접근할 수 있습니다.", "is_admin": False}, {"reason": "휴가 설정은 관리자만 접근할 수 있습니다.", "is_admin": False},
status_code=403, status_code=403,
) )
today = date.today() today = today_kst()
try: try:
year = int(request.query_params.get("year") or today.year) year = int(request.query_params.get("year") or today.year)
except ValueError: except ValueError:
@@ -551,7 +553,7 @@ async def export_xlsx(
if store is None: if store is None:
raise HTTPException(status_code=503, detail="vacation_db 미설정") raise HTTPException(status_code=503, detail="vacation_db 미설정")
today = date.today() today = today_kst()
try: try:
year = int(request.query_params.get("year") or today.year) year = int(request.query_params.get("year") or today.year)
month = int(request.query_params.get("month") or 0) month = int(request.query_params.get("month") or 0)
@@ -599,7 +601,7 @@ async def export_xlsx(
wb.save(buf) wb.save(buf)
buf.seek(0) buf.seek(0)
scope = f"{year}{('%02d' % month) if (1 <= month <= 12) else ''}" 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( return StreamingResponse(
buf, buf,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+3 -2
View File
@@ -10,10 +10,11 @@ import json
import os import os
import tempfile import tempfile
import threading import threading
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Iterable from typing import Any, Iterable
from .timezone import now_kst_iso
# 슈퍼 관리자 — 강등/삭제 불가 # 슈퍼 관리자 — 강등/삭제 불가
SUPER_ADMIN_EMAIL = "king@dbxcorp.co.kr" SUPER_ADMIN_EMAIL = "king@dbxcorp.co.kr"
@@ -36,7 +37,7 @@ APPROVER_KEYS: tuple[str, ...] = ("expense_approver", "vacation_approver")
def _now_iso() -> str: def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds") return now_kst_iso()
class UserStore: class UserStore:
+38
View File
@@ -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)