feat(시간 표시): 화면 시각 = 보는 사람 PC 시간대 · 서버 응답 UTC Z · 서버가 만드는 파일 글 = 한국 시각 (PLAN 593-1 · 요구 456)

- 공용 `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 <noreply@anthropic.com>
This commit is contained in:
2026-10-30 10:03:35 +09:00
co-authored by Claude Sonnet 5.5
parent 1c0c44f9e2
commit 59610fecf1
62 changed files with 364 additions and 140 deletions
+5 -4
View File
@@ -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
@@ -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:
+3 -3
View File
@@ -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,
+4 -10
View File
@@ -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<unknown>): Promise<void>
}
}
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);
/**
* 위·아래 두 줄짜리 표 칸. 아랫줄은 작은 글씨라 **행 높이는 한 줄일 때와 같다**.
+3 -2
View File
@@ -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 {
@@ -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 {
@@ -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);
}
@@ -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] }),
],
});
+2 -1
View File
@@ -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);
+2 -7
View File
@@ -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) },
{
@@ -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;
@@ -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));
}
@@ -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],
},
)
@@ -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:
@@ -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)
@@ -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:
+4 -2
View File
@@ -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
@@ -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,
},
+3 -1
View File
@@ -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])
+3 -1
View File
@@ -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]),
}
+2 -2
View File
@@ -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"),
},
)
+2 -2
View File
@@ -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),
@@ -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)
]
+2 -1
View File
@@ -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}` : ""}`;
}
@@ -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 => {
@@ -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(),
@@ -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)
+2 -2
View File
@@ -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"],
@@ -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
@@ -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
+2 -1
View File
@@ -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"));
+2 -2
View File
@@ -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),
+2 -2
View File
@@ -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())
# ── 프로그램 몫 ──────────────────────────────────────────────────────────────
+3 -3
View File
@@ -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["시트"]}
# 새 판에 없는 프로그램 몫(보강검토 — 저장본 없음)은 뺌
+2 -1
View File
@@ -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;
+2 -2
View File
@@ -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,
+2 -4
View File
@@ -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");
+2 -2
View File
@@ -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")
+2 -2
View File
@@ -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")
+2 -2
View File
@@ -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]] = {}
+2 -2
View File
@@ -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():
+2 -2
View File
@@ -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,
}
+3 -3
View File
@@ -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)
@@ -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)
@@ -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.열쇠 },
@@ -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;
/** 가장 늦게 고친 날 — 항목 통째 */
@@ -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():
@@ -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)] # 화면이 지우기 확인 창 전에 막음
+3 -3
View File
@@ -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
+3 -2
View File
@@ -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)}개",
@@ -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",
});
@@ -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)
+3 -3
View File
@@ -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": {
+52
View File
@@ -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 ""
+72
View File
@@ -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);
+2 -15
View File
@@ -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";
+3 -1
View File
@@ -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),
}
+2
View File
@@ -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
# ─────────────────────────────────────────────────────────────────────────
@@ -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]]
@@ -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;"
+99
View File
@@ -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}
@@ -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" });