fix(시간 표시): 서버 시각 모두 끝 Z · Z 없는 옛 기록 = 한국 시각으로 읽음 (PLAN 593-2 · 요구 456)

- 서버: DB 시각 글(DATE_FORMAT) 끝 Z · B03 올린 때 · D04 확정 시각 · 건강 확인 시각 = UTC Z · 개요 최근 변경 T 치환 뺌
- 서버가 만드는 엑셀(E05 면적 계산 시각) = 한국 시각 글(kst_text · Z 없는 옛 글은 이미 한국 시각)
- 화면: Z 없는 글 = 옛 로컬 기록 → +09:00 로 읽음(옛 파일은 고쳐 쓰지 않음)
- 시험: test_593_time(UTC · 서울 · 뉴욕 시간대 · Z 없는 길 0 정적 검사 · D04 · kst_text)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-30 10:26:24 +09:00
co-authored by Claude Sonnet 5.5
parent 764f7e6c51
commit bbc61e5040
12 changed files with 103 additions and 36 deletions
+3 -3
View File
@@ -137,8 +137,8 @@ async def get_workflow_states_for_projects(
await cursor.execute(
f"""
SELECT project_id, stage_no, stage_key, state, progress_percent, params, message,
DATE_FORMAT(started_at, '%%Y-%%m-%%dT%%H:%%i:%%s') AS started_at,
DATE_FORMAT(completed_at, '%%Y-%%m-%%dT%%H:%%i:%%s') AS completed_at
DATE_FORMAT(started_at, '%%Y-%%m-%%dT%%H:%%i:%%sZ') AS started_at,
DATE_FORMAT(completed_at, '%%Y-%%m-%%dT%%H:%%i:%%sZ') AS completed_at
FROM project_workflow_stages
WHERE project_id IN ({format_strings})
ORDER BY project_id, stage_no ASC
@@ -505,7 +505,7 @@ async def get_system_resources(days: int = 30) -> dict[str, Any]:
bucket_seconds = 120
else:
bucket_seconds = max(120, (days * 86400) // target_points)
date_fmt = "%%Y-%%m-%%dT%%H:%%i:%%s"
date_fmt = "%%Y-%%m-%%dT%%H:%%i:%%sZ"
await cursor.execute(
f"""SELECT
DATE_FORMAT(
@@ -282,7 +282,7 @@ _AUDIT_ACTIONS = ("PROJECT_CREATE", "PROJECT_UPDATE")
async def _changes(cursor: Any, project_id: str, stages: list[dict[str, Any]]) -> list[dict]:
"""최근 변경 — 감사 기록(만듦 · 정보 고침) + 단계 마친 때 · 새것부터 8 개."""
await cursor.execute(
"""SELECT l.action, DATE_FORMAT(l.timestamp, '%%Y-%%m-%%d %%H:%%i:%%s') AS at,
"""SELECT l.action, DATE_FORMAT(l.timestamp, '%%Y-%%m-%%dT%%H:%%i:%%sZ') AS at,
u.name AS who
FROM system_audit_logs l LEFT JOIN users u ON u.id = l.user_id
WHERE l.resource_type = 'project' AND l.resource_ref = %s
@@ -293,7 +293,7 @@ async def _changes(cursor: Any, project_id: str, stages: list[dict[str, Any]]) -
items: list[dict[str, Any]] = [dict(r) for r in await cursor.fetchall()]
items += [
{
"at": s["completed_at"].replace("T", " "),
"at": s["completed_at"],
"who": None,
"action": "STAGE_COMPLETE",
"stage_no": s["stage_no"],
@@ -142,7 +142,7 @@ async def create_project(
SELECT id AS project_id, name, region, road_type, project_year,
estimated_length_m, route_start_m, route_end_m,
memo, status, storage_path,
DATE_FORMAT(created_at, '%%Y-%%m-%%dT%%H:%%i:%%s') AS created_at
DATE_FORMAT(created_at, '%%Y-%%m-%%dT%%H:%%i:%%sZ') AS created_at
FROM projects
WHERE id = %s
""",
@@ -165,7 +165,7 @@ async def get_project_by_id(project_id: str) -> dict[str, Any] | None:
SELECT id AS project_id, user_id, company_id, name, region, road_type,
project_year, estimated_length_m, route_start_m, route_end_m,
memo, status, storage_path,
DATE_FORMAT(created_at, '%%Y-%%m-%%dT%%H:%%i:%%s') AS created_at
DATE_FORMAT(created_at, '%%Y-%%m-%%dT%%H:%%i:%%sZ') AS created_at
FROM projects
WHERE id = %s AND deleted_at IS NULL
""",
+3 -2
View File
@@ -115,6 +115,7 @@ from B03_FileInput.B03_FileInput_Service_WF1 import trigger_wf1_analysis_and_ema
from common_util.common_util_auth import verify_session
from common_util.common_util_json import atomic_write_json
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_time import utc_text
from common_util.common_util_workflow import load_project_workflow
from common_util.common_util_workflow_state import (
analysis_lock_owner,
@@ -366,7 +367,7 @@ async def get_project_upload_overview(
original_filename=str(row["original_filename"]),
file_size_mb=float(row["file_size_mb"] or 0.0),
status=str(row["status"]),
uploaded_at=str(row["upload_at"]) if row.get("upload_at") else None,
uploaded_at=utc_text(row.get("upload_at")),
relative_path=(str(row["raw_file_path"]) if row.get("raw_file_path") else None),
metadata=_parse_metadata(row.get("metadata")),
)
@@ -382,7 +383,7 @@ async def get_project_upload_overview(
progress_percent=round(
100.0 * int(row["completed_chunks"]) / max(1, int(row["total_chunks"])), 1
),
updated_at=str(row["updated_at"]) if row.get("updated_at") else None,
updated_at=utc_text(row.get("updated_at")),
)
for row in sessions
],
+3 -2
View File
@@ -30,6 +30,7 @@ from pydantic import BaseModel
from common_util.common_util_auth import is_forest_service, verify_session
from common_util.common_util_crs import tm_to_lonlat
from common_util.common_util_time import utc_text
from D03_PlanAnalysis import D03_PlanAnalysis_Store as store
from D04_PlanConfirm import D04_PlanConfirm_Repository as repo
from D04_PlanConfirm.D04_PlanConfirm_Email import build_completion_mail, send_completion_mail
@@ -337,7 +338,7 @@ def year_summary(row: dict[str, Any]) -> dict[str, Any]:
planned = body.get("planned") or []
out = {"year": int(row["year"]), "saved": len(planned) >= 2, "confirmed_at": None}
if row.get("confirmed_at"):
out["confirmed_at"] = str(row["confirmed_at"])
out["confirmed_at"] = utc_text(row["confirmed_at"])
if out["saved"]:
epsg = int(row["epsg"])
out.update(
@@ -356,7 +357,7 @@ def unit_summary(unit: dict[str, Any]) -> dict[str, Any]:
out = {key: unit[key] for key in ("piece_id", "line", "line_name", "year", "length_m")}
out.update(saved=_saved(unit), confirmed_at=None, label=None)
if unit["confirmed_at"]:
out["confirmed_at"] = str(unit["confirmed_at"])
out["confirmed_at"] = utc_text(unit["confirmed_at"])
if out["saved"]:
epsg = int(unit["epsg"])
out.update(
+1 -1
View File
@@ -28,7 +28,7 @@ router = APIRouter(prefix="/api/e01", tags=["E01_StabRegister"])
_SOURCE_SQL = """
SELECT p.id AS project_id, p.name, p.region, p.road_type, p.project_year,
DATE_FORMAT(p.updated_at, '%%Y-%%m-%%dT%%H:%%i:%%s') AS updated_at,
DATE_FORMAT(p.updated_at, '%%Y-%%m-%%dT%%H:%%i:%%sZ') AS updated_at,
s.state = 'COMPLETE' AS section_complete
FROM projects p
LEFT JOIN project_workflow_stages s ON s.project_id = p.id AND s.stage_no = 3
+2 -1
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
from pathlib import Path
from typing import Any
from common_util.common_util_time import kst_text
from E02_StabGround.E02_StabGround_Model import load_defaults
from E05_StabReport.E05_StabReport_Cells import UNCONFIRMED
from E07_StabArea.E07_StabArea_Engine import CLASS_KEYS, LEVEL_KEYS
@@ -98,7 +99,7 @@ def fill_area(sheet: dict[str, Any], area: dict[str, Any] | None, labels: dict[f
f"토층 {s['soil_depth_m']:g} m",
)
_set(sheet, "B7", scenario_text(s))
_set(sheet, "B8", str(area["computed_at"]).replace("T", " "))
_set(sheet, "B8", kst_text(area["computed_at"]))
_set(sheet, "B9", _unconfirmed(s))
for k in range(1, 7):
_set(sheet, f"C{11 + k}", float(summary["areas_m2"][CLASS_KEYS[k]]))
+18 -4
View File
@@ -8,10 +8,10 @@ from datetime import date, datetime, timedelta, timezone
KST = timezone(timedelta(hours=9), "KST")
def utc_text(value: datetime | None) -> str | None:
"""시간대 없는 값 = UTC 로 보고 「2026-10-30T01:02:03Z」 · 시간대 있는 값은 UTC 로 바꿈 · 없음 = None."""
if value is None:
return None
def utc_text(value: datetime | str | None) -> str | None:
"""시간대 없는 값 = UTC 로 보고 「2026-10-30T01:02:03Z」 · 시간대 있는 값은 UTC 로 바꿈 · 없음 = None · 글은 그대로."""
if value is None or isinstance(value, str):
return value or None
if value.tzinfo is not None:
value = value.astimezone(timezone.utc)
return value.replace(tzinfo=None, microsecond=0).isoformat() + "Z"
@@ -40,6 +40,20 @@ def kst_from_timestamp(seconds: float) -> datetime:
return datetime.fromtimestamp(seconds, KST)
def kst_text(value: datetime | str | None) -> str:
"""저장된 시각 → 「2026-10-30 10:02:03」 한국 시각(파일 글용) · Z · +hh:mm 있으면 바꿈 · 없는 옛 글 = 이미 한국 시각."""
if value is None or value == "":
return ""
if isinstance(value, str):
try:
value = datetime.fromisoformat(value.strip().replace(" ", "T"))
except ValueError:
return value
if value.tzinfo is None:
return value.replace(microsecond=0).isoformat(sep=" ")
return value.astimezone(KST).replace(tzinfo=None, microsecond=0).isoformat(sep=" ")
def utc_now_naive() -> datetime:
"""지금 — DB 칸(UTC, 시간대 표시 없음)과 견주는 값."""
return datetime.now(timezone.utc).replace(tzinfo=None)
+5 -3
View File
@@ -1,20 +1,22 @@
/* =============================================================================
* common_util_time.ts
* 화면 시간 표시 — 저장·전송은 UTC 하나, 보일 때만 보는 사람 PC 시간대로 바꿈.
* 서버 글(「2026-10-30 01:02:03」 · 「…T01:02:03」 = 시간대 표시 없음)은 UTC 로 읽음.
* 서버가 내는 시각은 끝 Z(UTC) · 시간대 표시 없는 옛 기록(「2026-10-30 10:02:03」 · 「…T10:02:03」)은
* 로컬(한국 시각, +09:00)로 적힌 것이라 한국 시각으로 읽음.
* 짝: `common_util/common_util_time.py`(서버가 만드는 파일 글 = 한국 시각 고정).
* ========================================================================== */
const DAY_MS = 24 * 3600 * 1000;
const HAS_ZONE = /(Z|[+-]\d\d:?\d\d)$/;
const OLD_LOCAL_ZONE = "+09:00"; // 옛 기록 = 한국 시각
/** 서버 시각 글 → 밀리초(UTC 기준) · 못 읽으면 null */
/** 시각 글 → 밀리초(UTC 기준) · 시간대 표시 없는 옛 글 = 한국 시각 · 못 읽으면 null */
export function parseUtcMs(value: string | number | null | undefined): number | null {
if (value === null || value === undefined || value === "") return null;
if (typeof value === "number") return Number.isFinite(value) ? value : null;
const iso = value.trim().replace(" ", "T");
if (!iso.includes("T")) return null; // 날짜만 = 시각이 아니라 바꾸지 않음
const ms = Date.parse(HAS_ZONE.test(iso) ? iso : `${iso}Z`);
const ms = Date.parse(HAS_ZONE.test(iso) ? iso : `${iso}${OLD_LOCAL_ZONE}`);
return Number.isNaN(ms) ? null : ms;
}
+2 -2
View File
@@ -360,8 +360,8 @@ async def get_workflow_state(cursor: aiomysql.DictCursor, project_id: str) -> Di
await cursor.execute(
"""
SELECT stage_no, stage_key, state, progress_percent, params, message,
DATE_FORMAT(started_at, '%%Y-%%m-%%dT%%H:%%i:%%s') AS started_at,
DATE_FORMAT(completed_at, '%%Y-%%m-%%dT%%H:%%i:%%s') AS completed_at
DATE_FORMAT(started_at, '%%Y-%%m-%%dT%%H:%%i:%%sZ') AS started_at,
DATE_FORMAT(completed_at, '%%Y-%%m-%%dT%%H:%%i:%%sZ') AS completed_at
FROM project_workflow_stages
WHERE project_id = %s
ORDER BY stage_no ASC
+5 -8
View File
@@ -14,7 +14,6 @@ import logging
import os
import time
from contextlib import asynccontextmanager
from datetime import datetime
from pathlib import Path
from typing import Any
@@ -112,7 +111,7 @@ from common_util.common_util_auth import (
# 개발환경 전용 — 「확정 없이 다음으로」. **문은 서버가 정본이다** — `ENVIRONMENT` 가
# 개발이 아니면 세 입구 모두 403 으로 거절한다(화면 단추 숨김은 보조).
from common_util.common_util_dev_unlock_router import router as dev_unlock_router
from common_util.common_util_time import install_json_utc
from common_util.common_util_time import install_json_utc, utc_from_timestamp
from common_util.common_util_help_router import router as help_router
from common_util.common_util_map_router import router as map_router
from common_util.common_util_resource_monitor import sample_resources_loop
@@ -232,7 +231,7 @@ async def lifespan(app: FastAPI):
logger.info(
"[reload] 감시 폴더 %d개, 소스 최신 수정 %s",
len(reload_dirs()),
datetime.fromtimestamp(_CODE_MTIME_AT_START).isoformat(timespec="seconds"),
utc_from_timestamp(_CODE_MTIME_AT_START),
)
# ⚠⚠ **리로드마다 프론트를 다시 빌드하지 않는다** (2026-09-12 실측으로 드러난 자리).
@@ -447,11 +446,9 @@ async def health():
body: dict[str, Any] = {"status": "ok", "environment": ENVIRONMENT}
if DEBUG:
newest = _newest_source_mtime()
body["started_at"] = datetime.fromtimestamp(_STARTED_AT).isoformat(timespec="seconds")
body["code_mtime"] = datetime.fromtimestamp(_CODE_MTIME_AT_START).isoformat(
timespec="seconds"
)
body["source_mtime"] = datetime.fromtimestamp(newest).isoformat(timespec="seconds")
body["started_at"] = utc_from_timestamp(_STARTED_AT)
body["code_mtime"] = utc_from_timestamp(_CODE_MTIME_AT_START)
body["source_mtime"] = utc_from_timestamp(newest)
# 1초 여유 — 저장 직후의 미세한 시각차로 헛경고가 뜨지 않게.
body["stale"] = newest > _CODE_MTIME_AT_START + 1.0
return body
+57 -6
View File
@@ -1,4 +1,4 @@
"""593-1 시간 표시 — UTC 글 → 보는 PC 시간대(TS) · 서버가 만드는 글 = UTC Z / 한국 시각(Python)."""
"""593 시간 표시 — Z 글 → 보는 PC 시간대(TS) · Z 없는 옛 글 = 한국 시각 · 서버가 내는 글 = UTC Z / 파일 글 = 한국 시각(Python)."""
import json
import os
@@ -21,11 +21,11 @@ import * as m from "./common_util_time.js";
const now = Date.parse("2026-10-30T20:00:00Z");
const out = {
tz: Intl.DateTimeFormat().resolvedOptions().timeZone,
date: ["2026-10-30 01:02:03", "2026-10-30T01:02:03", "2026-10-30T01:02:03Z", "2026-10-30T10:02:03+09:00", null, "", "x"].map((v) => m.dateTimeText(v)),
date: ["2026-10-30 10:02:03", "2026-10-30T10:02:03", "2026-10-30T01:02:03Z", "2026-10-30T10:02:03+09:00", null, "", "x"].map((v) => m.dateTimeText(v)),
day: [m.dateText("2026-10-30T20:30:00Z"), m.dateText("2026-10-30"), m.dateText(null), m.dateText("")],
time: [m.timeText("2026-10-30 20:30:00"), m.timeText(null)],
time: [m.timeText("2026-10-30T20:30:00Z"), m.timeText(null)],
ago: ["2026-10-30T20:00:00Z", "2026-10-30T10:00:00Z", "2026-10-29T10:00:00Z", "2026-10-26T10:00:00Z", "2026-10-01T10:00:00Z", null].map((v) => m.agoText(v, "-", now)),
kst: m.kstText("2026-10-30 01:02:03"),
kst: m.kstText("2026-10-30 10:02:03"),
};
writeFileSync(process.argv[2], JSON.stringify(out));
"""
@@ -60,11 +60,11 @@ def runner(tmp_path_factory):
def test_seoul(runner):
r = runner("Asia/Seoul")
assert r["tz"] == "Asia/Seoul"
# Z 없는 옛 글 · ISO · Z · +09:00 모두 같은 한국 시각
# Z 없는 옛 글(한국 시각) · ISO · Z · +09:00 모두 같은 한국 시각
assert r["date"][:4] == ["2026-10-30 10:02"] * 4
assert r["date"][4:] == ["—", "—", "—"]
assert r["day"] == ["2026-10-31", "2026-10-30", "-", "-"] # 20:30Z = 한국 다음 날 05:30
assert r["time"] == ["05:30", ""]
assert r["time"] == ["05:30", ""] # 20:30Z
assert r["kst"] == "2026-10-30 10:02"
# 지금 = 한국 10-31 05:00 → 한국 달력 날짜로 센다
assert r["ago"] == ["오늘", "어제", "2 일 전", "5 일 전", "2026-10-01", "-"]
@@ -77,10 +77,17 @@ def test_other_zone_follows_viewer(runner):
assert r["day"][1] == "2026-10-30" # 날짜만 있는 값은 안 바뀜
def test_utc_zone_old_text_is_kst(runner):
r = runner("UTC") # 옛 로컬 글 10:02(한국) = UTC 01:02
assert r["date"][:4] == ["2026-10-30 01:02"] * 4
assert r["kst"] == "2026-10-30 01:02"
def test_server_utc_text():
assert srv.utc_text(datetime(2026, 10, 30, 1, 2, 3, 999)) == "2026-10-30T01:02:03Z"
assert srv.utc_text(datetime(2026, 10, 30, 10, 2, 3, tzinfo=srv.KST)) == "2026-10-30T01:02:03Z"
assert srv.utc_text(None) is None
assert srv.utc_text("x") == "x" and srv.utc_text("") is None
assert srv.utc_now_text().endswith("Z")
assert srv.utc_from_timestamp(0) == "1970-01-01T00:00:00Z"
@@ -97,3 +104,47 @@ def test_응답_시각_끝_Z():
srv.install_json_utc()
body = jsonable_encoder({"at": datetime(2026, 10, 30, 1, 2, 3), "n": 1})
assert body == {"at": "2026-10-30T01:02:03Z", "n": 1}
def test_kst_text_파일_글():
assert srv.kst_text("2026-10-30T01:02:03Z") == "2026-10-30 10:02:03"
assert srv.kst_text("2026-10-30T10:02:03") == "2026-10-30 10:02:03" # 옛 글 = 한국 시각
assert srv.kst_text("2026-10-30 10:02:03") == "2026-10-30 10:02:03"
assert srv.kst_text(datetime(2026, 10, 30, 10, 2, 3, tzinfo=srv.KST)) == "2026-10-30 10:02:03"
assert srv.kst_text(None) == "" and srv.kst_text("x") == "x"
def test_D04_확정_시각_끝_Z():
from D04_PlanConfirm.D04_PlanConfirm_Router import unit_summary, year_summary
at = datetime(2026, 10, 30, 1, 2, 3)
assert year_summary({"year": 2026, "route_json": {}, "confirmed_at": at})["confirmed_at"] == (
"2026-10-30T01:02:03Z"
)
unit = {
"piece_id": None, "line": 1, "line_name": "본선", "year": 2026, "length_m": 0,
"planned": [], "confirmed_at": at,
} # fmt: skip
assert unit_summary(unit)["confirmed_at"] == "2026-10-30T01:02:03Z"
def _server_sources():
skip = {"venv", "node_modules", "resources", "tmp", "docs", ".claude", "config", "storage"}
for dirpath, dirs, files in os.walk(ROOT):
dirs[:] = [d for d in dirs if d not in skip and not d.startswith(".")]
for name in files:
if name.endswith(".py"):
path = Path(dirpath) / name
yield path, path.read_text(encoding="utf-8")
def test_서버_시각_글_Z_없는_길_0():
# DB 시각을 글로 뽑는 SQL = 끝 Z · 시각 칸을 str() 로 감싸 내보내지 않음
bad_sql = re.compile(r"%i:%%?s(?!Z)")
bad_str = re.compile(r"""\bstr\([^)\n]*(_at|timestamp|_until)["']\]\)""")
found = []
for path, text in _server_sources():
for no, line in enumerate(text.splitlines(), 1):
if bad_sql.search(line) or bad_str.search(line):
found.append(f"{path.name}:{no}")
assert found == []