From 59610fecf1bf373c01d4332856969d1ca83c30fa Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 30 Oct 2026 10:03:35 +0900 Subject: [PATCH] =?UTF-8?q?feat(=EC=8B=9C=EA=B0=84=20=ED=91=9C=EC=8B=9C):?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=20=EC=8B=9C=EA=B0=81=20=3D=20=EB=B3=B4?= =?UTF-8?q?=EB=8A=94=20=EC=82=AC=EB=9E=8C=20PC=20=EC=8B=9C=EA=B0=84?= =?UTF-8?q?=EB=8C=80=20=C2=B7=20=EC=84=9C=EB=B2=84=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?UTC=20Z=20=C2=B7=20=EC=84=9C=EB=B2=84=EA=B0=80=20=EB=A7=8C?= =?UTF-8?q?=EB=93=9C=EB=8A=94=20=ED=8C=8C=EC=9D=BC=20=EA=B8=80=20=3D=20?= =?UTF-8?q?=ED=95=9C=EA=B5=AD=20=EC=8B=9C=EA=B0=81=20(PLAN=20593-1=20?= =?UTF-8?q?=C2=B7=20=EC=9A=94=EA=B5=AC=20456)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공용 `common_util_time.ts`(날짜 · 시각 · 오늘/어제/n 일 전 · Z 없는 옛 글 = UTC) · `common_util_time.py`(UTC Z 글 · 한국 시각) - 화면 바꿈: 대시보드(프로젝트 · 휴지통 · 시스템 로그 · 보관함 · 홈 카드) · B09 굳힘 · E03 · E05 · E07 · M02 호표 이력 · 목록 고친 날 · 지난 판 - 서버: 응답 datetime = UTC + Z(main.py) · 로컬 시각 저장 → UTC · 대시보드 경과일 = UTC 기준 · 엑셀 날짜 · 파일 이름 = 한국 시각 - 시험: test_593_time(서울 · 뉴욕 · Z 없는 옛 글 · 서버 글) Co-Authored-By: Claude Sonnet 5.5 --- B01_Dashboard/B01_Dashboard_Router_Home.py | 9 +- .../B01_Dashboard_Router_Overview.py | 3 +- B01_Dashboard/B01_Dashboard_Summary.py | 6 +- B01_Dashboard/B01_Dashboard_UI_Common.ts | 14 +-- B01_Dashboard/B01_Dashboard_UI_Home_Cards.ts | 5 +- .../B01_Dashboard_UI_ProjectDetail.ts | 5 +- .../B01_Dashboard_UI_ProjectList_Parts.ts | 8 +- .../B01_Dashboard_UI_ProjectList_Views.ts | 3 +- B01_Dashboard/B01_Dashboard_UI_Shell.ts | 3 +- B01_Dashboard/B01_Dashboard_UI_TempUpload.ts | 9 +- .../B01_Dashboard_UI_View_SysLogs.ts | 3 +- .../logic/B01_Dashboard_Logic_Pages.ts | 4 +- .../B02_ProjRegister_Repository.py | 3 +- .../B03_FileInput_Router_Temp_Support.py | 4 +- .../B04_PreProcess_Engine_SheetStore.py | 4 +- .../B04_PreProcess_Engine_Watershed_Export.py | 4 +- B04_PreProcess/B04_PreProcess_Repository.py | 6 +- .../B05_Profile_Engine_RouteInitial_Store.py | 4 +- B05_Profile/B05_Profile_Repository.py | 4 +- B06_Section/B06_Section_Repository.py | 4 +- B09_Estimation/B09_Estimation_Calc_Total.py | 4 +- B09_Estimation/B09_Estimation_Prices.py | 4 +- .../B09_Estimation_Router_Quotes.py | 4 +- B09_Estimation/B09_Estimation_UI_Check.ts | 3 +- B09_Estimation/B09_Estimation_UI_Tabs_Base.ts | 3 +- .../draft/B09_Draft_Schedule_Source.py | 3 +- .../D03_PlanAnalysis_ForestRoad.py | 4 +- E03_StabSection/E03_StabSection_Router.py | 4 +- .../E03_StabSection_Router_Prob.py | 4 +- .../E03_StabSection_Router_WhatIf.py | 9 +- E03_StabSection/E03_StabSection_UI_Page.ts | 3 +- E04_Stab3D/E04_Stab3D_Run.py | 4 +- E05_StabReport/E05_StabReport_Build.py | 4 +- E05_StabReport/E05_StabReport_Store.py | 6 +- E05_StabReport/E05_StabReport_UI_Page.ts | 3 +- E07_StabArea/E07_StabArea_Router.py | 4 +- E07_StabArea/E07_StabArea_UI_Page.ts | 6 +- M01_MasterData/M01_MasterData_Store_Combo.py | 4 +- M01_MasterData/M01_MasterData_Store_Make.py | 4 +- M02_MasterTemplete/M02_Hopyo_Build.py | 4 +- M02_MasterTemplete/M02_Hopyo_Ledger.py | 4 +- M02_MasterTemplete/M02_Hopyo_Store.py | 4 +- M02_MasterTemplete/M02_Item_Snapshot.py | 6 +- .../M02_MasterTemplete_Combo_Store.py | 4 +- .../M02_MasterTemplete_Hopyo_Ledger.ts | 5 +- .../M02_MasterTemplete_ItemList.ts | 3 +- .../M02_MasterTemplete_Items.py | 6 +- .../M02_MasterTemplete_Store.py | 4 +- M02_MasterTemplete/M02_Template_Layers.py | 6 +- M02_MasterTemplete/M02_Template_Migrate.py | 5 +- .../StructOptions/M02_SO_Def.ts | 3 +- .../StructOptions/M02_SO_Definition.py | 4 +- common_util/common_util_project_copy.py | 6 +- common_util/common_util_time.py | 52 ++++++++++ common_util/common_util_time.ts | 72 ++++++++++++++ common_util/common_util_time_kst.ts | 17 +--- common_util/common_util_workflow_state.py | 4 +- main.py | 2 + .../tester/test_547_1_project_summary.py | 5 +- resources/tester/test_549_b01_sys_pages.py | 1 + resources/tester/test_593_time.py | 99 +++++++++++++++++++ ui_template/ui_template_item_picker_detail.ts | 3 +- 62 files changed, 364 insertions(+), 140 deletions(-) create mode 100644 common_util/common_util_time.py create mode 100644 common_util/common_util_time.ts create mode 100644 resources/tester/test_593_time.py diff --git a/B01_Dashboard/B01_Dashboard_Router_Home.py b/B01_Dashboard/B01_Dashboard_Router_Home.py index de6e859b9..4a72b0076 100644 --- a/B01_Dashboard/B01_Dashboard_Router_Home.py +++ b/B01_Dashboard/B01_Dashboard_Router_Home.py @@ -1,12 +1,13 @@ """B01 대시보드 홈 API (PLAN 547) — 프로젝트 카드 요약 · 역할별 홈.""" -from datetime import datetime, timedelta +from datetime import timedelta from typing import Any import aiomysql from fastapi import APIRouter, Depends, Query from common_util.common_util_auth import is_forest_email, verify_session +from common_util.common_util_time import utc_now_naive, utc_text from config.config_db import get_db_pool from .B01_Dashboard_Repository import get_system_resources @@ -111,13 +112,13 @@ async def _todos(cursor: Any, session: dict[str, Any], role: str) -> dict[str, A (int(session["user_id"]),), ) batches = list(await cursor.fetchall()) - soon = datetime.now() + timedelta(days=EXPIRE_SOON_DAYS) + soon = utc_now_naive() + timedelta(days=EXPIRE_SOON_DAYS) todos["expiring_temp"] = [ { "batch_id": str(b["id"]), "name": b["name"], - "expires_at": b["expires_at"].isoformat(), - "days_left": max(0, (b["expires_at"] - datetime.now()).days), + "expires_at": utc_text(b["expires_at"]), + "days_left": max(0, (b["expires_at"] - utc_now_naive()).days), } for b in batches if b["expires_at"] and b["expires_at"] <= soon diff --git a/B01_Dashboard/B01_Dashboard_Router_Overview.py b/B01_Dashboard/B01_Dashboard_Router_Overview.py index 563a45c7f..615b534af 100644 --- a/B01_Dashboard/B01_Dashboard_Router_Overview.py +++ b/B01_Dashboard/B01_Dashboard_Router_Overview.py @@ -14,6 +14,7 @@ import aiomysql from fastapi import APIRouter, Depends, HTTPException, Query from common_util.common_util_auth import verify_session +from common_util.common_util_time import utc_now_naive from config.config_db import get_db_pool from .B01_Dashboard_Repository import _project_rows @@ -64,7 +65,7 @@ def idle_days(updated_at: datetime | None, now: datetime | None = None) -> int | """마지막 수정 뒤 지난 날 수.""" if updated_at is None: return None - return ((now or datetime.now()) - updated_at).days + return ((now or utc_now_naive()) - updated_at).days def is_mine(row: dict[str, Any], user_id: int) -> bool: diff --git a/B01_Dashboard/B01_Dashboard_Summary.py b/B01_Dashboard/B01_Dashboard_Summary.py index d9f7f04cd..659b8c433 100644 --- a/B01_Dashboard/B01_Dashboard_Summary.py +++ b/B01_Dashboard/B01_Dashboard_Summary.py @@ -9,12 +9,12 @@ from __future__ import annotations import json -from datetime import datetime from pathlib import Path from typing import Any import aiomysql +from common_util.common_util_time import utc_now_naive, utc_text from config.config_db import get_db_pool from M02_MasterTemplete.M02_Template_Migrate import project_root @@ -125,7 +125,7 @@ async def project_summaries(project_ids: list[str]) -> list[dict[str, Any]]: ) stations = {int(r["route_id"]): int(r["n"]) for r in await cursor.fetchall()} - now = datetime.now() + now = utc_now_naive() result = [] for pid in ids: row = rows.get(pid) @@ -159,7 +159,7 @@ async def project_summaries(project_ids: list[str]) -> list[dict[str, Any]]: "stage_total": len(names), "stage_name": names[index], "done": bool(stages) and all(s["state"] == "COMPLETE" for s in stages), - "updated_at": updated.isoformat() if updated else None, + "updated_at": utc_text(updated), "stale_days": (now - updated).days if updated else None, "owner_user_id": row.get("user_id"), "owner_names": owners, diff --git a/B01_Dashboard/B01_Dashboard_UI_Common.ts b/B01_Dashboard/B01_Dashboard_UI_Common.ts index 1674b3cac..80abc62c5 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Common.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Common.ts @@ -7,6 +7,7 @@ import { showToast, } from "@ui/ui_template_elements"; import type { DashboardUser } from "./B01_Dashboard_Api_Fetch"; +import { dateText, timeText } from "../common_util/common_util_time"; /** * 목록형 컨테이너가 한 번에 보여 주는 행 수. 넘치는 만큼은 컨테이너 안에서 스크롤한다 — @@ -36,16 +37,9 @@ export async function runRequest(action: () => Promise): Promise } } -export function formatDate(value?: string | null): string { - return value ? value.slice(0, 10) : "-"; -} - -/** 시:분 — 표에서 날짜 아래 줄에 붙인다 (2026-09-06 사용자 지시). */ -export function formatTime(value?: string | null): string { - if (!value) return ""; - const time = value.includes("T") ? value.split("T")[1] : value.slice(11); - return time ? time.slice(0, 5) : ""; -} +/** 날짜 · 시:분 — 서버 UTC 를 보는 PC 시간대로(`common_util_time`). 시:분은 표에서 날짜 아래 줄에 붙인다. */ +export const formatDate = (value?: string | null): string => dateText(value); +export const formatTime = (value?: string | null): string => timeText(value); /** * 위·아래 두 줄짜리 표 칸. 아랫줄은 작은 글씨라 **행 높이는 한 줄일 때와 같다**. diff --git a/B01_Dashboard/B01_Dashboard_UI_Home_Cards.ts b/B01_Dashboard/B01_Dashboard_UI_Home_Cards.ts index ca7502b4a..35a638f52 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Home_Cards.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Home_Cards.ts @@ -9,6 +9,7 @@ import { createButton, el } from "@ui/ui_template_elements"; import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav"; import { groupFlowOf } from "../A00_Common/group_workflow"; import type { ProjectSummary } from "./B01_Dashboard_Api_Home"; +import { calendarDaysAgo } from "../common_util/common_util_time"; import { L } from "./B01_Dashboard_UI_Common"; import { isStale } from "./logic/B01_Dashboard_Home_Layout"; @@ -59,10 +60,10 @@ export const stageText = (card: ProjectSummary): string => /** 「n 일째 그대로」 · 오늘 · n 일 전 */ export function whenText(card: ProjectSummary): string { - const days = card.stale_days ?? null; + const days = calendarDaysAgo(card.updated_at) ?? card.stale_days ?? null; if (days === null) return ""; if (isStale(card)) return `${days} 일째 그대로`; - return days === 0 ? "오늘" : `${days} 일 전`; + return days <= 0 ? "오늘" : `${days} 일 전`; } export function continueStage(card: ProjectSummary): void { diff --git a/B01_Dashboard/B01_Dashboard_UI_ProjectDetail.ts b/B01_Dashboard/B01_Dashboard_UI_ProjectDetail.ts index f2c0da38b..701f4181f 100644 --- a/B01_Dashboard/B01_Dashboard_UI_ProjectDetail.ts +++ b/B01_Dashboard/B01_Dashboard_UI_ProjectDetail.ts @@ -28,6 +28,7 @@ import { stageProgress, } from "./B01_Dashboard_UI_ProjectList_Parts"; import { openMenuAt, rowMenuItems } from "./B01_Dashboard_UI_ProjectList_Views"; +import { dateText, dateTimeText } from "../common_util/common_util_time"; /** 인쇄 · 내려받기 화면(B09 초안 길) — 도면 PDF · 수량 · 내역 xlsx 를 받는 곳 */ const PRINT_ROUTE = "b09-print" as RoutePath; @@ -117,7 +118,7 @@ function infoPane(detail: ProjectDetail): HTMLElement { ["시행청", p.client_org], ["사업 번호", p.project_number], ["담당", people], - ["만든 날", (p.created_at ?? "").slice(0, 10)], + ["만든 날", dateText(p.created_at, "")], ]; const kv = el("div", { className: "b01-pd-kv" }); for (const [label, value] of rows) { @@ -178,7 +179,7 @@ function changeText(change: ProjectChange, detail: ProjectDetail): string { : change.action === "PROJECT_CREATE" ? "만듦" : "정보 고침"; - return [change.at.slice(0, 16), change.who, what].filter(Boolean).join(" · "); + return [dateTimeText(change.at, ""), change.who, what].filter(Boolean).join(" · "); } function changePane(detail: ProjectDetail): HTMLElement | null { diff --git a/B01_Dashboard/B01_Dashboard_UI_ProjectList_Parts.ts b/B01_Dashboard/B01_Dashboard_UI_ProjectList_Parts.ts index 641fe0ec7..50f23cfca 100644 --- a/B01_Dashboard/B01_Dashboard_UI_ProjectList_Parts.ts +++ b/B01_Dashboard/B01_Dashboard_UI_ProjectList_Parts.ts @@ -9,6 +9,7 @@ import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflo import { groupFlowOf } from "../A00_Common/group_workflow"; import type { RoutePath } from "@config/config_frontend"; import type { BoardColumn, OverviewProject } from "./B01_Dashboard_Api_Projects"; +import { agoText } from "../common_util/common_util_time"; import { L } from "./B01_Dashboard_UI_Common"; /** 종류 — 칩 · 배지 차례 · 사방설계 = 준비 중 */ @@ -121,10 +122,5 @@ export function thumbSvg(points: number[][] | null | undefined): SVGElement { /** 「오늘」 · 「어제」 · 「n 일 전」 · 그 밖 날짜 */ export function ago(project: OverviewProject): string { - const days = project.idle_days; - if (days === null || days === undefined) return "-"; - if (days <= 0) return "오늘"; - if (days === 1) return "어제"; - if (days < 7) return `${days} 일 전`; - return (project.updated_at ?? "").slice(0, 10); + return agoText(project.updated_at); } diff --git a/B01_Dashboard/B01_Dashboard_UI_ProjectList_Views.ts b/B01_Dashboard/B01_Dashboard_UI_ProjectList_Views.ts index 70d3fde77..252ef0238 100644 --- a/B01_Dashboard/B01_Dashboard_UI_ProjectList_Views.ts +++ b/B01_Dashboard/B01_Dashboard_UI_ProjectList_Views.ts @@ -11,6 +11,7 @@ import { import { el } from "@ui/ui_template_elements_base"; import type { DashboardUser } from "./B01_Dashboard_Api_Fetch"; import type { OverviewProject } from "./B01_Dashboard_Api_Projects"; +import { dateText } from "../common_util/common_util_time"; import { canDeleteProject, canEditProject } from "./B01_Dashboard_UI_Helper"; import { openDeleteProjectModal, openEditProjectModal } from "./B01_Dashboard_UI_Modals"; import { @@ -148,7 +149,7 @@ export function projectRows( }), el("td", { text: boardLabel(project.board) }), el("td", { text: project.manager_name ?? "-" }), - el("td", { text: (project.updated_at ?? "").slice(0, 10) || "-" }), + el("td", { text: dateText(project.updated_at) }), el("td", { children: [more] }), ], }); diff --git a/B01_Dashboard/B01_Dashboard_UI_Shell.ts b/B01_Dashboard/B01_Dashboard_UI_Shell.ts index 83b1edd6c..935c8d2ab 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Shell.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Shell.ts @@ -12,6 +12,7 @@ import { icon, type IconName } from "@ui/ui_template_icons"; import { currentLanguageIndex, setLanguage } from "@ui/ui_template_locale"; import { readTouchMode, setTouchMode, type TouchMode } from "../A00_Common/device_context"; import { currentQuery, navigateTo, type MountedPage } from "../A00_Common/router"; +import { parseUtcMs } from "../common_util/common_util_time"; import { fetchAllCompanies, fetchCompanyJoinRequests, @@ -216,7 +217,7 @@ async function refreshBadges(ctx: ViewCtx, nav: DashboardNav, bell: HTMLElement) const newPlans = plans.filter((p) => !p.linked_project_id || p.update_available).length; const now = Date.now(); const expiring = temp.filter( - (b) => b.expires_at && Date.parse(b.expires_at) - now < EXPIRE_SOON_MS, + (b) => b.expires_at && (parseUtcMs(b.expires_at) ?? Infinity) - now < EXPIRE_SOON_MS, ).length; nav.setBadge("people", joins.length, true); nav.setBadge("assigned", newPlans, true); diff --git a/B01_Dashboard/B01_Dashboard_UI_TempUpload.ts b/B01_Dashboard/B01_Dashboard_UI_TempUpload.ts index f0a655371..7cb7371c7 100644 --- a/B01_Dashboard/B01_Dashboard_UI_TempUpload.ts +++ b/B01_Dashboard/B01_Dashboard_UI_TempUpload.ts @@ -29,6 +29,7 @@ import { uploadTempChunk, type TempBatchItem, } from "./B01_Dashboard_Api_Temp"; +import { dateText } from "../common_util/common_util_time"; import { L } from "./B01_Dashboard_UI_Common"; import { badge, @@ -73,12 +74,6 @@ function taskKey(file: File): string { return `${file.name}::${file.size}`; } -function formatDate(value: string | null): string { - if (!value) return "-"; - const date = new Date(value); - return Number.isNaN(date.getTime()) ? "-" : date.toLocaleDateString(); -} - const COLUMNS: Column[] = [ { label: "보관 이름" }, { label: "상태", width: "120px" }, @@ -263,7 +258,7 @@ export function buildTempBox(onGuide: () => void): TempBox { nameWithCaret(batch, opened), status, leftCell(batch), - formatDate(batch.created_at), + dateText(batch.created_at), rowMenu([ { label: L("B01_Temp_Btn_Add"), onClick: () => openAddModal(batch) }, { diff --git a/B01_Dashboard/B01_Dashboard_UI_View_SysLogs.ts b/B01_Dashboard/B01_Dashboard_UI_View_SysLogs.ts index 16d7d9940..cc7b8b0e6 100644 --- a/B01_Dashboard/B01_Dashboard_UI_View_SysLogs.ts +++ b/B01_Dashboard/B01_Dashboard_UI_View_SysLogs.ts @@ -6,6 +6,7 @@ import { fetchAuditLogPage, type AuditLog } from "./B01_Dashboard_Api_Fetch"; import { formatDate, formatTime } from "./B01_Dashboard_UI_Common"; +import { dateText } from "../common_util/common_util_time"; import { createButton } from "@ui/ui_template_elements"; import { dataTable, emptyState, pageFrame } from "./B01_Dashboard_UI_PageKit"; import { el as h, loadPage, searchBox, selectBox, toolbar } from "./B01_Dashboard_UI_View_Parts"; @@ -54,7 +55,7 @@ export interface LogFilter { /** 동작 · 기간(날짜 YYYY-MM-DD 양끝 포함) · 찾기(이메일 · 대상 · 접속 주소) */ export function filterLogs(logs: AuditLog[], f: LogFilter): AuditLog[] { return logs.filter((l) => { - const day = (l.timestamp ?? "").slice(0, 10); + const day = dateText(l.timestamp, ""); if (f.action && l.action !== f.action) return false; if (f.from && day < f.from) return false; if (f.to && day > f.to) return false; diff --git a/B01_Dashboard/logic/B01_Dashboard_Logic_Pages.ts b/B01_Dashboard/logic/B01_Dashboard_Logic_Pages.ts index 553069fab..4d71b627c 100644 --- a/B01_Dashboard/logic/B01_Dashboard_Logic_Pages.ts +++ b/B01_Dashboard/logic/B01_Dashboard_Logic_Pages.ts @@ -43,7 +43,9 @@ export function daysLeft( now: number = Date.now(), ): number | null { if (!expiresAt) return null; - const at = new Date(expiresAt).getTime(); + // 시간대 표시 없는 서버 글 = UTC(짝: common_util_time.parseUtcMs — 이 파일은 단독으로 돎) + const iso = expiresAt.trim().replace(" ", "T"); + const at = Date.parse(/(Z|[+-]\d\d:?\d\d)$/.test(iso) ? iso : `${iso}Z`); if (Number.isNaN(at)) return null; return Math.max(0, Math.ceil((at - now) / DAY_MS)); } diff --git a/B02_ProjRegister/B02_ProjRegister_Repository.py b/B02_ProjRegister/B02_ProjRegister_Repository.py index 7eedcccaa..4cf8d6a30 100644 --- a/B02_ProjRegister/B02_ProjRegister_Repository.py +++ b/B02_ProjRegister/B02_ProjRegister_Repository.py @@ -13,6 +13,7 @@ import aiomysql from common_util.common_util_audit import record_audit from common_util.common_util_json import atomic_write_json from common_util.common_util_storage import PROJECT_STORAGE_LAYOUT_V2 +from common_util.common_util_time import utc_now_text from common_util.common_util_workflow import load_project_workflow from common_util.common_util_workflow_state import initialize_project_stages from config.config_db import get_db_pool @@ -40,7 +41,7 @@ def _initialize_project_storage(project_root: Path, project_id: str) -> None: { "project_id": project_id, "storage_version": 2, - "created_at": datetime.utcnow().isoformat(timespec="seconds"), + "created_at": utc_now_text(), "stages": [f"{stage}/{subdir}" for stage, subdir in PROJECT_STORAGE_LAYOUT_V2], }, ) diff --git a/B03_FileInput/B03_FileInput_Router_Temp_Support.py b/B03_FileInput/B03_FileInput_Router_Temp_Support.py index 30e7477a1..cafad8b68 100644 --- a/B03_FileInput/B03_FileInput_Router_Temp_Support.py +++ b/B03_FileInput/B03_FileInput_Router_Temp_Support.py @@ -3,6 +3,7 @@ 라우터가 700줄을 넘어 떼어냈다(2026-09-04). 본체와 청크 모듈이 함께 쓰는 것만 둔다. """ +from datetime import datetime from pathlib import Path from typing import Any @@ -16,6 +17,7 @@ from B03_FileInput.B03_FileInput_Repository_Temp import ( from common_util.common_util_storage import ( resolve_temp_batch_path, ) +from common_util.common_util_time import utc_text _POINT_CLOUD_FILE_TYPES = frozenset({"las", "laz"}) @@ -25,7 +27,7 @@ def _total_chunks(size_bytes: int, chunk_size_bytes: int) -> int: def _iso(value: Any) -> str | None: - return value.isoformat() if value is not None and hasattr(value, "isoformat") else None + return utc_text(value) if isinstance(value, datetime) else None async def _batch_root(connection: Any, *, batch_id: str, user_id: int) -> Path: diff --git a/B04_PreProcess/B04_PreProcess_Engine_SheetStore.py b/B04_PreProcess/B04_PreProcess_Engine_SheetStore.py index 58bd99cc0..5c05a924f 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_SheetStore.py +++ b/B04_PreProcess/B04_PreProcess_Engine_SheetStore.py @@ -15,7 +15,6 @@ from __future__ import annotations import base64 -import datetime import http.cookiejar import json import logging @@ -27,6 +26,7 @@ import urllib.request import zipfile from pathlib import Path +from common_util.common_util_time import kst_today from config.config_system import ( MAP_SHEETS_DIRNAME, MAP_SHEETS_INDEX_FILENAME, @@ -305,7 +305,7 @@ def ingest_zip(store_dir: str | Path, src_path: str | Path, sheet_no: str | None "original_name": src.name, "crs": crs, "bounds_wgs84": [round(v, 8) for v in bounds], - "acquired": datetime.date.today().isoformat(), + "acquired": kst_today().isoformat(), "source": "수동 다운로드 인제스트", } _save_index(store, index) diff --git a/B04_PreProcess/B04_PreProcess_Engine_Watershed_Export.py b/B04_PreProcess/B04_PreProcess_Engine_Watershed_Export.py index 9ff75451e..c0231b68f 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_Watershed_Export.py +++ b/B04_PreProcess/B04_PreProcess_Engine_Watershed_Export.py @@ -15,7 +15,6 @@ from __future__ import annotations import json import logging from collections.abc import Sequence -from datetime import datetime from pathlib import Path from typing import Any, Callable @@ -23,6 +22,7 @@ import numpy as np from shapely.geometry.base import BaseGeometry from common_util.common_util_storage import resolve_stored_project_path +from common_util.common_util_time import utc_now_text from config.config_system import DRAINAGE_CACHE_DIRNAME logger = logging.getLogger(__name__) @@ -181,7 +181,7 @@ def _update_manifest( logger.warning("배수유역: manifest를 읽지 못해 새로 만듭니다 (%s).", manifest_path) manifest[stage] = { "file": filename, - "saved_at": datetime.now().isoformat(timespec="seconds"), + "saved_at": utc_now_text(), "properties": properties, } try: diff --git a/B04_PreProcess/B04_PreProcess_Repository.py b/B04_PreProcess/B04_PreProcess_Repository.py index 7adf6f9a0..241d6cc46 100644 --- a/B04_PreProcess/B04_PreProcess_Repository.py +++ b/B04_PreProcess/B04_PreProcess_Repository.py @@ -12,6 +12,8 @@ from uuid import UUID import aiomysql +from common_util.common_util_time import utc_text + _STAGE_ROOT = "B04_PreProcess" @@ -224,7 +226,7 @@ async def list_project_point_cloud_inputs( "file_size_mb": float(row[4]) if row[4] is not None else None, "crs_epsg": row[5], "status": row[6], - "created_at": row[7].isoformat() if row[7] else None, + "created_at": utc_text(row[7]), } for row in rows ] @@ -279,7 +281,7 @@ async def list_surface_models( "resolution_m": row[3], "model_file_path": row[4], "generation_params": generation_params, - "created_at": row[6].isoformat() if row[6] else None, + "created_at": utc_text(row[6]), } ) return models diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Store.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Store.py index 663a77c6c..c19084da5 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial_Store.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Store.py @@ -25,7 +25,6 @@ import hashlib import json import time from contextlib import asynccontextmanager -from datetime import datetime from pathlib import Path from typing import Any, AsyncIterator, Callable @@ -37,6 +36,7 @@ from B05_Profile.B05_Profile_Engine_RouteInitial import ( resolve_weights, ) from common_util.common_util_json import atomic_write_json +from common_util.common_util_time import utc_now_text # 3 = 곡선 MC 일반측점(351-2) · 4 = 다투는 곡선 끔(364-5) · 5 = 후보마다 맞춤(383-1) · # 6 = 같은 쪽 곡선 합치기 · 거리 한도 대신 직접 잼(406) · 7 = 측점 밖이 늘면 옛 차례 맞춤(406-4) · @@ -202,7 +202,7 @@ def write_stored( "key": key, "pool": pool, "default": default, - "created_at": datetime.now().isoformat(timespec="seconds"), + "created_at": utc_now_text(), "request": request, "result": result, }, diff --git a/B05_Profile/B05_Profile_Repository.py b/B05_Profile/B05_Profile_Repository.py index 540348c59..b2eb0287e 100644 --- a/B05_Profile/B05_Profile_Repository.py +++ b/B05_Profile/B05_Profile_Repository.py @@ -12,6 +12,8 @@ from uuid import UUID import aiomysql +from common_util.common_util_time import utc_text + _STAGE_ROOT = "B05_Profile" @@ -172,7 +174,7 @@ async def get_latest_route( return None result = dict(row) result["id"] = int(result["id"]) - result["computed_at"] = result["computed_at"].isoformat() if result["computed_at"] else None + result["computed_at"] = utc_text(result["computed_at"]) for key in ("constraints", "algorithm_params"): if isinstance(result.get(key), str): result[key] = json.loads(result[key]) diff --git a/B06_Section/B06_Section_Repository.py b/B06_Section/B06_Section_Repository.py index b9d243f68..d33d198cf 100644 --- a/B06_Section/B06_Section_Repository.py +++ b/B06_Section/B06_Section_Repository.py @@ -13,6 +13,8 @@ from uuid import UUID import aiomysql +from common_util.common_util_time import utc_text + _STAGE_ROOT = "B06_Section" @@ -335,7 +337,7 @@ async def get_longitudinal_section( "data": data if isinstance(data, dict) else None, "longitudinal_file_path": row[2], "status": row[3], - "computed_at": row[4].isoformat() if row[4] else None, + "computed_at": utc_text(row[4]), } diff --git a/B09_Estimation/B09_Estimation_Calc_Total.py b/B09_Estimation/B09_Estimation_Calc_Total.py index 497b5692b..b54e624a1 100644 --- a/B09_Estimation/B09_Estimation_Calc_Total.py +++ b/B09_Estimation/B09_Estimation_Calc_Total.py @@ -24,10 +24,10 @@ from __future__ import annotations -from datetime import date from typing import Any from B09_Estimation.B09_Estimation_Calc_Base import num, rounddown, roundup, total +from common_util.common_util_time import kst_today #: 제비율 줄 (키, 이름, M01 요율 후보 키, 산식 글) — 엑셀 총괄 49~58 차례 · 나.소계에 듦 RATE_LINES: tuple[tuple[str, str, str, str], ...] = ( @@ -155,7 +155,7 @@ def compute_total( "target": target, "price": num(est.get("estimated_price")), "days": num(est.get("duration_days")), - "year": year or date.today().year, + "year": year or kst_today().year, "env": est.get("environment_kind"), }, ) diff --git a/B09_Estimation/B09_Estimation_Prices.py b/B09_Estimation/B09_Estimation_Prices.py index d34bc262f..8a13f11d3 100644 --- a/B09_Estimation/B09_Estimation_Prices.py +++ b/B09_Estimation/B09_Estimation_Prices.py @@ -16,11 +16,11 @@ from __future__ import annotations import json -from datetime import datetime from decimal import Decimal from typing import Any from B08_Quantity.B08_Quantity_Material_Calc import DEDUCT_KEY +from common_util.common_util_time import utc_now_text from M01_MasterData import M01_MasterData_Store as m01 #: 운전사 시간 노임 계수 표(건설품셈 공통 8-1-3 · 건협기술-2230) @@ -194,7 +194,7 @@ def refresh_book(keys: list[str], book: dict[str, Any] | None = None) -> dict[st old = (book or {}).get("rows") or {} rows.update({key: old[key] for key in want["missing"] if key in old}) return { - "made": datetime.now().isoformat(timespec="seconds"), + "made": utc_now_text(), "rows": rows, "driver_factor": driver_factor(master), "fx": fx_inputs(master), diff --git a/B09_Estimation/B09_Estimation_Router_Quotes.py b/B09_Estimation/B09_Estimation_Router_Quotes.py index adb60a382..7cbe8a560 100644 --- a/B09_Estimation/B09_Estimation_Router_Quotes.py +++ b/B09_Estimation/B09_Estimation_Router_Quotes.py @@ -12,7 +12,6 @@ from __future__ import annotations import asyncio -from datetime import datetime from pathlib import Path from typing import Any from uuid import UUID @@ -23,6 +22,7 @@ from fastapi.responses import FileResponse from B03_FileInput.B03_FileInput_Engine import save_upload_stream from B08_Quantity.B08_Quantity_Router_Hydro import _project_root from B09_Estimation import B09_Estimation_Store as store +from common_util.common_util_time import utc_from_timestamp router = APIRouter() PATH = "/{project_id}/estimation/quotes" @@ -33,7 +33,7 @@ def _listing(folder: Path) -> list[dict[str, Any]]: { "name": f.name, "size": f.stat().st_size, - "at": datetime.fromtimestamp(f.stat().st_mtime).isoformat(timespec="seconds"), + "at": utc_from_timestamp(f.stat().st_mtime), } for f in store.quote_files(folder) ] diff --git a/B09_Estimation/B09_Estimation_UI_Check.ts b/B09_Estimation/B09_Estimation_UI_Check.ts index f53652d09..285941732 100644 --- a/B09_Estimation/B09_Estimation_UI_Check.ts +++ b/B09_Estimation/B09_Estimation_UI_Check.ts @@ -11,6 +11,7 @@ import { attachHoverInfo } from "@ui/ui_template_hover_info"; import { createBadge } from "@ui/ui_template_badge"; import { createText } from "@ui/ui_template_text"; import type { Row } from "./B09_Estimation_Calc_Base"; +import { dateTimeText } from "../common_util/common_util_time"; import { tabName, type Link, type TabId } from "./B09_Estimation_UI_Tabs"; export interface CheckItem { @@ -38,7 +39,7 @@ export function checkItems(sheets: Row, payload: Row): { items: CheckItem[]; not /** [업데이트] 호버 글 — 굳힌 시각 + 낡은 까닭 */ export function refreshTip(payload: Row | null): string { const made = payload?.book?.made; - const when = made ? `단가 굳힘 ${String(made).replace("T", " ")}` : "단가 아직 안 굳힘"; + const when = made ? `단가 굳힘 ${dateTimeText(String(made))}` : "단가 아직 안 굳힘"; const stale = payload?.stale; return `${when} · 누르면 지금 M01 값으로 새로 굳힘${stale ? ` · 업데이트 필요 — ${stale}` : ""}`; } diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts index 02ed55245..1091fc393 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts @@ -18,6 +18,7 @@ import type { MapContextMenuItem } from "@ui/ui_template_context_menu"; import type { DataRow } from "@ui/ui_template_data_table"; import { SOURCES, type Row } from "./B09_Estimation_Calc_Base"; +import { dateText } from "../common_util/common_util_time"; import { openQuotes } from "./B09_Estimation_UI_Tabs_Base_Quote"; import { blockLevel, @@ -64,7 +65,7 @@ export const buildExpense: TabBuild = ({ sheets, payload }) => ); function madeText(payload: Row): string { - return payload.book?.made ? `${String(payload.book.made).slice(0, 10)} 굳힘` : "아직 안 굳힘"; + return payload.book?.made ? `${dateText(String(payload.book.made))} 굳힘` : "아직 안 굳힘"; } export const buildBasis: TabBuild = ({ sheets }): TabTable => { diff --git a/B09_Estimation/draft/B09_Draft_Schedule_Source.py b/B09_Estimation/draft/B09_Draft_Schedule_Source.py index ad206aee7..4d098a054 100644 --- a/B09_Estimation/draft/B09_Draft_Schedule_Source.py +++ b/B09_Estimation/draft/B09_Draft_Schedule_Source.py @@ -25,6 +25,7 @@ from typing import Any from B09_Estimation.B09_Estimation_Calc_Base import num from B09_Estimation.B09_Estimation_Summary import expand, hopyo_lines from B09_Estimation.draft.B09_Draft_Schedule_Calc import HOLIDAY_KINDS, compute_schedule +from common_util.common_util_time import kst_today HOLIDAYS = Path(__file__).with_name("B09_Draft_Schedule_Holidays.json") CLIMATE = { @@ -116,7 +117,7 @@ def holiday_table() -> dict[str, list[list[str]]]: def schedule_inputs(sheets: dict[str, Any], today: datetime.date | None = None) -> dict[str, Any]: - today = today or datetime.date.today() + today = today or kst_today() return { "units": unit_resources(sheets), "holidays": holiday_table(), diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_ForestRoad.py b/D03_PlanAnalysis/D03_PlanAnalysis_ForestRoad.py index cb7e6fa8f..052a24828 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_ForestRoad.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_ForestRoad.py @@ -19,7 +19,6 @@ import math import os import tempfile import zipfile -from datetime import datetime from pathlib import Path from typing import Any @@ -30,6 +29,7 @@ from common_util.common_util_derived_store import ( write_derived, ) from common_util.common_util_json import atomic_write_json +from common_util.common_util_time import utc_now_text Box = tuple[float, float, float, float] @@ -192,7 +192,7 @@ def save_roads( { "files": [Path(name).name for name, _ in files], "bytes": len(data), - "uploaded_at": datetime.now().isoformat(timespec="seconds"), + "uploaded_at": utc_now_text(), }, ) return road_layer(project_root, box, epsg) diff --git a/E03_StabSection/E03_StabSection_Router.py b/E03_StabSection/E03_StabSection_Router.py index 664abfa09..3ad7f2040 100644 --- a/E03_StabSection/E03_StabSection_Router.py +++ b/E03_StabSection/E03_StabSection_Router.py @@ -26,7 +26,6 @@ import os import subprocess import sys import tempfile -import time from functools import lru_cache from pathlib import Path from typing import Any @@ -48,6 +47,7 @@ from common_util.common_util_derived_store import ( from common_util.common_util_json import atomic_write_json from common_util.common_util_station_key import chainage_key from common_util.common_util_surface_sampler import DtmGridSampler +from common_util.common_util_time import utc_now_text from E01_StabRegister import E01_StabRegister_Source as src from E02_StabGround import E02_StabGround_Model as ground_model from E02_StabGround import E02_StabGround_Router as e02 @@ -307,7 +307,7 @@ def run_all(root: Path, plan: dict[str, Any], report: Any) -> int: rows.append(view.station_row(value["meta"], value["out"], plan["factors"], value["error"])) summary = { "fingerprint": plan["fp"], - "computed_at": time.strftime("%Y-%m-%dT%H:%M:%S"), + "computed_at": utc_now_text(), "conditions": shared["conditions"], "method": view.PRIMARY, "factors": plan["factors"], diff --git a/E03_StabSection/E03_StabSection_Router_Prob.py b/E03_StabSection/E03_StabSection_Router_Prob.py index 981ab6e07..1f673ade0 100644 --- a/E03_StabSection/E03_StabSection_Router_Prob.py +++ b/E03_StabSection/E03_StabSection_Router_Prob.py @@ -22,7 +22,6 @@ import os import subprocess import sys import tempfile -import time from functools import lru_cache from pathlib import Path from typing import Any, Literal @@ -37,6 +36,7 @@ from common_util.common_util_derived_store import ( read_derived, write_derived, ) +from common_util.common_util_time import utc_now_text from E02_StabGround import E02_StabGround_Router as e02 from E03_StabSection import E03_StabSection_Engine_Prob as prob from E03_StabSection import E03_StabSection_Router as e03 @@ -286,7 +286,7 @@ def run_prob(root: Path, station_key: str, req: ProbRequest, report: Any) -> dic "surface_kind": slot["surface"]["kind"], "request": req.model_dump(), "result": result, - "computed_at": time.strftime("%Y-%m-%dT%H:%M:%S"), + "computed_at": utc_now_text(), } write_derived(root, KIND, key, record, keep=KEEP) return record diff --git a/E03_StabSection/E03_StabSection_Router_WhatIf.py b/E03_StabSection/E03_StabSection_Router_WhatIf.py index d466b60a5..93ed6a367 100644 --- a/E03_StabSection/E03_StabSection_Router_WhatIf.py +++ b/E03_StabSection/E03_StabSection_Router_WhatIf.py @@ -19,9 +19,9 @@ import hashlib import inspect import math import time +from functools import lru_cache, partial from pathlib import Path from typing import Any, Literal -from functools import lru_cache, partial from uuid import UUID from fastapi import APIRouter, HTTPException @@ -34,13 +34,14 @@ from common_util.common_util_derived_store import ( read_derived, write_derived, ) +from common_util.common_util_time import utc_now_text from E01_StabRegister import E01_StabRegister_Source as src from E02_StabGround import E02_StabGround_Model as ground_model from E02_StabGround import E02_StabGround_Router as e02 from E03_StabSection import E03_StabSection_Router as e03 from E03_StabSection import E03_StabSection_View as view -from E03_StabSection import E03_StabSection_Walls as walls_mod from E03_StabSection import E03_StabSection_WallCheck as wallcheck +from E03_StabSection import E03_StabSection_Walls as walls_mod from E03_StabSection import E03_StabSection_WhatIf as whatif from E03_StabSection.E03_StabSection_Engine_Model import WALL_MATERIAL, section_model_from_b06 from E03_StabSection.E03_StabSection_Engine_Station import analyze_station @@ -301,7 +302,7 @@ def run_whatif(root: Path, station_key: str, req: WhatIfRequest) -> dict[str, An # 엔진 고리 전 = 네일 · 앵커 힘 없음(결과 = 보강 없는 재해석 · 화면은 그림만) "support_forces": _forces(out, req.side) if supports else None, "supports_pending": bool(supports) and not engine, - "computed_at": time.strftime("%Y-%m-%dT%H:%M:%S"), + "computed_at": utc_now_text(), "elapsed_s": round(time.perf_counter() - t0, 2), } shown = _section(ctx, variant, after, out) @@ -396,7 +397,7 @@ def run_wallcheck(root: Path, station_key: str, req: WallRequest) -> dict[str, A "base_fp": row["fp"], "params": params, "walls": walls, - "computed_at": time.strftime("%Y-%m-%dT%H:%M:%S"), + "computed_at": utc_now_text(), } write_derived(root, KIND, key, record, keep=KEEP) return record diff --git a/E03_StabSection/E03_StabSection_UI_Page.ts b/E03_StabSection/E03_StabSection_UI_Page.ts index 7c907017a..e8d33eef3 100644 --- a/E03_StabSection/E03_StabSection_UI_Page.ts +++ b/E03_StabSection/E03_StabSection_UI_Page.ts @@ -24,6 +24,7 @@ import { import { t, type LocaleKey } from "@ui/ui_template_locale"; import { createSegment } from "@ui/ui_template_segment"; import { currentProjectId } from "../A00_Common/b_project_switch"; +import { dateTimeText } from "../common_util/common_util_time"; import { mountGroupStep } from "../A00_Common/group_workflow"; import { buildMissingBanner } from "../E01_StabRegister/E01_StabRegister_UI_Missing"; import { buildList } from "./E03_StabSection_UI_List"; @@ -279,7 +280,7 @@ export function renderE03StabSection(root: HTMLElement): void { if (!result) lines.push(t("E03_Loading")); else if (result.status === "none") lines.push(t("E03_NotYet")); else { - lines.push(`${t("E03_Computed")} ${result.computed_at ?? ""}`); + lines.push(`${t("E03_Computed")} ${dateTimeText(result.computed_at, "")}`); if (!result.fresh) lines.push(t("E03_Stale")); } if (result && !result.editable) lines.push(t("E03_ReadOnly")); diff --git a/E04_Stab3D/E04_Stab3D_Run.py b/E04_Stab3D/E04_Stab3D_Run.py index 9569498ee..001705de0 100644 --- a/E04_Stab3D/E04_Stab3D_Run.py +++ b/E04_Stab3D/E04_Stab3D_Run.py @@ -22,7 +22,6 @@ import json import math import time from collections.abc import Callable -from datetime import datetime from functools import lru_cache from pathlib import Path from typing import Any @@ -30,6 +29,7 @@ from typing import Any import numpy as np from scipy.spatial import cKDTree +from common_util.common_util_time import utc_now_text from E01_StabRegister import E01_StabRegister_Source as source from E02_StabGround import E02_StabGround_Model as ground_model from E03_StabSection.E03_StabSection_Types import DEFAULT_ROAD_SURCHARGE_KPA @@ -335,7 +335,7 @@ def pack( "fingerprint": finger, "engine": engine_hash(), "settings": settings, - "computed_at": datetime.now().isoformat(timespec="seconds"), + "computed_at": utc_now_text(), "elapsed_s": round(elapsed_s, 2), "grid": grid, "z": _flat(np.asarray(raw["z"], dtype=float), 2), diff --git a/E05_StabReport/E05_StabReport_Build.py b/E05_StabReport/E05_StabReport_Build.py index e1f130792..0abc3eaa9 100644 --- a/E05_StabReport/E05_StabReport_Build.py +++ b/E05_StabReport/E05_StabReport_Build.py @@ -25,11 +25,11 @@ from __future__ import annotations import hashlib import json import re -from datetime import date from pathlib import Path from typing import Any from common_util.common_util_derived_store import read_derived +from common_util.common_util_time import kst_today from E01_StabRegister import E01_StabRegister_Source as source from E02_StabGround import E02_StabGround_Model as ground_model from E03_StabSection import E03_StabSection_View as view2d @@ -263,7 +263,7 @@ def fill_cover(sheet: dict[str, Any], inputs: dict[str, Any]) -> None: "C9", " · ".join(str(v) for v in (project.get("region"), project.get("road_type")) if v), ) - _set(sheet, "C11", date.today().isoformat()) + _set(sheet, "C11", kst_today().isoformat()) # ── 프로그램 몫 ────────────────────────────────────────────────────────────── diff --git a/E05_StabReport/E05_StabReport_Store.py b/E05_StabReport/E05_StabReport_Store.py index d234d8959..d215c0663 100644 --- a/E05_StabReport/E05_StabReport_Store.py +++ b/E05_StabReport/E05_StabReport_Store.py @@ -14,7 +14,6 @@ from __future__ import annotations import hashlib import shutil -from datetime import datetime from pathlib import Path from typing import Any @@ -22,6 +21,7 @@ from common_util.common_util_json import atomic_write_json from common_util.common_util_print_pdf import PrintError from common_util.common_util_spreadsheet import with_server_values from common_util.common_util_svg_png import svgs_to_pngs +from common_util.common_util_time import kst_now, utc_now_text from E05_StabReport import E05_StabReport_Print as printer from E05_StabReport.E05_StabReport_Build import ( META, @@ -78,7 +78,7 @@ def _numbered(book: dict[str, Any]) -> dict[str, Any]: def _meta(book: dict[str, Any], inputs: dict[str, Any], figures: list[str]) -> dict[str, Any]: return { "fingerprint": inputs["fingerprint"], - "built_at": datetime.now().isoformat(timespec="seconds"), + "built_at": utc_now_text(), "signs": {s["id"]: sheet_sign(s) for s in book["시트"] if s["id"] in PROGRAM_SHEETS}, "figures": figures, } @@ -142,7 +142,7 @@ def refresh(project_root: Path) -> dict[str, Any]: if overwritten: old = folder / "_지난" old.mkdir(parents=True, exist_ok=True) - stamp = datetime.now().strftime("%Y%m%d_%H%M%S") + stamp = kst_now().strftime("%Y%m%d_%H%M%S") shutil.copy2(folder / REPORT, old / f"{stamp}.json") have = {s["id"] for s in book["시트"]} # 새 판에 없는 프로그램 몫(보강검토 — 저장본 없음)은 뺌 diff --git a/E05_StabReport/E05_StabReport_UI_Page.ts b/E05_StabReport/E05_StabReport_UI_Page.ts index ea0d0485e..4ca9695ba 100644 --- a/E05_StabReport/E05_StabReport_UI_Page.ts +++ b/E05_StabReport/E05_StabReport_UI_Page.ts @@ -27,6 +27,7 @@ import { openModal } from "@ui/ui_template_modal"; import { createText } from "@ui/ui_template_text"; import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { currentProjectId } from "../A00_Common/b_project_switch"; +import { dateTimeText } from "../common_util/common_util_time"; import { mountGroupStep } from "../A00_Common/group_workflow"; import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet"; import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet_types"; @@ -162,7 +163,7 @@ export function renderE05StabReport(root: HTMLElement): void { if (busy) lines.push(t("E05_Working")); if (dirty) lines.push(t("E05_Unsaved")); if (state?.meta?.built_at) - lines.push(`${t("E05_BuiltAt")} ${state.meta.built_at.replace("T", " ")}`); + lines.push(`${t("E05_BuiltAt")} ${dateTimeText(state.meta.built_at)}`); status.replaceChildren(...(lines.length ? [note(lines.join("\n"), "e05-status__text")] : [])); const ready = Boolean(state?.book) && !busy; saveButton.disabled = !ready || !editable || !dirty; diff --git a/E07_StabArea/E07_StabArea_Router.py b/E07_StabArea/E07_StabArea_Router.py index 9ab353736..8cedd5840 100644 --- a/E07_StabArea/E07_StabArea_Router.py +++ b/E07_StabArea/E07_StabArea_Router.py @@ -25,7 +25,6 @@ import json import logging import time from collections.abc import Callable -from datetime import datetime from functools import lru_cache from pathlib import Path from typing import Any, Literal @@ -39,6 +38,7 @@ from pydantic import BaseModel, Field, model_validator from common_util.common_util_auth import require_company 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_now_text from config.config_db import get_db_pool from E01_StabRegister import E01_StabRegister_Source as source from E02_StabGround import E02_StabGround_Model as ground_model @@ -245,7 +245,7 @@ def run( "fingerprint": finger, "engine": engine_hash(), "settings": settings, - "computed_at": datetime.now().isoformat(timespec="seconds"), + "computed_at": utc_now_text(), "elapsed_s": round(time.perf_counter() - started, 2), "grid": { "x0": x0 + int(ix.min()) * cell, diff --git a/E07_StabArea/E07_StabArea_UI_Page.ts b/E07_StabArea/E07_StabArea_UI_Page.ts index 3423d9920..a4f1a43a0 100644 --- a/E07_StabArea/E07_StabArea_UI_Page.ts +++ b/E07_StabArea/E07_StabArea_UI_Page.ts @@ -32,6 +32,7 @@ import { createSidePanels } from "@ui/ui_template_side_panels"; import { createText } from "@ui/ui_template_text"; import { inferStationInterval, stationLabel } from "../common_util/common_util_station"; import { readState, writeState } from "../A00_Common/b_page_state"; +import { dateTimeText } from "../common_util/common_util_time"; import { currentProjectId } from "../A00_Common/b_project_switch"; import { mountGroupStep } from "../A00_Common/group_workflow"; import { createAreaMap, type MapLayer, type StationPoint } from "./E07_StabArea_UI_Map"; @@ -337,10 +338,7 @@ export function renderE07StabArea(root: HTMLElement): void { "E07_Sum_Covered", `${Math.round(s.covered_area_m2).toLocaleString()} / ${Math.round(s.band_area_m2).toLocaleString()} m²`, ], - [ - "E07_Sum_Time", - `${result.elapsed_s.toFixed(1)} s · ${result.computed_at.replace("T", " ")}`, - ], + ["E07_Sum_Time", `${result.elapsed_s.toFixed(1)} s · ${dateTimeText(result.computed_at)}`], ]; const list = createKv(pairs.map(([key, value]) => ({ label: t(key), value }))); list.classList.add("e07-summary"); diff --git a/M01_MasterData/M01_MasterData_Store_Combo.py b/M01_MasterData/M01_MasterData_Store_Combo.py index c8eed5a3d..2b7163325 100644 --- a/M01_MasterData/M01_MasterData_Store_Combo.py +++ b/M01_MasterData/M01_MasterData_Store_Combo.py @@ -10,9 +10,9 @@ from __future__ import annotations import json import re -from datetime import date from pathlib import Path +from common_util.common_util_time import kst_today from config import config_system from M01_MasterData import M01_MasterData_Store as store @@ -34,7 +34,7 @@ def _make_file() -> None: path = _path() if path.is_file(): return - head = {"그룹": mcb.GROUP, "판": str(date.today().year), "줄": []} + head = {"그룹": mcb.GROUP, "판": str(kst_today().year), "줄": []} path.write_text(store.dump(head), encoding="utf-8", newline="\n") diff --git a/M01_MasterData/M01_MasterData_Store_Make.py b/M01_MasterData/M01_MasterData_Store_Make.py index 13762011a..810b11c6f 100644 --- a/M01_MasterData/M01_MasterData_Store_Make.py +++ b/M01_MasterData/M01_MasterData_Store_Make.py @@ -8,9 +8,9 @@ from __future__ import annotations import re -from datetime import date from pathlib import Path +from common_util.common_util_time import kst_today from M01_MasterData import M01_MasterData_Store as store cm, mk, mcp = store.cm, store.mk, store.mcp @@ -274,7 +274,7 @@ def _make_file() -> None: path = _own_path() if path.is_file(): return - head = {"그룹": "로직", "원문": "자체", "판": str(date.today().year), "줄": []} + head = {"그룹": "로직", "원문": "자체", "판": str(kst_today().year), "줄": []} path.write_text(store.dump(head), encoding="utf-8", newline="\n") diff --git a/M02_MasterTemplete/M02_Hopyo_Build.py b/M02_MasterTemplete/M02_Hopyo_Build.py index 882836014..9546c75c1 100644 --- a/M02_MasterTemplete/M02_Hopyo_Build.py +++ b/M02_MasterTemplete/M02_Hopyo_Build.py @@ -27,11 +27,11 @@ from __future__ import annotations import json -from datetime import datetime from decimal import Decimal from pathlib import Path from typing import Any +from common_util.common_util_time import utc_now_text from M01_MasterData import M01_MasterData_Store as m01 from M02_MasterTemplete import M02_Hopyo_Ledger as ledger from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match @@ -217,7 +217,7 @@ class Builder: } def build(self, checked: dict[int, dict[str, Any]], whole: list[dict[str, Any]]) -> dict: - stamp = datetime.now().isoformat(timespec="seconds") + stamp = utc_now_text() hopyo, file = self.hopyo, self.doc.get("파일") or "" lines, mats, missing, used, mat_lines = [], {}, [], set(), {} nation: dict[tuple, dict[str, Any]] = {} diff --git a/M02_MasterTemplete/M02_Hopyo_Ledger.py b/M02_MasterTemplete/M02_Hopyo_Ledger.py index 560292915..91312db61 100644 --- a/M02_MasterTemplete/M02_Hopyo_Ledger.py +++ b/M02_MasterTemplete/M02_Hopyo_Ledger.py @@ -14,11 +14,11 @@ from __future__ import annotations -from datetime import datetime from decimal import Decimal from pathlib import Path from typing import Any +from common_util.common_util_time import utc_now_text from M01_MasterData import M01_MasterData_Store as m01 from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs @@ -245,7 +245,7 @@ def write( if not entries: return 0 have = read(folder) - stamp = datetime.now().isoformat(timespec="seconds") + stamp = utc_now_text() version = _version() out: dict[str, dict[str, Any] | None] = dict(have) # 옛 꼴도 새 꼴로 다시 씀 for full, row in entries.items(): diff --git a/M02_MasterTemplete/M02_Hopyo_Store.py b/M02_MasterTemplete/M02_Hopyo_Store.py index b82ad7b51..52f045339 100644 --- a/M02_MasterTemplete/M02_Hopyo_Store.py +++ b/M02_MasterTemplete/M02_Hopyo_Store.py @@ -11,11 +11,11 @@ from __future__ import annotations import json import re import threading -from datetime import datetime from pathlib import Path from typing import Any from common_util.common_util_json import atomic_write_json +from common_util.common_util_time import utc_now_text from M02_MasterTemplete import M02_MasterTemplete_Store as store DIRNAME = "실무자료" @@ -91,7 +91,7 @@ def save( doc = { "키": key, "파일": name, - "올린때": datetime.now().isoformat(timespec="seconds"), + "올린때": utc_now_text(), "올린이": who, **parsed, } diff --git a/M02_MasterTemplete/M02_Item_Snapshot.py b/M02_MasterTemplete/M02_Item_Snapshot.py index 8fff486b4..629c2cc1d 100644 --- a/M02_MasterTemplete/M02_Item_Snapshot.py +++ b/M02_MasterTemplete/M02_Item_Snapshot.py @@ -22,7 +22,6 @@ from __future__ import annotations import logging import re import shutil -from datetime import datetime from pathlib import Path from typing import Any @@ -31,6 +30,7 @@ from B05_Profile.B05_Profile_Structures_Schema import STRUCTURE_ID_RULE from common_util.common_util_drainage_pipes import pipe_points_path_in, read_pipe_points_file from common_util.common_util_json import atomic_write_json from common_util.common_util_summary_item import GROUP_JOIN, snapshot_folder_name +from common_util.common_util_time import kst_now, utc_now_text from M02_MasterTemplete import M02_Template_Layers as layers from M02_MasterTemplete.M02_Item_Source import ( LABOR_FILE, @@ -82,7 +82,7 @@ def safe_folder(base: Path, name: str) -> Path | None: def now_text() -> str: - return datetime.now().isoformat(timespec="seconds") + return utc_now_text() def same_item(manifest: Any, item: dict[str, Any]) -> bool: @@ -181,7 +181,7 @@ def retire(folder: Path) -> str | None: if isinstance(manifest, dict) and manifest.get("고침"): past = folder.parent / PAST_DIRNAME past.mkdir(parents=True, exist_ok=True) - target = past / f"{folder.name}_{datetime.now():%Y%m%d%H%M%S}" + target = past / f"{folder.name}_{kst_now():%Y%m%d%H%M%S}" shutil.move(str(folder), str(target)) return target.name shutil.rmtree(folder) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py index 935336213..ce991de1a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py @@ -14,11 +14,11 @@ from __future__ import annotations import json import re -from datetime import date from pathlib import Path from typing import Any from common_util.common_util_json import atomic_write_json +from common_util.common_util_time import kst_today from M01_MasterData import M01_MasterData_Store as m01 from M01_MasterData import M01_MasterData_Store_Combo as combo_store from M02_MasterTemplete import M02_MasterTemplete_Store as store @@ -109,7 +109,7 @@ def _load() -> tuple[dict[str, Any], str]: return _m01(combo_store._read) path = store.root() / FILE if not path.is_file(): - head = {"그룹": mcb.GROUP, "판": str(date.today().year), "폴더": [], "다음": 1} + head = {"그룹": mcb.GROUP, "판": str(kst_today().year), "폴더": [], "다음": 1} return {**head, "줄": []}, "" raw = path.read_bytes() return json.loads(raw), store.version_of(raw) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Ledger.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Ledger.ts index 3c8c4db34..0407a7891 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Ledger.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Ledger.ts @@ -20,6 +20,7 @@ import { type LedgerRow, } from "./M02_MasterTemplete_Hopyo_Api"; import { fetchLogicInputs } from "./M02_MasterTemplete_Labor_Api"; +import { dateTimeText } from "../common_util/common_util_time"; import { openLaborInput } from "./M02_MasterTemplete_Labor_Input"; import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; import "./M02_MasterTemplete_Hopyo.css"; @@ -262,9 +263,7 @@ export function buildHopyoLedger(opt: LedgerOptions): HTMLElement { }, }), ); - const when = [r.누가?.이름, r.때?.replace("T", " ").slice(0, 16), r.처음파일] - .filter(Boolean) - .join(" · "); + const when = [r.누가?.이름, dateTimeText(r.때, ""), r.처음파일].filter(Boolean).join(" · "); return el("tr", { className: `m02-hopyo-ledger__row${r.고침 ? "" : " is-upper"}${r.고친 ? " is-edited" : ""}`, attrs: { "data-ledger": r.열쇠 }, diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts index f74b0f691..6e04e0c2e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts @@ -9,6 +9,7 @@ import { createInputField, el, showToast } from "@ui/ui_template_elements"; import { buildPager } from "../M01_MasterData/M01_MasterData_UI_Cells"; +import { dateText } from "../common_util/common_util_time"; import { buildHeadFilters } from "../M01_MasterData/M01_MasterData_UI_Head"; import "../M01_MasterData/M01_MasterData_UI_Style.css"; import { fetchTree, type Layer, type TreeItem } from "./M02_MasterTemplete_Api_Fetch"; @@ -50,7 +51,7 @@ const TEXT = { const SIZE = 30; const DASH = "—"; -const day = (iso?: string): string => (iso ? iso.slice(0, 10) : ""); +const day = (iso?: string): string => dateText(iso, ""); const money = (value: number | null | undefined): string => typeof value === "number" ? value.toLocaleString("ko-KR", { maximumFractionDigits: 0 }) : DASH; /** 가장 늦게 고친 날 — 항목 통째 */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Items.py index 44851ff8d..317d4698d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Items.py @@ -9,11 +9,11 @@ from __future__ import annotations import json -from datetime import datetime from pathlib import Path from typing import Any from common_util.common_util_json import atomic_write_json +from common_util.common_util_time import utc_from_timestamp from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos from M02_MasterTemplete import M02_MasterTemplete_Store as store @@ -103,9 +103,7 @@ def _list_extras(folder: Path, col: str) -> dict[str, Any]: for kind in ("structure", "area", "labor"): path = folder / kind / f"{col}.json" if path.is_file(): - stamps[kind] = datetime.fromtimestamp(path.stat().st_mtime).isoformat( - timespec="seconds" - ) + stamps[kind] = utc_from_timestamp(path.stat().st_mtime) labor = None path = folder / "labor" / f"{col}.json" if path.is_file(): diff --git a/M02_MasterTemplete/M02_MasterTemplete_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Store.py index b98e59a58..06c289772 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Store.py @@ -13,12 +13,12 @@ import threading from collections.abc import Iterator from contextlib import contextmanager from contextvars import ContextVar -from datetime import datetime from pathlib import Path from typing import Any from common_util import common_util_spreadsheet as spreadsheet from common_util.common_util_json import atomic_write_json +from common_util.common_util_time import utc_from_timestamp from M02_MasterTemplete import M02_MasterTemplete_Tree as tree from M02_MasterTemplete import M02_Template_Layers as layers @@ -106,7 +106,7 @@ def _path(kind: str, name: str) -> Path: def _info(kind: str, path: Path) -> dict[str, Any]: raw = path.read_bytes() - stamp = datetime.fromtimestamp(path.stat().st_mtime).isoformat(timespec="seconds") + stamp = utc_from_timestamp(path.stat().st_mtime) row = {"종류": kind, "이름": path.stem, "판": version_of(raw), "수정일": stamp} if (kind, path.stem) in IN_USE: row["쓰는곳"] = IN_USE[(kind, path.stem)] # 화면이 지우기 확인 창 전에 막음 diff --git a/M02_MasterTemplete/M02_Template_Layers.py b/M02_MasterTemplete/M02_Template_Layers.py index b4297033c..96cc87707 100644 --- a/M02_MasterTemplete/M02_Template_Layers.py +++ b/M02_MasterTemplete/M02_Template_Layers.py @@ -18,11 +18,11 @@ from __future__ import annotations import hashlib import json import shutil -from datetime import datetime from pathlib import Path from typing import Any from common_util.common_util_json import atomic_write_json +from common_util.common_util_time import utc_from_timestamp, utc_now_text from config import config_system LAYERS = ("system", "company", "personal", "project") @@ -128,7 +128,7 @@ def version_of(path: str | Path) -> str | None: def _modified(path: Path) -> str: - return datetime.fromtimestamp(path.stat().st_mtime).isoformat(timespec="seconds") + return utc_from_timestamp(path.stat().st_mtime) def list_templates(layer_dir: str | Path) -> list[dict[str, Any]]: @@ -314,7 +314,7 @@ def _stamp(layer: str, name: str, version: str | None, **extra: Any) -> dict[str "층": layer, "이름": name, "판": version, - "적용일": datetime.now().isoformat(timespec="seconds"), + "적용일": utc_now_text(), } entry.update({key: value for key, value in extra.items() if value is not None}) return entry diff --git a/M02_MasterTemplete/M02_Template_Migrate.py b/M02_MasterTemplete/M02_Template_Migrate.py index 294cdd088..e5cf4591f 100644 --- a/M02_MasterTemplete/M02_Template_Migrate.py +++ b/M02_MasterTemplete/M02_Template_Migrate.py @@ -16,10 +16,11 @@ import asyncio import copy import os import sys -from datetime import datetime from pathlib import Path, PurePosixPath from typing import Any +from common_util.common_util_time import kst_now + sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from config import config_system # noqa: E402 @@ -133,7 +134,7 @@ def render(report: list[dict[str, Any]], system: list[dict[str, Any]]) -> str: lines = [ "# 기존 프로젝트 양식 넣기", "", - f"- 돌린 때: {datetime.now().isoformat(timespec='seconds')}", + f"- 돌린 때: {kst_now().isoformat(timespec='seconds')}", f"- 시스템 양식: {names}", "- 방법: 작업본 · `_initial/` 에 없는 것만 더함 · 있는 양식 · 다른 파일은 그대로", f"- 프로젝트 {len(report)}개 · 넣음 {sum(r['상태'] == '넣음' for r in report)}개", diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def.ts index 07c978eb2..ac10f3f04 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Def.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def.ts @@ -41,6 +41,7 @@ import { to } from "./M02_SO_Text"; import { createText } from "@ui/ui_template_text"; import { createEmpty } from "@ui/ui_template_empty"; import { createTabs } from "@ui/ui_template_tabs"; +import { dateTimeText } from "../../common_util/common_util_time"; /** 옛 편집기(옵션 · 배치 · 그룹 · 규칙 · 기준 측점) — 모든 종류에서 숨김(52-12 · 정의 화면 = 이름만) */ const OLD_EDITOR = false; @@ -362,7 +363,7 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void { ...(versions.length ? versions.map((v) => { const b = createButton({ - label: to("PastItem", `${v.판} · ${v.저장일}`), + label: to("PastItem", `${v.판} · ${dateTimeText(v.저장일)}`), variant: past?.판 === v.판 ? "filled" : "ghost", size: "sm", }); diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Definition.py b/M02_MasterTemplete/StructOptions/M02_SO_Definition.py index 157e0eadf..925aa90d2 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Definition.py +++ b/M02_MasterTemplete/StructOptions/M02_SO_Definition.py @@ -9,7 +9,6 @@ from __future__ import annotations import json from collections.abc import Callable -from datetime import datetime from pathlib import Path from typing import Any @@ -17,6 +16,7 @@ from pydantic import ValidationError from B05_Profile import B05_Profile_Structures_Schema as schema from common_util.common_util_json import atomic_write_json +from common_util.common_util_time import utc_from_timestamp from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete.StructOptions.M02_SO_Check import catalog_errors @@ -43,7 +43,7 @@ def history() -> list[dict[str, Any]]: for path in folder.glob("판_*.json") if folder.is_dir() else []: number = path.stem.removeprefix("판_") if number.isdigit(): - when = datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d %H:%M") + when = utc_from_timestamp(path.stat().st_mtime) rows.append({"판": int(number), "저장일": when}) return sorted(rows, key=lambda r: r["판"], reverse=True) diff --git a/common_util/common_util_project_copy.py b/common_util/common_util_project_copy.py index 960bfc58f..a90949e35 100644 --- a/common_util/common_util_project_copy.py +++ b/common_util/common_util_project_copy.py @@ -22,7 +22,6 @@ import hashlib import json import subprocess import time -from datetime import datetime from pathlib import Path from typing import Any, Literal from uuid import uuid4 @@ -32,6 +31,7 @@ import aiomysql from B05_Profile.B05_Profile_Engine_Sections_Names import find_cross_file from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_surface_confirmation import get_surface_confirmation_params +from common_util.common_util_time import utc_now_text Scope = Literal["full", "stability"] KIND_LABELS = {"ROAD": "B", "PLAN": "D", "STABILITY": "E"} @@ -390,7 +390,7 @@ async def clone_full( "kind": kind, "source_project_id": source_id, "project_id": new_id, - "copied_at": datetime.now().isoformat(timespec="seconds"), + "copied_at": utc_now_text(), "tables": {name: len(items) for name, items in rows.items()}, "skipped_tables": skipped, "files": _tree_files(new_root), @@ -517,7 +517,7 @@ async def copy_stability(connection, source_id: str, new_id: str, project_root: "scope": "stability", "source_project_id": source_id, "project_id": new_id, - "copied_at": datetime.now().isoformat(timespec="seconds"), + "copied_at": utc_now_text(), "source_fingerprint": digest.hexdigest(), "route_id": dump["route"]["id"] if dump["route"] else None, "tables": { diff --git a/common_util/common_util_time.py b/common_util/common_util_time.py new file mode 100644 index 000000000..28651f9a2 --- /dev/null +++ b/common_util/common_util_time.py @@ -0,0 +1,52 @@ +"""서버 시각 글 — 저장·전송은 UTC(끝에 Z) · 서버가 만드는 파일 글(엑셀 · PDF · 메일 · 파일 이름)은 한국 시각 고정. + +화면 글은 `common_util_time.ts`(보는 PC 시간대) — 서버 PC 시간대에 기대지 않음(한국은 서머타임 없음 = 고정 +9). +""" + +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 + if value.tzinfo is not None: + value = value.astimezone(timezone.utc) + return value.replace(tzinfo=None, microsecond=0).isoformat() + "Z" + + +def utc_now_text() -> str: + """지금 — UTC 글(저장용).""" + return utc_text(datetime.now(timezone.utc)) or "" + + +def utc_from_timestamp(seconds: float) -> str: + """파일 수정 시각 등 epoch 초 → UTC 글.""" + return utc_text(datetime.fromtimestamp(seconds, timezone.utc)) or "" + + +def kst_now() -> datetime: + """지금 — 한국 시각(서버 PC 시간대와 무관).""" + return datetime.now(KST) + + +def kst_today() -> date: + return kst_now().date() + + +def kst_from_timestamp(seconds: float) -> datetime: + return datetime.fromtimestamp(seconds, KST) + + +def utc_now_naive() -> datetime: + """지금 — DB 칸(UTC, 시간대 표시 없음)과 견주는 값.""" + return datetime.now(timezone.utc).replace(tzinfo=None) + + +def install_json_utc() -> None: + """응답의 datetime(DB 값 = UTC) 을 끝에 Z 붙은 글로 — 화면이 보는 사람 시간대로 바꿈.""" + from fastapi.encoders import ENCODERS_BY_TYPE + + ENCODERS_BY_TYPE[datetime] = lambda value: utc_text(value) or "" diff --git a/common_util/common_util_time.ts b/common_util/common_util_time.ts new file mode 100644 index 000000000..225c8c8f4 --- /dev/null +++ b/common_util/common_util_time.ts @@ -0,0 +1,72 @@ +/* ============================================================================= + * common_util_time.ts + * 화면 시간 표시 — 저장·전송은 UTC 하나, 보일 때만 보는 사람 PC 시간대로 바꿈. + * 서버 글(「2026-10-30 01:02:03」 · 「…T01:02:03」 = 시간대 표시 없음)은 UTC 로 읽음. + * 짝: `common_util/common_util_time.py`(서버가 만드는 파일 글 = 한국 시각 고정). + * ========================================================================== */ + +const DAY_MS = 24 * 3600 * 1000; +const HAS_ZONE = /(Z|[+-]\d\d:?\d\d)$/; + +/** 서버 시각 글 → 밀리초(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`); + return Number.isNaN(ms) ? null : ms; +} + +const two = (n: number): string => String(n).padStart(2, "0"); + +function dateOf(d: Date): string { + return `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())}`; +} + +/** 날짜만 「YYYY-MM-DD」(보는 PC 시간대) · 빈 값 · 못 읽음 = empty */ +export function dateText(value: string | null | undefined, empty = "-"): string { + const ms = parseUtcMs(value); + if (ms !== null) return dateOf(new Date(ms)); + return /^\d{4}-\d\d-\d\d$/.test(String(value ?? "").trim()) ? String(value).trim() : empty; +} + +/** 시:분 「HH:MM」(보는 PC 시간대) · 빈 값 · 못 읽음 = 빈 글 */ +export function timeText(value: string | null | undefined): string { + const ms = parseUtcMs(value); + if (ms === null) return ""; + const d = new Date(ms); + return `${two(d.getHours())}:${two(d.getMinutes())}`; +} + +/** 날짜 + 시각 「YYYY-MM-DD HH:MM」(보는 PC 시간대) · 빈 값 · 못 읽음 = empty */ +export function dateTimeText(value: string | null | undefined, empty = "—"): string { + const ms = parseUtcMs(value); + if (ms === null) return empty; + const d = new Date(ms); + return `${dateOf(d)} ${two(d.getHours())}:${two(d.getMinutes())}`; +} + +/** 달력 날짜(보는 PC 시간대) 차이 — 오늘 0 · 어제 1 · 미래 음수 */ +export function calendarDaysAgo(value: string | null | undefined, now = Date.now()): number | null { + const ms = parseUtcMs(value); + if (ms === null) return null; + const day = (t: number): number => { + const d = new Date(t); + return Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()); + }; + return Math.round((day(now) - day(ms)) / DAY_MS); +} + +/** 「오늘」 · 「어제」 · 「n 일 전」(7 일 미만) · 그 밖 날짜 · 빈 값 = empty */ +export function agoText(value: string | null | undefined, empty = "-", now = Date.now()): string { + const days = calendarDaysAgo(value, now); + if (days === null) return empty; + if (days <= 0) return "오늘"; + if (days === 1) return "어제"; + if (days < 7) return `${days} 일 전`; + return dateText(value, empty); +} + +/** 옛 이름 — 확정 시각 등(`dateTimeText` 와 같음) */ +export const kstText = (utc: string | null | undefined): string => dateTimeText(utc); diff --git a/common_util/common_util_time_kst.ts b/common_util/common_util_time_kst.ts index 7615c24b8..61c67014a 100644 --- a/common_util/common_util_time_kst.ts +++ b/common_util/common_util_time_kst.ts @@ -1,15 +1,2 @@ -/* ============================================================================= - * common_util_time_kst.ts - * 서버 UTC 시각(DB 세션 time_zone +00:00) → 화면 KST(UTC+9) 글자 — 서버 값은 UTC 그대로. - * ========================================================================== */ - -const KST_OFFSET_MS = 9 * 3600 * 1000; - -/** "YYYY-MM-DD HH:MM:SS" · ISO(UTC) → "YYYY-MM-DD HH:MM" KST · 빈 값 = "—" · 못 읽으면 원글 */ -export function kstText(utc: string | null | undefined): string { - if (!utc) return "—"; - const iso = utc.trim().replace(" ", "T"); - const ms = Date.parse(/(Z|[+-]\d\d:?\d\d)$/.test(iso) ? iso : `${iso}Z`); - if (Number.isNaN(ms)) return utc; - return new Date(ms + KST_OFFSET_MS).toISOString().slice(0, 16).replace("T", " "); -} +/* 옛 이름 — `common_util_time.ts` 로 옮김(보는 PC 시간대). 호출부가 바뀌면 이 파일을 지움. */ +export { kstText } from "./common_util_time"; diff --git a/common_util/common_util_workflow_state.py b/common_util/common_util_workflow_state.py index aaf1657e1..5d1db5e37 100644 --- a/common_util/common_util_workflow_state.py +++ b/common_util/common_util_workflow_state.py @@ -6,6 +6,8 @@ from typing import Any, Dict import aiomysql +from common_util.common_util_time import utc_text + # `project_workflow_stages.message` 는 varchar(255) — 넘치면 UPDATE 자체가 실패한다. _STAGE_MESSAGE_MAX_LENGTH = 200 @@ -492,7 +494,7 @@ async def analysis_lock_owner( "user_id": user_id, "name": name, "email": email, - "started_at": started_at.isoformat() if started_at else None, + "started_at": utc_text(started_at), } diff --git a/main.py b/main.py index c61eef869..4c1ef4a4c 100644 --- a/main.py +++ b/main.py @@ -112,6 +112,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_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 @@ -298,6 +299,7 @@ app = FastAPI( debug=DEBUG, lifespan=lifespan, ) +install_json_utc() # 응답의 시각 = UTC 글 + Z(화면이 보는 사람 시간대로 바꿈) app.add_exception_handler(ProjectNotFoundError, project_not_found_response) # 지워진 프로젝트 = 404 # ───────────────────────────────────────────────────────────────────────── diff --git a/resources/tester/test_547_1_project_summary.py b/resources/tester/test_547_1_project_summary.py index 71d3a0907..76b7f1543 100644 --- a/resources/tester/test_547_1_project_summary.py +++ b/resources/tester/test_547_1_project_summary.py @@ -2,10 +2,11 @@ import asyncio import json -from datetime import datetime, timedelta +from datetime import timedelta import B01_Dashboard.B01_Dashboard_Router_Home as home import B01_Dashboard.B01_Dashboard_Summary as summary +from common_util.common_util_time import utc_now_naive ROUTE = "B05_Profile/route/r.geojson" @@ -65,7 +66,7 @@ class _Pool: def test_summaries_in_batch(monkeypatch, tmp_path): - now = datetime.now() + now = utc_now_naive() # DB 값 = UTC route_dir = tmp_path / "B05_Profile" / "route" route_dir.mkdir(parents=True) coords = [[0, 0, 1], [10, 0, 1], [10, 5, 1]] diff --git a/resources/tester/test_549_b01_sys_pages.py b/resources/tester/test_549_b01_sys_pages.py index 2729a46f1..6a6264ca4 100644 --- a/resources/tester/test_549_b01_sys_pages.py +++ b/resources/tester/test_549_b01_sys_pages.py @@ -87,6 +87,7 @@ def test_로그_거르기_글(): ] # fmt: skip out = _node( "type AuditLog = any;\n" + + "const dateText = (v: string | null | undefined, e: string) => (v ? v.slice(0, 10) : e);\n" + src + f"const l = {json.dumps(logs, ensure_ascii=False)};" + "const f = (o: object) => filterLogs(l, { action: '', from: '', to: '', q: '', ...o }).length;" diff --git a/resources/tester/test_593_time.py b/resources/tester/test_593_time.py new file mode 100644 index 000000000..127918300 --- /dev/null +++ b/resources/tester/test_593_time.py @@ -0,0 +1,99 @@ +"""593-1 시간 표시 — UTC 글 → 보는 PC 시간대(TS) · 서버가 만드는 글 = UTC Z / 한국 시각(Python).""" + +import json +import os +import re +import subprocess +from datetime import datetime +from pathlib import Path + +import pytest + +from common_util import common_util_time as srv + +ROOT = Path(__file__).resolve().parents[2] +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" +SRC = ROOT / "common_util" / "common_util_time.ts" + +_RUNNER = """ +import { writeFileSync } from "node:fs"; +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)), + 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)], + 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"), +}; +writeFileSync(process.argv[2], JSON.stringify(out)); +""" + + +@pytest.fixture(scope="module") +def runner(tmp_path_factory): + if not TSC.is_file(): + pytest.skip("프론트엔드 의존성 미설치") + tmp = tmp_path_factory.mktemp("time") + out = tmp / "js" + args = ["--outDir", str(out), "--module", "esnext", "--target", "es2022", "--ignoreConfig"] + subprocess.run( + ["node", str(TSC), str(SRC), *args], cwd=str(ROOT), check=True, capture_output=True + ) # noqa: S603, S607 + for emitted in out.glob("*.js"): + text = emitted.read_text(encoding="utf-8") + emitted.write_text(re.sub(r'(from\s+"\./[^"]+)"', r'\1.js"', text), encoding="utf-8") + (out / "runner.mjs").write_text(_RUNNER, encoding="utf-8") + + def run(tz: str) -> dict: + dst = tmp / f"out_{tz.replace('/', '_')}.json" + env = {**os.environ, "TZ": tz} + subprocess.run( + ["node", str(out / "runner.mjs"), str(dst)], check=True, capture_output=True, env=env + ) # noqa: S603, S607 + return json.loads(dst.read_text(encoding="utf-8")) + + return run + + +def test_seoul(runner): + r = runner("Asia/Seoul") + assert r["tz"] == "Asia/Seoul" + # 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["kst"] == "2026-10-30 10:02" + # 지금 = 한국 10-31 05:00 → 한국 달력 날짜로 센다 + assert r["ago"] == ["오늘", "어제", "2 일 전", "5 일 전", "2026-10-01", "-"] + + +def test_other_zone_follows_viewer(runner): + r = runner("America/New_York") # 10-30 = 서머타임(UTC-4) + assert r["date"][:4] == ["2026-10-29 21:02"] * 4 + assert r["day"][0] == "2026-10-30" + assert r["day"][1] == "2026-10-30" # 날짜만 있는 값은 안 바뀜 + + +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_now_text().endswith("Z") + assert srv.utc_from_timestamp(0) == "1970-01-01T00:00:00Z" + + +def test_server_kst(): + assert srv.kst_from_timestamp(0).strftime("%Y-%m-%d %H:%M") == "1970-01-01 09:00" + assert srv.kst_now().utcoffset().total_seconds() == 9 * 3600 + assert srv.kst_today() == srv.kst_now().date() + + +def test_응답_시각_끝_Z(): + from fastapi.encoders import jsonable_encoder + + 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} diff --git a/ui_template/ui_template_item_picker_detail.ts b/ui_template/ui_template_item_picker_detail.ts index bdea143d2..d673624fb 100644 --- a/ui_template/ui_template_item_picker_detail.ts +++ b/ui_template/ui_template_item_picker_detail.ts @@ -15,6 +15,7 @@ import { createButton, el, showConfirmDialog, showToast } from "./ui_template_elements"; import { readTemplate } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; +import { dateText } from "../common_util/common_util_time"; import { calcLabor, type LaborDoc } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api"; import type { EditKind, ItemCardsHandle } from "../M02_MasterTemplete/M02_MasterTemplete_ItemCards"; import type { ItemSource } from "../M02_MasterTemplete/M02_MasterTemplete_ItemSource"; @@ -117,7 +118,7 @@ const TEXT = { const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); const fill = (text: string, value: string): string => text.replace("{value}", value); -const dateOf = (iso?: string | null): string => (iso ? iso.slice(0, 10) : ""); +const dateOf = (iso?: string | null): string => dateText(iso, ""); export function createItemDetail(opts: ItemDetailOptions): ItemDetail { const root = el("div", { className: "ui-item-picker__detail" });