diff --git a/B01_Dashboard/B01_Dashboard_Repository.py b/B01_Dashboard/B01_Dashboard_Repository.py index 6fec3759c..2f71ed603 100644 --- a/B01_Dashboard/B01_Dashboard_Repository.py +++ b/B01_Dashboard/B01_Dashboard_Repository.py @@ -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( diff --git a/B01_Dashboard/B01_Dashboard_Router_Overview.py b/B01_Dashboard/B01_Dashboard_Router_Overview.py index 49050758f..43db71d98 100644 --- a/B01_Dashboard/B01_Dashboard_Router_Overview.py +++ b/B01_Dashboard/B01_Dashboard_Router_Overview.py @@ -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"], diff --git a/B02_ProjRegister/B02_ProjRegister_Repository.py b/B02_ProjRegister/B02_ProjRegister_Repository.py index 4cf8d6a30..6f20f6168 100644 --- a/B02_ProjRegister/B02_ProjRegister_Repository.py +++ b/B02_ProjRegister/B02_ProjRegister_Repository.py @@ -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 """, diff --git a/B03_FileInput/B03_FileInput_Router.py b/B03_FileInput/B03_FileInput_Router.py index 9c49fa47b..facbbb697 100644 --- a/B03_FileInput/B03_FileInput_Router.py +++ b/B03_FileInput/B03_FileInput_Router.py @@ -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 ], diff --git a/D04_PlanConfirm/D04_PlanConfirm_Router.py b/D04_PlanConfirm/D04_PlanConfirm_Router.py index 6491596a0..a735914e0 100644 --- a/D04_PlanConfirm/D04_PlanConfirm_Router.py +++ b/D04_PlanConfirm/D04_PlanConfirm_Router.py @@ -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( diff --git a/E01_StabRegister/E01_StabRegister_Router.py b/E01_StabRegister/E01_StabRegister_Router.py index c356b8d75..615be255f 100644 --- a/E01_StabRegister/E01_StabRegister_Router.py +++ b/E01_StabRegister/E01_StabRegister_Router.py @@ -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 diff --git a/E05_StabReport/E05_StabReport_Area.py b/E05_StabReport/E05_StabReport_Area.py index ec0eb0754..d50934c87 100644 --- a/E05_StabReport/E05_StabReport_Area.py +++ b/E05_StabReport/E05_StabReport_Area.py @@ -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]])) diff --git a/common_util/common_util_time.py b/common_util/common_util_time.py index 28651f9a2..022265b85 100644 --- a/common_util/common_util_time.py +++ b/common_util/common_util_time.py @@ -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) diff --git a/common_util/common_util_time.ts b/common_util/common_util_time.ts index 225c8c8f4..edcca5746 100644 --- a/common_util/common_util_time.ts +++ b/common_util/common_util_time.ts @@ -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; } diff --git a/common_util/common_util_workflow_state.py b/common_util/common_util_workflow_state.py index 5d1db5e37..789f37eba 100644 --- a/common_util/common_util_workflow_state.py +++ b/common_util/common_util_workflow_state.py @@ -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 diff --git a/main.py b/main.py index 4c1ef4a4c..7528307aa 100644 --- a/main.py +++ b/main.py @@ -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 diff --git a/resources/tester/test_593_time.py b/resources/tester/test_593_time.py index 127918300..32aa3279d 100644 --- a/resources/tester/test_593_time.py +++ b/resources/tester/test_593_time.py @@ -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 == []