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_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
+5 -4
View File
@@ -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)
+4 -2
View File
@@ -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
+5 -4
View File
@@ -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",
+3 -2
View File
@@ -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:
+5 -3
View File
@@ -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
+10 -8
View File
@@ -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",