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( await cursor.execute(
f""" f"""
SELECT project_id, stage_no, stage_key, state, progress_percent, params, message, 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(started_at, '%%Y-%%m-%%dT%%H:%%i:%%sZ') AS started_at,
DATE_FORMAT(completed_at, '%%Y-%%m-%%dT%%H:%%i:%%s') AS completed_at DATE_FORMAT(completed_at, '%%Y-%%m-%%dT%%H:%%i:%%sZ') AS completed_at
FROM project_workflow_stages FROM project_workflow_stages
WHERE project_id IN ({format_strings}) WHERE project_id IN ({format_strings})
ORDER BY project_id, stage_no ASC 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 bucket_seconds = 120
else: else:
bucket_seconds = max(120, (days * 86400) // target_points) 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( await cursor.execute(
f"""SELECT f"""SELECT
DATE_FORMAT( 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]: async def _changes(cursor: Any, project_id: str, stages: list[dict[str, Any]]) -> list[dict]:
"""최근 변경 — 감사 기록(만듦 · 정보 고침) + 단계 마친 때 · 새것부터 8 개.""" """최근 변경 — 감사 기록(만듦 · 정보 고침) + 단계 마친 때 · 새것부터 8 개."""
await cursor.execute( 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 u.name AS who
FROM system_audit_logs l LEFT JOIN users u ON u.id = l.user_id 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 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: list[dict[str, Any]] = [dict(r) for r in await cursor.fetchall()]
items += [ items += [
{ {
"at": s["completed_at"].replace("T", " "), "at": s["completed_at"],
"who": None, "who": None,
"action": "STAGE_COMPLETE", "action": "STAGE_COMPLETE",
"stage_no": s["stage_no"], "stage_no": s["stage_no"],
@@ -142,7 +142,7 @@ async def create_project(
SELECT id AS project_id, name, region, road_type, project_year, SELECT id AS project_id, name, region, road_type, project_year,
estimated_length_m, route_start_m, route_end_m, estimated_length_m, route_start_m, route_end_m,
memo, status, storage_path, 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 FROM projects
WHERE id = %s 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, SELECT id AS project_id, user_id, company_id, name, region, road_type,
project_year, estimated_length_m, route_start_m, route_end_m, project_year, estimated_length_m, route_start_m, route_end_m,
memo, status, storage_path, 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 FROM projects
WHERE id = %s AND deleted_at IS NULL 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_auth import verify_session
from common_util.common_util_json import atomic_write_json 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_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 import load_project_workflow
from common_util.common_util_workflow_state import ( from common_util.common_util_workflow_state import (
analysis_lock_owner, analysis_lock_owner,
@@ -366,7 +367,7 @@ async def get_project_upload_overview(
original_filename=str(row["original_filename"]), original_filename=str(row["original_filename"]),
file_size_mb=float(row["file_size_mb"] or 0.0), file_size_mb=float(row["file_size_mb"] or 0.0),
status=str(row["status"]), 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), relative_path=(str(row["raw_file_path"]) if row.get("raw_file_path") else None),
metadata=_parse_metadata(row.get("metadata")), metadata=_parse_metadata(row.get("metadata")),
) )
@@ -382,7 +383,7 @@ async def get_project_upload_overview(
progress_percent=round( progress_percent=round(
100.0 * int(row["completed_chunks"]) / max(1, int(row["total_chunks"])), 1 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 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_auth import is_forest_service, verify_session
from common_util.common_util_crs import tm_to_lonlat 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 D03_PlanAnalysis import D03_PlanAnalysis_Store as store
from D04_PlanConfirm import D04_PlanConfirm_Repository as repo from D04_PlanConfirm import D04_PlanConfirm_Repository as repo
from D04_PlanConfirm.D04_PlanConfirm_Email import build_completion_mail, send_completion_mail 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 [] planned = body.get("planned") or []
out = {"year": int(row["year"]), "saved": len(planned) >= 2, "confirmed_at": None} out = {"year": int(row["year"]), "saved": len(planned) >= 2, "confirmed_at": None}
if row.get("confirmed_at"): if row.get("confirmed_at"):
out["confirmed_at"] = str(row["confirmed_at"]) out["confirmed_at"] = utc_text(row["confirmed_at"])
if out["saved"]: if out["saved"]:
epsg = int(row["epsg"]) epsg = int(row["epsg"])
out.update( 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 = {key: unit[key] for key in ("piece_id", "line", "line_name", "year", "length_m")}
out.update(saved=_saved(unit), confirmed_at=None, label=None) out.update(saved=_saved(unit), confirmed_at=None, label=None)
if unit["confirmed_at"]: if unit["confirmed_at"]:
out["confirmed_at"] = str(unit["confirmed_at"]) out["confirmed_at"] = utc_text(unit["confirmed_at"])
if out["saved"]: if out["saved"]:
epsg = int(unit["epsg"]) epsg = int(unit["epsg"])
out.update( out.update(
+1 -1
View File
@@ -28,7 +28,7 @@ router = APIRouter(prefix="/api/e01", tags=["E01_StabRegister"])
_SOURCE_SQL = """ _SOURCE_SQL = """
SELECT p.id AS project_id, p.name, p.region, p.road_type, p.project_year, 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 s.state = 'COMPLETE' AS section_complete
FROM projects p FROM projects p
LEFT JOIN project_workflow_stages s ON s.project_id = p.id AND s.stage_no = 3 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 pathlib import Path
from typing import Any from typing import Any
from common_util.common_util_time import kst_text
from E02_StabGround.E02_StabGround_Model import load_defaults from E02_StabGround.E02_StabGround_Model import load_defaults
from E05_StabReport.E05_StabReport_Cells import UNCONFIRMED from E05_StabReport.E05_StabReport_Cells import UNCONFIRMED
from E07_StabArea.E07_StabArea_Engine import CLASS_KEYS, LEVEL_KEYS 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", f"토층 {s['soil_depth_m']:g} m",
) )
_set(sheet, "B7", scenario_text(s)) _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)) _set(sheet, "B9", _unconfirmed(s))
for k in range(1, 7): for k in range(1, 7):
_set(sheet, f"C{11 + k}", float(summary["areas_m2"][CLASS_KEYS[k]])) _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") KST = timezone(timedelta(hours=9), "KST")
def utc_text(value: datetime | None) -> str | None: def utc_text(value: datetime | str | None) -> str | None:
"""시간대 없는 값 = UTC 로 보고 「2026-10-30T01:02:03Z」 · 시간대 있는 값은 UTC 로 바꿈 · 없음 = None.""" """시간대 없는 값 = UTC 로 보고 「2026-10-30T01:02:03Z」 · 시간대 있는 값은 UTC 로 바꿈 · 없음 = None · 글은 그대로."""
if value is None: if value is None or isinstance(value, str):
return None return value or None
if value.tzinfo is not None: if value.tzinfo is not None:
value = value.astimezone(timezone.utc) value = value.astimezone(timezone.utc)
return value.replace(tzinfo=None, microsecond=0).isoformat() + "Z" 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) 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: def utc_now_naive() -> datetime:
"""지금 — DB 칸(UTC, 시간대 표시 없음)과 견주는 값.""" """지금 — DB 칸(UTC, 시간대 표시 없음)과 견주는 값."""
return datetime.now(timezone.utc).replace(tzinfo=None) return datetime.now(timezone.utc).replace(tzinfo=None)
+5 -3
View File
@@ -1,20 +1,22 @@
/* ============================================================================= /* =============================================================================
* common_util_time.ts * common_util_time.ts
* 화면 시간 표시 — 저장·전송은 UTC 하나, 보일 때만 보는 사람 PC 시간대로 바꿈. * 화면 시간 표시 — 저장·전송은 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`(서버가 만드는 파일 글 = 한국 시각 고정). * 짝: `common_util/common_util_time.py`(서버가 만드는 파일 글 = 한국 시각 고정).
* ========================================================================== */ * ========================================================================== */
const DAY_MS = 24 * 3600 * 1000; const DAY_MS = 24 * 3600 * 1000;
const HAS_ZONE = /(Z|[+-]\d\d:?\d\d)$/; 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 { export function parseUtcMs(value: string | number | null | undefined): number | null {
if (value === null || value === undefined || value === "") return null; if (value === null || value === undefined || value === "") return null;
if (typeof value === "number") return Number.isFinite(value) ? value : null; if (typeof value === "number") return Number.isFinite(value) ? value : null;
const iso = value.trim().replace(" ", "T"); const iso = value.trim().replace(" ", "T");
if (!iso.includes("T")) return null; // 날짜만 = 시각이 아니라 바꾸지 않음 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; 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( await cursor.execute(
""" """
SELECT stage_no, stage_key, state, progress_percent, params, message, 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(started_at, '%%Y-%%m-%%dT%%H:%%i:%%sZ') AS started_at,
DATE_FORMAT(completed_at, '%%Y-%%m-%%dT%%H:%%i:%%s') AS completed_at DATE_FORMAT(completed_at, '%%Y-%%m-%%dT%%H:%%i:%%sZ') AS completed_at
FROM project_workflow_stages FROM project_workflow_stages
WHERE project_id = %s WHERE project_id = %s
ORDER BY stage_no ASC ORDER BY stage_no ASC
+5 -8
View File
@@ -14,7 +14,6 @@ import logging
import os import os
import time import time
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -112,7 +111,7 @@ from common_util.common_util_auth import (
# 개발환경 전용 — 「확정 없이 다음으로」. **문은 서버가 정본이다** — `ENVIRONMENT` 가 # 개발환경 전용 — 「확정 없이 다음으로」. **문은 서버가 정본이다** — `ENVIRONMENT` 가
# 개발이 아니면 세 입구 모두 403 으로 거절한다(화면 단추 숨김은 보조). # 개발이 아니면 세 입구 모두 403 으로 거절한다(화면 단추 숨김은 보조).
from common_util.common_util_dev_unlock_router import router as dev_unlock_router 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_help_router import router as help_router
from common_util.common_util_map_router import router as map_router from common_util.common_util_map_router import router as map_router
from common_util.common_util_resource_monitor import sample_resources_loop from common_util.common_util_resource_monitor import sample_resources_loop
@@ -232,7 +231,7 @@ async def lifespan(app: FastAPI):
logger.info( logger.info(
"[reload] 감시 폴더 %d개, 소스 최신 수정 %s", "[reload] 감시 폴더 %d개, 소스 최신 수정 %s",
len(reload_dirs()), len(reload_dirs()),
datetime.fromtimestamp(_CODE_MTIME_AT_START).isoformat(timespec="seconds"), utc_from_timestamp(_CODE_MTIME_AT_START),
) )
# ⚠⚠ **리로드마다 프론트를 다시 빌드하지 않는다** (2026-09-12 실측으로 드러난 자리). # ⚠⚠ **리로드마다 프론트를 다시 빌드하지 않는다** (2026-09-12 실측으로 드러난 자리).
@@ -447,11 +446,9 @@ async def health():
body: dict[str, Any] = {"status": "ok", "environment": ENVIRONMENT} body: dict[str, Any] = {"status": "ok", "environment": ENVIRONMENT}
if DEBUG: if DEBUG:
newest = _newest_source_mtime() newest = _newest_source_mtime()
body["started_at"] = datetime.fromtimestamp(_STARTED_AT).isoformat(timespec="seconds") body["started_at"] = utc_from_timestamp(_STARTED_AT)
body["code_mtime"] = datetime.fromtimestamp(_CODE_MTIME_AT_START).isoformat( body["code_mtime"] = utc_from_timestamp(_CODE_MTIME_AT_START)
timespec="seconds" body["source_mtime"] = utc_from_timestamp(newest)
)
body["source_mtime"] = datetime.fromtimestamp(newest).isoformat(timespec="seconds")
# 1초 여유 — 저장 직후의 미세한 시각차로 헛경고가 뜨지 않게. # 1초 여유 — 저장 직후의 미세한 시각차로 헛경고가 뜨지 않게.
body["stale"] = newest > _CODE_MTIME_AT_START + 1.0 body["stale"] = newest > _CODE_MTIME_AT_START + 1.0
return body 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 json
import os import os
@@ -21,11 +21,11 @@ import * as m from "./common_util_time.js";
const now = Date.parse("2026-10-30T20:00:00Z"); const now = Date.parse("2026-10-30T20:00:00Z");
const out = { const out = {
tz: Intl.DateTimeFormat().resolvedOptions().timeZone, 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("")], 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)), 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)); writeFileSync(process.argv[2], JSON.stringify(out));
""" """
@@ -60,11 +60,11 @@ def runner(tmp_path_factory):
def test_seoul(runner): def test_seoul(runner):
r = runner("Asia/Seoul") r = runner("Asia/Seoul")
assert r["tz"] == "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] == ["2026-10-30 10:02"] * 4
assert r["date"][4:] == ["—", "—", "—"] assert r["date"][4:] == ["—", "—", "—"]
assert r["day"] == ["2026-10-31", "2026-10-30", "-", "-"] # 20:30Z = 한국 다음 날 05:30 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" assert r["kst"] == "2026-10-30 10:02"
# 지금 = 한국 10-31 05:00 → 한국 달력 날짜로 센다 # 지금 = 한국 10-31 05:00 → 한국 달력 날짜로 센다
assert r["ago"] == ["오늘", "어제", "2 일 전", "5 일 전", "2026-10-01", "-"] 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" # 날짜만 있는 값은 안 바뀜 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(): 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, 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(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(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_now_text().endswith("Z")
assert srv.utc_from_timestamp(0) == "1970-01-01T00:00:00Z" assert srv.utc_from_timestamp(0) == "1970-01-01T00:00:00Z"
@@ -97,3 +104,47 @@ def test_응답_시각_끝_Z():
srv.install_json_utc() srv.install_json_utc()
body = jsonable_encoder({"at": datetime(2026, 10, 30, 1, 2, 3), "n": 1}) body = jsonable_encoder({"at": datetime(2026, 10, 30, 1, 2, 3), "n": 1})
assert body == {"at": "2026-10-30T01:02:03Z", "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 == []