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) — 프로젝트 카드 요약 · 역할별 홈.""" """B01 대시보드 홈 API (PLAN 547) — 프로젝트 카드 요약 · 역할별 홈."""
from datetime import datetime, timedelta from datetime import timedelta
from typing import Any from typing import Any
import aiomysql import aiomysql
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from common_util.common_util_auth import is_forest_email, verify_session 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 config.config_db import get_db_pool
from .B01_Dashboard_Repository import get_system_resources 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"]),), (int(session["user_id"]),),
) )
batches = list(await cursor.fetchall()) 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"] = [ todos["expiring_temp"] = [
{ {
"batch_id": str(b["id"]), "batch_id": str(b["id"]),
"name": b["name"], "name": b["name"],
"expires_at": b["expires_at"].isoformat(), "expires_at": utc_text(b["expires_at"]),
"days_left": max(0, (b["expires_at"] - datetime.now()).days), "days_left": max(0, (b["expires_at"] - utc_now_naive()).days),
} }
for b in batches for b in batches
if b["expires_at"] and b["expires_at"] <= soon if b["expires_at"] and b["expires_at"] <= soon
@@ -14,6 +14,7 @@ import aiomysql
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from common_util.common_util_auth import verify_session 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 config.config_db import get_db_pool
from .B01_Dashboard_Repository import _project_rows 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: if updated_at is None:
return 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: def is_mine(row: dict[str, Any], user_id: int) -> bool:
+3 -3
View File
@@ -9,12 +9,12 @@
from __future__ import annotations from __future__ import annotations
import json import json
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import aiomysql import aiomysql
from common_util.common_util_time import utc_now_naive, utc_text
from config.config_db import get_db_pool from config.config_db import get_db_pool
from M02_MasterTemplete.M02_Template_Migrate import project_root 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()} stations = {int(r["route_id"]): int(r["n"]) for r in await cursor.fetchall()}
now = datetime.now() now = utc_now_naive()
result = [] result = []
for pid in ids: for pid in ids:
row = rows.get(pid) 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_total": len(names),
"stage_name": names[index], "stage_name": names[index],
"done": bool(stages) and all(s["state"] == "COMPLETE" for s in stages), "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, "stale_days": (now - updated).days if updated else None,
"owner_user_id": row.get("user_id"), "owner_user_id": row.get("user_id"),
"owner_names": owners, "owner_names": owners,
+4 -10
View File
@@ -7,6 +7,7 @@ import {
showToast, showToast,
} from "@ui/ui_template_elements"; } from "@ui/ui_template_elements";
import type { DashboardUser } from "./B01_Dashboard_Api_Fetch"; 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 { /** 날짜 · 시:분 — 서버 UTC 를 보는 PC 시간대로(`common_util_time`). 시:분은 표에서 날짜 아래 줄에 붙인다. */
return value ? value.slice(0, 10) : "-"; export const formatDate = (value?: string | null): string => dateText(value);
} export const formatTime = (value?: string | null): string => timeText(value);
/** 시:분 — 표에서 날짜 아래 줄에 붙인다 (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) : "";
}
/** /**
* 위·아래 두 줄짜리 표 칸. 아랫줄은 작은 글씨라 **행 높이는 한 줄일 때와 같다**. * 위·아래 두 줄짜리 표 칸. 아랫줄은 작은 글씨라 **행 높이는 한 줄일 때와 같다**.
+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 { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav";
import { groupFlowOf } from "../A00_Common/group_workflow"; import { groupFlowOf } from "../A00_Common/group_workflow";
import type { ProjectSummary } from "./B01_Dashboard_Api_Home"; import type { ProjectSummary } from "./B01_Dashboard_Api_Home";
import { calendarDaysAgo } from "../common_util/common_util_time";
import { L } from "./B01_Dashboard_UI_Common"; import { L } from "./B01_Dashboard_UI_Common";
import { isStale } from "./logic/B01_Dashboard_Home_Layout"; import { isStale } from "./logic/B01_Dashboard_Home_Layout";
@@ -59,10 +60,10 @@ export const stageText = (card: ProjectSummary): string =>
/** 「n 일째 그대로」 · 오늘 · n 일 전 */ /** 「n 일째 그대로」 · 오늘 · n 일 전 */
export function whenText(card: ProjectSummary): string { 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 (days === null) return "";
if (isStale(card)) return `${days} 일째 그대로`; if (isStale(card)) return `${days} 일째 그대로`;
return days === 0 ? "오늘" : `${days} 일 전`; return days <= 0 ? "오늘" : `${days} 일 전`;
} }
export function continueStage(card: ProjectSummary): void { export function continueStage(card: ProjectSummary): void {
@@ -28,6 +28,7 @@ import {
stageProgress, stageProgress,
} from "./B01_Dashboard_UI_ProjectList_Parts"; } from "./B01_Dashboard_UI_ProjectList_Parts";
import { openMenuAt, rowMenuItems } from "./B01_Dashboard_UI_ProjectList_Views"; import { openMenuAt, rowMenuItems } from "./B01_Dashboard_UI_ProjectList_Views";
import { dateText, dateTimeText } from "../common_util/common_util_time";
/** 인쇄 · 내려받기 화면(B09 초안 길) — 도면 PDF · 수량 · 내역 xlsx 를 받는 곳 */ /** 인쇄 · 내려받기 화면(B09 초안 길) — 도면 PDF · 수량 · 내역 xlsx 를 받는 곳 */
const PRINT_ROUTE = "b09-print" as RoutePath; const PRINT_ROUTE = "b09-print" as RoutePath;
@@ -117,7 +118,7 @@ function infoPane(detail: ProjectDetail): HTMLElement {
["시행청", p.client_org], ["시행청", p.client_org],
["사업 번호", p.project_number], ["사업 번호", p.project_number],
["담당", people], ["담당", people],
["만든 날", (p.created_at ?? "").slice(0, 10)], ["만든 날", dateText(p.created_at, "")],
]; ];
const kv = el("div", { className: "b01-pd-kv" }); const kv = el("div", { className: "b01-pd-kv" });
for (const [label, value] of rows) { for (const [label, value] of rows) {
@@ -178,7 +179,7 @@ function changeText(change: ProjectChange, detail: ProjectDetail): string {
: change.action === "PROJECT_CREATE" : 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 { 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 { groupFlowOf } from "../A00_Common/group_workflow";
import type { RoutePath } from "@config/config_frontend"; import type { RoutePath } from "@config/config_frontend";
import type { BoardColumn, OverviewProject } from "./B01_Dashboard_Api_Projects"; import type { BoardColumn, OverviewProject } from "./B01_Dashboard_Api_Projects";
import { agoText } from "../common_util/common_util_time";
import { L } from "./B01_Dashboard_UI_Common"; import { L } from "./B01_Dashboard_UI_Common";
/** 종류 — 칩 · 배지 차례 · 사방설계 = 준비 중 */ /** 종류 — 칩 · 배지 차례 · 사방설계 = 준비 중 */
@@ -121,10 +122,5 @@ export function thumbSvg(points: number[][] | null | undefined): SVGElement {
/** 「오늘」 · 「어제」 · 「n 일 전」 · 그 밖 날짜 */ /** 「오늘」 · 「어제」 · 「n 일 전」 · 그 밖 날짜 */
export function ago(project: OverviewProject): string { export function ago(project: OverviewProject): string {
const days = project.idle_days; return agoText(project.updated_at);
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);
} }
@@ -11,6 +11,7 @@ import {
import { el } from "@ui/ui_template_elements_base"; import { el } from "@ui/ui_template_elements_base";
import type { DashboardUser } from "./B01_Dashboard_Api_Fetch"; import type { DashboardUser } from "./B01_Dashboard_Api_Fetch";
import type { OverviewProject } from "./B01_Dashboard_Api_Projects"; 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 { canDeleteProject, canEditProject } from "./B01_Dashboard_UI_Helper";
import { openDeleteProjectModal, openEditProjectModal } from "./B01_Dashboard_UI_Modals"; import { openDeleteProjectModal, openEditProjectModal } from "./B01_Dashboard_UI_Modals";
import { import {
@@ -148,7 +149,7 @@ export function projectRows(
}), }),
el("td", { text: boardLabel(project.board) }), el("td", { text: boardLabel(project.board) }),
el("td", { text: project.manager_name ?? "-" }), 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] }), 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 { currentLanguageIndex, setLanguage } from "@ui/ui_template_locale";
import { readTouchMode, setTouchMode, type TouchMode } from "../A00_Common/device_context"; import { readTouchMode, setTouchMode, type TouchMode } from "../A00_Common/device_context";
import { currentQuery, navigateTo, type MountedPage } from "../A00_Common/router"; import { currentQuery, navigateTo, type MountedPage } from "../A00_Common/router";
import { parseUtcMs } from "../common_util/common_util_time";
import { import {
fetchAllCompanies, fetchAllCompanies,
fetchCompanyJoinRequests, 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 newPlans = plans.filter((p) => !p.linked_project_id || p.update_available).length;
const now = Date.now(); const now = Date.now();
const expiring = temp.filter( 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; ).length;
nav.setBadge("people", joins.length, true); nav.setBadge("people", joins.length, true);
nav.setBadge("assigned", newPlans, true); nav.setBadge("assigned", newPlans, true);
+2 -7
View File
@@ -29,6 +29,7 @@ import {
uploadTempChunk, uploadTempChunk,
type TempBatchItem, type TempBatchItem,
} from "./B01_Dashboard_Api_Temp"; } from "./B01_Dashboard_Api_Temp";
import { dateText } from "../common_util/common_util_time";
import { L } from "./B01_Dashboard_UI_Common"; import { L } from "./B01_Dashboard_UI_Common";
import { import {
badge, badge,
@@ -73,12 +74,6 @@ function taskKey(file: File): string {
return `${file.name}::${file.size}`; 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[] = [ const COLUMNS: Column[] = [
{ label: "보관 이름" }, { label: "보관 이름" },
{ label: "상태", width: "120px" }, { label: "상태", width: "120px" },
@@ -263,7 +258,7 @@ export function buildTempBox(onGuide: () => void): TempBox {
nameWithCaret(batch, opened), nameWithCaret(batch, opened),
status, status,
leftCell(batch), leftCell(batch),
formatDate(batch.created_at), dateText(batch.created_at),
rowMenu([ rowMenu([
{ label: L("B01_Temp_Btn_Add"), onClick: () => openAddModal(batch) }, { label: L("B01_Temp_Btn_Add"), onClick: () => openAddModal(batch) },
{ {
@@ -6,6 +6,7 @@
import { fetchAuditLogPage, type AuditLog } from "./B01_Dashboard_Api_Fetch"; import { fetchAuditLogPage, type AuditLog } from "./B01_Dashboard_Api_Fetch";
import { formatDate, formatTime } from "./B01_Dashboard_UI_Common"; import { formatDate, formatTime } from "./B01_Dashboard_UI_Common";
import { dateText } from "../common_util/common_util_time";
import { createButton } from "@ui/ui_template_elements"; import { createButton } from "@ui/ui_template_elements";
import { dataTable, emptyState, pageFrame } from "./B01_Dashboard_UI_PageKit"; import { dataTable, emptyState, pageFrame } from "./B01_Dashboard_UI_PageKit";
import { el as h, loadPage, searchBox, selectBox, toolbar } from "./B01_Dashboard_UI_View_Parts"; import { el as h, loadPage, searchBox, selectBox, toolbar } from "./B01_Dashboard_UI_View_Parts";
@@ -54,7 +55,7 @@ export interface LogFilter {
/** 동작 · 기간(날짜 YYYY-MM-DD 양끝 포함) · 찾기(이메일 · 대상 · 접속 주소) */ /** 동작 · 기간(날짜 YYYY-MM-DD 양끝 포함) · 찾기(이메일 · 대상 · 접속 주소) */
export function filterLogs(logs: AuditLog[], f: LogFilter): AuditLog[] { export function filterLogs(logs: AuditLog[], f: LogFilter): AuditLog[] {
return logs.filter((l) => { 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.action && l.action !== f.action) return false;
if (f.from && day < f.from) return false; if (f.from && day < f.from) return false;
if (f.to && day > f.to) return false; if (f.to && day > f.to) return false;
@@ -43,7 +43,9 @@ export function daysLeft(
now: number = Date.now(), now: number = Date.now(),
): number | null { ): number | null {
if (!expiresAt) return 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; if (Number.isNaN(at)) return null;
return Math.max(0, Math.ceil((at - now) / DAY_MS)); 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_audit import record_audit
from common_util.common_util_json import atomic_write_json from common_util.common_util_json import atomic_write_json
from common_util.common_util_storage import PROJECT_STORAGE_LAYOUT_V2 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 import load_project_workflow
from common_util.common_util_workflow_state import initialize_project_stages from common_util.common_util_workflow_state import initialize_project_stages
from config.config_db import get_db_pool 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, "project_id": project_id,
"storage_version": 2, "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], "stages": [f"{stage}/{subdir}" for stage, subdir in PROJECT_STORAGE_LAYOUT_V2],
}, },
) )
@@ -3,6 +3,7 @@
라우터가 700줄을 넘어 떼어냈다(2026-09-04). 본체와 청크 모듈이 함께 쓰는 것만 둔다. 라우터가 700줄을 넘어 떼어냈다(2026-09-04). 본체와 청크 모듈이 함께 쓰는 것만 둔다.
""" """
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -16,6 +17,7 @@ from B03_FileInput.B03_FileInput_Repository_Temp import (
from common_util.common_util_storage import ( from common_util.common_util_storage import (
resolve_temp_batch_path, resolve_temp_batch_path,
) )
from common_util.common_util_time import utc_text
_POINT_CLOUD_FILE_TYPES = frozenset({"las", "laz"}) _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: 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: async def _batch_root(connection: Any, *, batch_id: str, user_id: int) -> Path:
@@ -15,7 +15,6 @@
from __future__ import annotations from __future__ import annotations
import base64 import base64
import datetime
import http.cookiejar import http.cookiejar
import json import json
import logging import logging
@@ -27,6 +26,7 @@ import urllib.request
import zipfile import zipfile
from pathlib import Path from pathlib import Path
from common_util.common_util_time import kst_today
from config.config_system import ( from config.config_system import (
MAP_SHEETS_DIRNAME, MAP_SHEETS_DIRNAME,
MAP_SHEETS_INDEX_FILENAME, 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, "original_name": src.name,
"crs": crs, "crs": crs,
"bounds_wgs84": [round(v, 8) for v in bounds], "bounds_wgs84": [round(v, 8) for v in bounds],
"acquired": datetime.date.today().isoformat(), "acquired": kst_today().isoformat(),
"source": "수동 다운로드 인제스트", "source": "수동 다운로드 인제스트",
} }
_save_index(store, index) _save_index(store, index)
@@ -15,7 +15,6 @@ from __future__ import annotations
import json import json
import logging import logging
from collections.abc import Sequence from collections.abc import Sequence
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any, Callable from typing import Any, Callable
@@ -23,6 +22,7 @@ import numpy as np
from shapely.geometry.base import BaseGeometry from shapely.geometry.base import BaseGeometry
from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_time import utc_now_text
from config.config_system import DRAINAGE_CACHE_DIRNAME from config.config_system import DRAINAGE_CACHE_DIRNAME
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -181,7 +181,7 @@ def _update_manifest(
logger.warning("배수유역: manifest를 읽지 못해 새로 만듭니다 (%s).", manifest_path) logger.warning("배수유역: manifest를 읽지 못해 새로 만듭니다 (%s).", manifest_path)
manifest[stage] = { manifest[stage] = {
"file": filename, "file": filename,
"saved_at": datetime.now().isoformat(timespec="seconds"), "saved_at": utc_now_text(),
"properties": properties, "properties": properties,
} }
try: try:
+4 -2
View File
@@ -12,6 +12,8 @@ from uuid import UUID
import aiomysql import aiomysql
from common_util.common_util_time import utc_text
_STAGE_ROOT = "B04_PreProcess" _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, "file_size_mb": float(row[4]) if row[4] is not None else None,
"crs_epsg": row[5], "crs_epsg": row[5],
"status": row[6], "status": row[6],
"created_at": row[7].isoformat() if row[7] else None, "created_at": utc_text(row[7]),
} }
for row in rows for row in rows
] ]
@@ -279,7 +281,7 @@ async def list_surface_models(
"resolution_m": row[3], "resolution_m": row[3],
"model_file_path": row[4], "model_file_path": row[4],
"generation_params": generation_params, "generation_params": generation_params,
"created_at": row[6].isoformat() if row[6] else None, "created_at": utc_text(row[6]),
} }
) )
return models return models
@@ -25,7 +25,6 @@ import hashlib
import json import json
import time import time
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any, AsyncIterator, Callable from typing import Any, AsyncIterator, Callable
@@ -37,6 +36,7 @@ from B05_Profile.B05_Profile_Engine_RouteInitial import (
resolve_weights, resolve_weights,
) )
from common_util.common_util_json import atomic_write_json from common_util.common_util_json import atomic_write_json
from common_util.common_util_time import utc_now_text
# 3 = 곡선 MC 일반측점(351-2) · 4 = 다투는 곡선 끔(364-5) · 5 = 후보마다 맞춤(383-1) · # 3 = 곡선 MC 일반측점(351-2) · 4 = 다투는 곡선 끔(364-5) · 5 = 후보마다 맞춤(383-1) ·
# 6 = 같은 쪽 곡선 합치기 · 거리 한도 대신 직접 잼(406) · 7 = 측점 밖이 늘면 옛 차례 맞춤(406-4) · # 6 = 같은 쪽 곡선 합치기 · 거리 한도 대신 직접 잼(406) · 7 = 측점 밖이 늘면 옛 차례 맞춤(406-4) ·
@@ -202,7 +202,7 @@ def write_stored(
"key": key, "key": key,
"pool": pool, "pool": pool,
"default": default, "default": default,
"created_at": datetime.now().isoformat(timespec="seconds"), "created_at": utc_now_text(),
"request": request, "request": request,
"result": result, "result": result,
}, },
+3 -1
View File
@@ -12,6 +12,8 @@ from uuid import UUID
import aiomysql import aiomysql
from common_util.common_util_time import utc_text
_STAGE_ROOT = "B05_Profile" _STAGE_ROOT = "B05_Profile"
@@ -172,7 +174,7 @@ async def get_latest_route(
return None return None
result = dict(row) result = dict(row)
result["id"] = int(result["id"]) 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"): for key in ("constraints", "algorithm_params"):
if isinstance(result.get(key), str): if isinstance(result.get(key), str):
result[key] = json.loads(result[key]) result[key] = json.loads(result[key])
+3 -1
View File
@@ -13,6 +13,8 @@ from uuid import UUID
import aiomysql import aiomysql
from common_util.common_util_time import utc_text
_STAGE_ROOT = "B06_Section" _STAGE_ROOT = "B06_Section"
@@ -335,7 +337,7 @@ async def get_longitudinal_section(
"data": data if isinstance(data, dict) else None, "data": data if isinstance(data, dict) else None,
"longitudinal_file_path": row[2], "longitudinal_file_path": row[2],
"status": row[3], "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 __future__ import annotations
from datetime import date
from typing import Any from typing import Any
from B09_Estimation.B09_Estimation_Calc_Base import num, rounddown, roundup, total from B09_Estimation.B09_Estimation_Calc_Base import num, rounddown, roundup, total
from common_util.common_util_time import kst_today
#: 제비율 줄 (키, 이름, M01 요율 후보 키, 산식 글) — 엑셀 총괄 49~58 차례 · 나.소계에 듦 #: 제비율 줄 (키, 이름, M01 요율 후보 키, 산식 글) — 엑셀 총괄 49~58 차례 · 나.소계에 듦
RATE_LINES: tuple[tuple[str, str, str, str], ...] = ( RATE_LINES: tuple[tuple[str, str, str, str], ...] = (
@@ -155,7 +155,7 @@ def compute_total(
"target": target, "target": target,
"price": num(est.get("estimated_price")), "price": num(est.get("estimated_price")),
"days": num(est.get("duration_days")), "days": num(est.get("duration_days")),
"year": year or date.today().year, "year": year or kst_today().year,
"env": est.get("environment_kind"), "env": est.get("environment_kind"),
}, },
) )
+2 -2
View File
@@ -16,11 +16,11 @@
from __future__ import annotations from __future__ import annotations
import json import json
from datetime import datetime
from decimal import Decimal from decimal import Decimal
from typing import Any from typing import Any
from B08_Quantity.B08_Quantity_Material_Calc import DEDUCT_KEY 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 from M01_MasterData import M01_MasterData_Store as m01
#: 운전사 시간 노임 계수 표(건설품셈 공통 8-1-3 · 건협기술-2230) #: 운전사 시간 노임 계수 표(건설품셈 공통 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 {} old = (book or {}).get("rows") or {}
rows.update({key: old[key] for key in want["missing"] if key in old}) rows.update({key: old[key] for key in want["missing"] if key in old})
return { return {
"made": datetime.now().isoformat(timespec="seconds"), "made": utc_now_text(),
"rows": rows, "rows": rows,
"driver_factor": driver_factor(master), "driver_factor": driver_factor(master),
"fx": fx_inputs(master), "fx": fx_inputs(master),
@@ -12,7 +12,6 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from uuid import UUID from uuid import UUID
@@ -23,6 +22,7 @@ from fastapi.responses import FileResponse
from B03_FileInput.B03_FileInput_Engine import save_upload_stream from B03_FileInput.B03_FileInput_Engine import save_upload_stream
from B08_Quantity.B08_Quantity_Router_Hydro import _project_root from B08_Quantity.B08_Quantity_Router_Hydro import _project_root
from B09_Estimation import B09_Estimation_Store as store from B09_Estimation import B09_Estimation_Store as store
from common_util.common_util_time import utc_from_timestamp
router = APIRouter() router = APIRouter()
PATH = "/{project_id}/estimation/quotes" PATH = "/{project_id}/estimation/quotes"
@@ -33,7 +33,7 @@ def _listing(folder: Path) -> list[dict[str, Any]]:
{ {
"name": f.name, "name": f.name,
"size": f.stat().st_size, "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) 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 { createBadge } from "@ui/ui_template_badge";
import { createText } from "@ui/ui_template_text"; import { createText } from "@ui/ui_template_text";
import type { Row } from "./B09_Estimation_Calc_Base"; 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"; import { tabName, type Link, type TabId } from "./B09_Estimation_UI_Tabs";
export interface CheckItem { export interface CheckItem {
@@ -38,7 +39,7 @@ export function checkItems(sheets: Row, payload: Row): { items: CheckItem[]; not
/** [업데이트] 호버 글 — 굳힌 시각 + 낡은 까닭 */ /** [업데이트] 호버 글 — 굳힌 시각 + 낡은 까닭 */
export function refreshTip(payload: Row | null): string { export function refreshTip(payload: Row | null): string {
const made = payload?.book?.made; const made = payload?.book?.made;
const when = made ? `단가 굳힘 ${String(made).replace("T", " ")}` : "단가 아직 안 굳힘"; const when = made ? `단가 굳힘 ${dateTimeText(String(made))}` : "단가 아직 안 굳힘";
const stale = payload?.stale; const stale = payload?.stale;
return `${when} · 누르면 지금 M01 값으로 새로 굳힘${stale ? ` · 업데이트 필요 — ${stale}` : ""}`; return `${when} · 누르면 지금 M01 값으로 새로 굳힘${stale ? ` · 업데이트 필요 — ${stale}` : ""}`;
} }
@@ -18,6 +18,7 @@
import type { MapContextMenuItem } from "@ui/ui_template_context_menu"; import type { MapContextMenuItem } from "@ui/ui_template_context_menu";
import type { DataRow } from "@ui/ui_template_data_table"; import type { DataRow } from "@ui/ui_template_data_table";
import { SOURCES, type Row } from "./B09_Estimation_Calc_Base"; 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 { openQuotes } from "./B09_Estimation_UI_Tabs_Base_Quote";
import { import {
blockLevel, blockLevel,
@@ -64,7 +65,7 @@ export const buildExpense: TabBuild = ({ sheets, payload }) =>
); );
function madeText(payload: Row): string { 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 => { 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_Calc_Base import num
from B09_Estimation.B09_Estimation_Summary import expand, hopyo_lines from B09_Estimation.B09_Estimation_Summary import expand, hopyo_lines
from B09_Estimation.draft.B09_Draft_Schedule_Calc import HOLIDAY_KINDS, compute_schedule 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") HOLIDAYS = Path(__file__).with_name("B09_Draft_Schedule_Holidays.json")
CLIMATE = { 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]: 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 { return {
"units": unit_resources(sheets), "units": unit_resources(sheets),
"holidays": holiday_table(), "holidays": holiday_table(),
@@ -19,7 +19,6 @@ import math
import os import os
import tempfile import tempfile
import zipfile import zipfile
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -30,6 +29,7 @@ from common_util.common_util_derived_store import (
write_derived, write_derived,
) )
from common_util.common_util_json import atomic_write_json from common_util.common_util_json import atomic_write_json
from common_util.common_util_time import utc_now_text
Box = tuple[float, float, float, float] Box = tuple[float, float, float, float]
@@ -192,7 +192,7 @@ def save_roads(
{ {
"files": [Path(name).name for name, _ in files], "files": [Path(name).name for name, _ in files],
"bytes": len(data), "bytes": len(data),
"uploaded_at": datetime.now().isoformat(timespec="seconds"), "uploaded_at": utc_now_text(),
}, },
) )
return road_layer(project_root, box, epsg) return road_layer(project_root, box, epsg)
+2 -2
View File
@@ -26,7 +26,6 @@ import os
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
import time
from functools import lru_cache from functools import lru_cache
from pathlib import Path from pathlib import Path
from typing import Any 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_json import atomic_write_json
from common_util.common_util_station_key import chainage_key from common_util.common_util_station_key import chainage_key
from common_util.common_util_surface_sampler import DtmGridSampler 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 E01_StabRegister import E01_StabRegister_Source as src
from E02_StabGround import E02_StabGround_Model as ground_model from E02_StabGround import E02_StabGround_Model as ground_model
from E02_StabGround import E02_StabGround_Router as e02 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"])) rows.append(view.station_row(value["meta"], value["out"], plan["factors"], value["error"]))
summary = { summary = {
"fingerprint": plan["fp"], "fingerprint": plan["fp"],
"computed_at": time.strftime("%Y-%m-%dT%H:%M:%S"), "computed_at": utc_now_text(),
"conditions": shared["conditions"], "conditions": shared["conditions"],
"method": view.PRIMARY, "method": view.PRIMARY,
"factors": plan["factors"], "factors": plan["factors"],
@@ -22,7 +22,6 @@ import os
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
import time
from functools import lru_cache from functools import lru_cache
from pathlib import Path from pathlib import Path
from typing import Any, Literal from typing import Any, Literal
@@ -37,6 +36,7 @@ from common_util.common_util_derived_store import (
read_derived, read_derived,
write_derived, write_derived,
) )
from common_util.common_util_time import utc_now_text
from E02_StabGround import E02_StabGround_Router as e02 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_Engine_Prob as prob
from E03_StabSection import E03_StabSection_Router as e03 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"], "surface_kind": slot["surface"]["kind"],
"request": req.model_dump(), "request": req.model_dump(),
"result": result, "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) write_derived(root, KIND, key, record, keep=KEEP)
return record return record
@@ -19,9 +19,9 @@ import hashlib
import inspect import inspect
import math import math
import time import time
from functools import lru_cache, partial
from pathlib import Path from pathlib import Path
from typing import Any, Literal from typing import Any, Literal
from functools import lru_cache, partial
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
@@ -34,13 +34,14 @@ from common_util.common_util_derived_store import (
read_derived, read_derived,
write_derived, write_derived,
) )
from common_util.common_util_time import utc_now_text
from E01_StabRegister import E01_StabRegister_Source as src 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_Model as ground_model
from E02_StabGround import E02_StabGround_Router as e02 from E02_StabGround import E02_StabGround_Router as e02
from E03_StabSection import E03_StabSection_Router as e03 from E03_StabSection import E03_StabSection_Router as e03
from E03_StabSection import E03_StabSection_View as view 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_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 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_Model import WALL_MATERIAL, section_model_from_b06
from E03_StabSection.E03_StabSection_Engine_Station import analyze_station 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, "support_forces": _forces(out, req.side) if supports else None,
"supports_pending": bool(supports) and not engine, "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), "elapsed_s": round(time.perf_counter() - t0, 2),
} }
shown = _section(ctx, variant, after, out) 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"], "base_fp": row["fp"],
"params": params, "params": params,
"walls": walls, "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) write_derived(root, KIND, key, record, keep=KEEP)
return record return record
+2 -1
View File
@@ -24,6 +24,7 @@ import {
import { t, type LocaleKey } from "@ui/ui_template_locale"; import { t, type LocaleKey } from "@ui/ui_template_locale";
import { createSegment } from "@ui/ui_template_segment"; import { createSegment } from "@ui/ui_template_segment";
import { currentProjectId } from "../A00_Common/b_project_switch"; import { currentProjectId } from "../A00_Common/b_project_switch";
import { dateTimeText } from "../common_util/common_util_time";
import { mountGroupStep } from "../A00_Common/group_workflow"; import { mountGroupStep } from "../A00_Common/group_workflow";
import { buildMissingBanner } from "../E01_StabRegister/E01_StabRegister_UI_Missing"; import { buildMissingBanner } from "../E01_StabRegister/E01_StabRegister_UI_Missing";
import { buildList } from "./E03_StabSection_UI_List"; import { buildList } from "./E03_StabSection_UI_List";
@@ -279,7 +280,7 @@ export function renderE03StabSection(root: HTMLElement): void {
if (!result) lines.push(t("E03_Loading")); if (!result) lines.push(t("E03_Loading"));
else if (result.status === "none") lines.push(t("E03_NotYet")); else if (result.status === "none") lines.push(t("E03_NotYet"));
else { 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.fresh) lines.push(t("E03_Stale"));
} }
if (result && !result.editable) lines.push(t("E03_ReadOnly")); if (result && !result.editable) lines.push(t("E03_ReadOnly"));
+2 -2
View File
@@ -22,7 +22,6 @@ import json
import math import math
import time import time
from collections.abc import Callable from collections.abc import Callable
from datetime import datetime
from functools import lru_cache from functools import lru_cache
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -30,6 +29,7 @@ from typing import Any
import numpy as np import numpy as np
from scipy.spatial import cKDTree 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 E01_StabRegister import E01_StabRegister_Source as source
from E02_StabGround import E02_StabGround_Model as ground_model from E02_StabGround import E02_StabGround_Model as ground_model
from E03_StabSection.E03_StabSection_Types import DEFAULT_ROAD_SURCHARGE_KPA from E03_StabSection.E03_StabSection_Types import DEFAULT_ROAD_SURCHARGE_KPA
@@ -335,7 +335,7 @@ def pack(
"fingerprint": finger, "fingerprint": finger,
"engine": engine_hash(), "engine": engine_hash(),
"settings": settings, "settings": settings,
"computed_at": datetime.now().isoformat(timespec="seconds"), "computed_at": utc_now_text(),
"elapsed_s": round(elapsed_s, 2), "elapsed_s": round(elapsed_s, 2),
"grid": grid, "grid": grid,
"z": _flat(np.asarray(raw["z"], dtype=float), 2), "z": _flat(np.asarray(raw["z"], dtype=float), 2),
+2 -2
View File
@@ -25,11 +25,11 @@ from __future__ import annotations
import hashlib import hashlib
import json import json
import re import re
from datetime import date
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from common_util.common_util_derived_store import read_derived 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 E01_StabRegister import E01_StabRegister_Source as source
from E02_StabGround import E02_StabGround_Model as ground_model from E02_StabGround import E02_StabGround_Model as ground_model
from E03_StabSection import E03_StabSection_View as view2d 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", "C9",
" · ".join(str(v) for v in (project.get("region"), project.get("road_type")) if v), " · ".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 hashlib
import shutil import shutil
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any 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_print_pdf import PrintError
from common_util.common_util_spreadsheet import with_server_values 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_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 import E05_StabReport_Print as printer
from E05_StabReport.E05_StabReport_Build import ( from E05_StabReport.E05_StabReport_Build import (
META, 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]: def _meta(book: dict[str, Any], inputs: dict[str, Any], figures: list[str]) -> dict[str, Any]:
return { return {
"fingerprint": inputs["fingerprint"], "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}, "signs": {s["id"]: sheet_sign(s) for s in book["시트"] if s["id"] in PROGRAM_SHEETS},
"figures": figures, "figures": figures,
} }
@@ -142,7 +142,7 @@ def refresh(project_root: Path) -> dict[str, Any]:
if overwritten: if overwritten:
old = folder / "_지난" old = folder / "_지난"
old.mkdir(parents=True, exist_ok=True) 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") shutil.copy2(folder / REPORT, old / f"{stamp}.json")
have = {s["id"] for s in book["시트"]} 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 { createText } from "@ui/ui_template_text";
import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { clearState, readState, writeState } from "../A00_Common/b_page_state";
import { currentProjectId } from "../A00_Common/b_project_switch"; import { currentProjectId } from "../A00_Common/b_project_switch";
import { dateTimeText } from "../common_util/common_util_time";
import { mountGroupStep } from "../A00_Common/group_workflow"; import { mountGroupStep } from "../A00_Common/group_workflow";
import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet"; import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet";
import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet_types"; 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 (busy) lines.push(t("E05_Working"));
if (dirty) lines.push(t("E05_Unsaved")); if (dirty) lines.push(t("E05_Unsaved"));
if (state?.meta?.built_at) 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")] : [])); status.replaceChildren(...(lines.length ? [note(lines.join("\n"), "e05-status__text")] : []));
const ready = Boolean(state?.book) && !busy; const ready = Boolean(state?.book) && !busy;
saveButton.disabled = !ready || !editable || !dirty; saveButton.disabled = !ready || !editable || !dirty;
+2 -2
View File
@@ -25,7 +25,6 @@ import json
import logging import logging
import time import time
from collections.abc import Callable from collections.abc import Callable
from datetime import datetime
from functools import lru_cache from functools import lru_cache
from pathlib import Path from pathlib import Path
from typing import Any, Literal 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_auth import require_company
from common_util.common_util_json import atomic_write_json from common_util.common_util_json import atomic_write_json
from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_time import utc_now_text
from config.config_db import get_db_pool from config.config_db import get_db_pool
from E01_StabRegister import E01_StabRegister_Source as source from E01_StabRegister import E01_StabRegister_Source as source
from E02_StabGround import E02_StabGround_Model as ground_model from E02_StabGround import E02_StabGround_Model as ground_model
@@ -245,7 +245,7 @@ def run(
"fingerprint": finger, "fingerprint": finger,
"engine": engine_hash(), "engine": engine_hash(),
"settings": settings, "settings": settings,
"computed_at": datetime.now().isoformat(timespec="seconds"), "computed_at": utc_now_text(),
"elapsed_s": round(time.perf_counter() - started, 2), "elapsed_s": round(time.perf_counter() - started, 2),
"grid": { "grid": {
"x0": x0 + int(ix.min()) * cell, "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 { createText } from "@ui/ui_template_text";
import { inferStationInterval, stationLabel } from "../common_util/common_util_station"; import { inferStationInterval, stationLabel } from "../common_util/common_util_station";
import { readState, writeState } from "../A00_Common/b_page_state"; 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 { currentProjectId } from "../A00_Common/b_project_switch";
import { mountGroupStep } from "../A00_Common/group_workflow"; import { mountGroupStep } from "../A00_Common/group_workflow";
import { createAreaMap, type MapLayer, type StationPoint } from "./E07_StabArea_UI_Map"; import { createAreaMap, type MapLayer, type StationPoint } from "./E07_StabArea_UI_Map";
@@ -337,10 +338,7 @@ export function renderE07StabArea(root: HTMLElement): void {
"E07_Sum_Covered", "E07_Sum_Covered",
`${Math.round(s.covered_area_m2).toLocaleString()} / ${Math.round(s.band_area_m2).toLocaleString()} m²`, `${Math.round(s.covered_area_m2).toLocaleString()} / ${Math.round(s.band_area_m2).toLocaleString()} m²`,
], ],
[ ["E07_Sum_Time", `${result.elapsed_s.toFixed(1)} s · ${dateTimeText(result.computed_at)}`],
"E07_Sum_Time",
`${result.elapsed_s.toFixed(1)} s · ${result.computed_at.replace("T", " ")}`,
],
]; ];
const list = createKv(pairs.map(([key, value]) => ({ label: t(key), value }))); const list = createKv(pairs.map(([key, value]) => ({ label: t(key), value })));
list.classList.add("e07-summary"); list.classList.add("e07-summary");
+2 -2
View File
@@ -10,9 +10,9 @@ from __future__ import annotations
import json import json
import re import re
from datetime import date
from pathlib import Path from pathlib import Path
from common_util.common_util_time import kst_today
from config import config_system from config import config_system
from M01_MasterData import M01_MasterData_Store as store from M01_MasterData import M01_MasterData_Store as store
@@ -34,7 +34,7 @@ def _make_file() -> None:
path = _path() path = _path()
if path.is_file(): if path.is_file():
return 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") path.write_text(store.dump(head), encoding="utf-8", newline="\n")
+2 -2
View File
@@ -8,9 +8,9 @@
from __future__ import annotations from __future__ import annotations
import re import re
from datetime import date
from pathlib import Path from pathlib import Path
from common_util.common_util_time import kst_today
from M01_MasterData import M01_MasterData_Store as store from M01_MasterData import M01_MasterData_Store as store
cm, mk, mcp = store.cm, store.mk, store.mcp cm, mk, mcp = store.cm, store.mk, store.mcp
@@ -274,7 +274,7 @@ def _make_file() -> None:
path = _own_path() path = _own_path()
if path.is_file(): if path.is_file():
return return
head = {"그룹": "로직", "원문": "자체", "판": str(date.today().year), "줄": []} head = {"그룹": "로직", "원문": "자체", "판": str(kst_today().year), "줄": []}
path.write_text(store.dump(head), encoding="utf-8", newline="\n") path.write_text(store.dump(head), encoding="utf-8", newline="\n")
+2 -2
View File
@@ -27,11 +27,11 @@
from __future__ import annotations from __future__ import annotations
import json import json
from datetime import datetime
from decimal import Decimal from decimal import Decimal
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from common_util.common_util_time import utc_now_text
from M01_MasterData import M01_MasterData_Store as m01 from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Hopyo_Ledger as ledger from M02_MasterTemplete import M02_Hopyo_Ledger as ledger
from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match 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: 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 "" hopyo, file = self.hopyo, self.doc.get("파일") or ""
lines, mats, missing, used, mat_lines = [], {}, [], set(), {} lines, mats, missing, used, mat_lines = [], {}, [], set(), {}
nation: dict[tuple, dict[str, Any]] = {} nation: dict[tuple, dict[str, Any]] = {}
+2 -2
View File
@@ -14,11 +14,11 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from decimal import Decimal from decimal import Decimal
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from common_util.common_util_time import utc_now_text
from M01_MasterData import M01_MasterData_Store as m01 from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs
@@ -245,7 +245,7 @@ def write(
if not entries: if not entries:
return 0 return 0
have = read(folder) have = read(folder)
stamp = datetime.now().isoformat(timespec="seconds") stamp = utc_now_text()
version = _version() version = _version()
out: dict[str, dict[str, Any] | None] = dict(have) # 옛 꼴도 새 꼴로 다시 씀 out: dict[str, dict[str, Any] | None] = dict(have) # 옛 꼴도 새 꼴로 다시 씀
for full, row in entries.items(): for full, row in entries.items():
+2 -2
View File
@@ -11,11 +11,11 @@ from __future__ import annotations
import json import json
import re import re
import threading import threading
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from common_util.common_util_json import atomic_write_json from common_util.common_util_json import atomic_write_json
from common_util.common_util_time import utc_now_text
from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete import M02_MasterTemplete_Store as store
DIRNAME = "실무자료" DIRNAME = "실무자료"
@@ -91,7 +91,7 @@ def save(
doc = { doc = {
"키": key, "키": key,
"파일": name, "파일": name,
"올린때": datetime.now().isoformat(timespec="seconds"), "올린때": utc_now_text(),
"올린이": who, "올린이": who,
**parsed, **parsed,
} }
+3 -3
View File
@@ -22,7 +22,6 @@ from __future__ import annotations
import logging import logging
import re import re
import shutil import shutil
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any 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_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_json import atomic_write_json
from common_util.common_util_summary_item import GROUP_JOIN, snapshot_folder_name 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 import M02_Template_Layers as layers
from M02_MasterTemplete.M02_Item_Source import ( from M02_MasterTemplete.M02_Item_Source import (
LABOR_FILE, LABOR_FILE,
@@ -82,7 +82,7 @@ def safe_folder(base: Path, name: str) -> Path | None:
def now_text() -> str: def now_text() -> str:
return datetime.now().isoformat(timespec="seconds") return utc_now_text()
def same_item(manifest: Any, item: dict[str, Any]) -> bool: 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("고침"): if isinstance(manifest, dict) and manifest.get("고침"):
past = folder.parent / PAST_DIRNAME past = folder.parent / PAST_DIRNAME
past.mkdir(parents=True, exist_ok=True) 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)) shutil.move(str(folder), str(target))
return target.name return target.name
shutil.rmtree(folder) shutil.rmtree(folder)
@@ -14,11 +14,11 @@ from __future__ import annotations
import json import json
import re import re
from datetime import date
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from common_util.common_util_json import atomic_write_json from common_util.common_util_json import atomic_write_json
from common_util.common_util_time import kst_today
from M01_MasterData import M01_MasterData_Store as m01 from M01_MasterData import M01_MasterData_Store as m01
from M01_MasterData import M01_MasterData_Store_Combo as combo_store from M01_MasterData import M01_MasterData_Store_Combo as combo_store
from M02_MasterTemplete import M02_MasterTemplete_Store as 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) return _m01(combo_store._read)
path = store.root() / FILE path = store.root() / FILE
if not path.is_file(): if not path.is_file():
head = {"그룹": mcb.GROUP, "판": str(date.today().year), "폴더": [], "다음": 1} head = {"그룹": mcb.GROUP, "판": str(kst_today().year), "폴더": [], "다음": 1}
return {**head, "줄": []}, "" return {**head, "줄": []}, ""
raw = path.read_bytes() raw = path.read_bytes()
return json.loads(raw), store.version_of(raw) return json.loads(raw), store.version_of(raw)
@@ -20,6 +20,7 @@ import {
type LedgerRow, type LedgerRow,
} from "./M02_MasterTemplete_Hopyo_Api"; } from "./M02_MasterTemplete_Hopyo_Api";
import { fetchLogicInputs } from "./M02_MasterTemplete_Labor_Api"; import { fetchLogicInputs } from "./M02_MasterTemplete_Labor_Api";
import { dateTimeText } from "../common_util/common_util_time";
import { openLaborInput } from "./M02_MasterTemplete_Labor_Input"; import { openLaborInput } from "./M02_MasterTemplete_Labor_Input";
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
import "./M02_MasterTemplete_Hopyo.css"; 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.처음파일] const when = [r.누가?.이름, dateTimeText(r.때, ""), r.처음파일].filter(Boolean).join(" · ");
.filter(Boolean)
.join(" · ");
return el("tr", { return el("tr", {
className: `m02-hopyo-ledger__row${r.고침 ? "" : " is-upper"}${r.고친 ? " is-edited" : ""}`, className: `m02-hopyo-ledger__row${r.고침 ? "" : " is-upper"}${r.고친 ? " is-edited" : ""}`,
attrs: { "data-ledger": r.열쇠 }, attrs: { "data-ledger": r.열쇠 },
@@ -9,6 +9,7 @@
import { createInputField, el, showToast } from "@ui/ui_template_elements"; import { createInputField, el, showToast } from "@ui/ui_template_elements";
import { buildPager } from "../M01_MasterData/M01_MasterData_UI_Cells"; 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 { buildHeadFilters } from "../M01_MasterData/M01_MasterData_UI_Head";
import "../M01_MasterData/M01_MasterData_UI_Style.css"; import "../M01_MasterData/M01_MasterData_UI_Style.css";
import { fetchTree, type Layer, type TreeItem } from "./M02_MasterTemplete_Api_Fetch"; import { fetchTree, type Layer, type TreeItem } from "./M02_MasterTemplete_Api_Fetch";
@@ -50,7 +51,7 @@ const TEXT = {
const SIZE = 30; const SIZE = 30;
const DASH = "—"; 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 => const money = (value: number | null | undefined): string =>
typeof value === "number" ? value.toLocaleString("ko-KR", { maximumFractionDigits: 0 }) : DASH; typeof value === "number" ? value.toLocaleString("ko-KR", { maximumFractionDigits: 0 }) : DASH;
/** 가장 늦게 고친 날 — 항목 통째 */ /** 가장 늦게 고친 날 — 항목 통째 */
@@ -9,11 +9,11 @@
from __future__ import annotations from __future__ import annotations
import json import json
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from common_util.common_util_json import atomic_write_json from common_util.common_util_json import atomic_write_json
from common_util.common_util_time import utc_from_timestamp
from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos
from M02_MasterTemplete import M02_MasterTemplete_Store as store 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"): for kind in ("structure", "area", "labor"):
path = folder / kind / f"{col}.json" path = folder / kind / f"{col}.json"
if path.is_file(): if path.is_file():
stamps[kind] = datetime.fromtimestamp(path.stat().st_mtime).isoformat( stamps[kind] = utc_from_timestamp(path.stat().st_mtime)
timespec="seconds"
)
labor = None labor = None
path = folder / "labor" / f"{col}.json" path = folder / "labor" / f"{col}.json"
if path.is_file(): if path.is_file():
@@ -13,12 +13,12 @@ import threading
from collections.abc import Iterator from collections.abc import Iterator
from contextlib import contextmanager from contextlib import contextmanager
from contextvars import ContextVar from contextvars import ContextVar
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from common_util import common_util_spreadsheet as spreadsheet from common_util import common_util_spreadsheet as spreadsheet
from common_util.common_util_json import atomic_write_json from common_util.common_util_json import atomic_write_json
from common_util.common_util_time import utc_from_timestamp
from M02_MasterTemplete import M02_MasterTemplete_Tree as tree from M02_MasterTemplete import M02_MasterTemplete_Tree as tree
from M02_MasterTemplete import M02_Template_Layers as layers 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]: def _info(kind: str, path: Path) -> dict[str, Any]:
raw = path.read_bytes() 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} row = {"종류": kind, "이름": path.stem, "판": version_of(raw), "수정일": stamp}
if (kind, path.stem) in IN_USE: if (kind, path.stem) in IN_USE:
row["쓰는곳"] = IN_USE[(kind, path.stem)] # 화면이 지우기 확인 창 전에 막음 row["쓰는곳"] = IN_USE[(kind, path.stem)] # 화면이 지우기 확인 창 전에 막음
+3 -3
View File
@@ -18,11 +18,11 @@ from __future__ import annotations
import hashlib import hashlib
import json import json
import shutil import shutil
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from common_util.common_util_json import atomic_write_json from common_util.common_util_json import atomic_write_json
from common_util.common_util_time import utc_from_timestamp, utc_now_text
from config import config_system from config import config_system
LAYERS = ("system", "company", "personal", "project") LAYERS = ("system", "company", "personal", "project")
@@ -128,7 +128,7 @@ def version_of(path: str | Path) -> str | None:
def _modified(path: Path) -> str: 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]]: 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, "층": layer,
"이름": name, "이름": name,
"판": version, "판": version,
"적용일": datetime.now().isoformat(timespec="seconds"), "적용일": utc_now_text(),
} }
entry.update({key: value for key, value in extra.items() if value is not None}) entry.update({key: value for key, value in extra.items() if value is not None})
return entry return entry
+3 -2
View File
@@ -16,10 +16,11 @@ import asyncio
import copy import copy
import os import os
import sys import sys
from datetime import datetime
from pathlib import Path, PurePosixPath from pathlib import Path, PurePosixPath
from typing import Any from typing import Any
from common_util.common_util_time import kst_now
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from config import config_system # noqa: E402 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 = [ lines = [
"# 기존 프로젝트 양식 넣기", "# 기존 프로젝트 양식 넣기",
"", "",
f"- 돌린 때: {datetime.now().isoformat(timespec='seconds')}", f"- 돌린 때: {kst_now().isoformat(timespec='seconds')}",
f"- 시스템 양식: {names}", f"- 시스템 양식: {names}",
"- 방법: 작업본 · `_initial/` 에 없는 것만 더함 · 있는 양식 · 다른 파일은 그대로", "- 방법: 작업본 · `_initial/` 에 없는 것만 더함 · 있는 양식 · 다른 파일은 그대로",
f"- 프로젝트 {len(report)}개 · 넣음 {sum(r['상태'] == '넣음' for r in report)}개", 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 { createText } from "@ui/ui_template_text";
import { createEmpty } from "@ui/ui_template_empty"; import { createEmpty } from "@ui/ui_template_empty";
import { createTabs } from "@ui/ui_template_tabs"; import { createTabs } from "@ui/ui_template_tabs";
import { dateTimeText } from "../../common_util/common_util_time";
/** 옛 편집기(옵션 · 배치 · 그룹 · 규칙 · 기준 측점) — 모든 종류에서 숨김(52-12 · 정의 화면 = 이름만) */ /** 옛 편집기(옵션 · 배치 · 그룹 · 규칙 · 기준 측점) — 모든 종류에서 숨김(52-12 · 정의 화면 = 이름만) */
const OLD_EDITOR = false; const OLD_EDITOR = false;
@@ -362,7 +363,7 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void {
...(versions.length ...(versions.length
? versions.map((v) => { ? versions.map((v) => {
const b = createButton({ const b = createButton({
label: to("PastItem", `${v.판} · ${v.저장일}`), label: to("PastItem", `${v.판} · ${dateTimeText(v.저장일)}`),
variant: past?.판 === v.판 ? "filled" : "ghost", variant: past?.판 === v.판 ? "filled" : "ghost",
size: "sm", size: "sm",
}); });
@@ -9,7 +9,6 @@ from __future__ import annotations
import json import json
from collections.abc import Callable from collections.abc import Callable
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -17,6 +16,7 @@ from pydantic import ValidationError
from B05_Profile import B05_Profile_Structures_Schema as schema 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_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 import M02_MasterTemplete_Store as store
from M02_MasterTemplete.StructOptions.M02_SO_Check import catalog_errors 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 []: for path in folder.glob("판_*.json") if folder.is_dir() else []:
number = path.stem.removeprefix("판_") number = path.stem.removeprefix("판_")
if number.isdigit(): 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}) rows.append({"판": int(number), "저장일": when})
return sorted(rows, key=lambda r: r["판"], reverse=True) return sorted(rows, key=lambda r: r["판"], reverse=True)
+3 -3
View File
@@ -22,7 +22,6 @@ import hashlib
import json import json
import subprocess import subprocess
import time import time
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any, Literal from typing import Any, Literal
from uuid import uuid4 from uuid import uuid4
@@ -32,6 +31,7 @@ import aiomysql
from B05_Profile.B05_Profile_Engine_Sections_Names import find_cross_file 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_storage import resolve_stored_project_path
from common_util.common_util_surface_confirmation import get_surface_confirmation_params 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"] Scope = Literal["full", "stability"]
KIND_LABELS = {"ROAD": "B", "PLAN": "D", "STABILITY": "E"} KIND_LABELS = {"ROAD": "B", "PLAN": "D", "STABILITY": "E"}
@@ -390,7 +390,7 @@ async def clone_full(
"kind": kind, "kind": kind,
"source_project_id": source_id, "source_project_id": source_id,
"project_id": new_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()}, "tables": {name: len(items) for name, items in rows.items()},
"skipped_tables": skipped, "skipped_tables": skipped,
"files": _tree_files(new_root), "files": _tree_files(new_root),
@@ -517,7 +517,7 @@ async def copy_stability(connection, source_id: str, new_id: str, project_root:
"scope": "stability", "scope": "stability",
"source_project_id": source_id, "source_project_id": source_id,
"project_id": new_id, "project_id": new_id,
"copied_at": datetime.now().isoformat(timespec="seconds"), "copied_at": utc_now_text(),
"source_fingerprint": digest.hexdigest(), "source_fingerprint": digest.hexdigest(),
"route_id": dump["route"]["id"] if dump["route"] else None, "route_id": dump["route"]["id"] if dump["route"] else None,
"tables": { "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.ts` 로 옮김(보는 PC 시간대). 호출부가 바뀌면 이 파일을 지움. */
* common_util_time_kst.ts export { kstText } from "./common_util_time";
* 서버 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", " ");
}
+3 -1
View File
@@ -6,6 +6,8 @@ from typing import Any, Dict
import aiomysql import aiomysql
from common_util.common_util_time import utc_text
# `project_workflow_stages.message` 는 varchar(255) — 넘치면 UPDATE 자체가 실패한다. # `project_workflow_stages.message` 는 varchar(255) — 넘치면 UPDATE 자체가 실패한다.
_STAGE_MESSAGE_MAX_LENGTH = 200 _STAGE_MESSAGE_MAX_LENGTH = 200
@@ -492,7 +494,7 @@ async def analysis_lock_owner(
"user_id": user_id, "user_id": user_id,
"name": name, "name": name,
"email": email, "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` 가 # 개발환경 전용 — 「확정 없이 다음으로」. **문은 서버가 정본이다** — `ENVIRONMENT` 가
# 개발이 아니면 세 입구 모두 403 으로 거절한다(화면 단추 숨김은 보조). # 개발이 아니면 세 입구 모두 403 으로 거절한다(화면 단추 숨김은 보조).
from common_util.common_util_dev_unlock_router import router as dev_unlock_router from common_util.common_util_dev_unlock_router import router as dev_unlock_router
from common_util.common_util_time import install_json_utc
from common_util.common_util_help_router import router as help_router from common_util.common_util_help_router import router as help_router
from common_util.common_util_map_router import router as map_router from common_util.common_util_map_router import router as map_router
from common_util.common_util_resource_monitor import sample_resources_loop from common_util.common_util_resource_monitor import sample_resources_loop
@@ -298,6 +299,7 @@ app = FastAPI(
debug=DEBUG, debug=DEBUG,
lifespan=lifespan, lifespan=lifespan,
) )
install_json_utc() # 응답의 시각 = UTC 글 + Z(화면이 보는 사람 시간대로 바꿈)
app.add_exception_handler(ProjectNotFoundError, project_not_found_response) # 지워진 프로젝트 = 404 app.add_exception_handler(ProjectNotFoundError, project_not_found_response) # 지워진 프로젝트 = 404
# ───────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────
@@ -2,10 +2,11 @@
import asyncio import asyncio
import json 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_Router_Home as home
import B01_Dashboard.B01_Dashboard_Summary as summary import B01_Dashboard.B01_Dashboard_Summary as summary
from common_util.common_util_time import utc_now_naive
ROUTE = "B05_Profile/route/r.geojson" ROUTE = "B05_Profile/route/r.geojson"
@@ -65,7 +66,7 @@ class _Pool:
def test_summaries_in_batch(monkeypatch, tmp_path): 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 = tmp_path / "B05_Profile" / "route"
route_dir.mkdir(parents=True) route_dir.mkdir(parents=True)
coords = [[0, 0, 1], [10, 0, 1], [10, 5, 1]] coords = [[0, 0, 1], [10, 0, 1], [10, 5, 1]]
@@ -87,6 +87,7 @@ def test_로그_거르기_글():
] # fmt: skip ] # fmt: skip
out = _node( out = _node(
"type AuditLog = any;\n" "type AuditLog = any;\n"
+ "const dateText = (v: string | null | undefined, e: string) => (v ? v.slice(0, 10) : e);\n"
+ src + src
+ f"const l = {json.dumps(logs, ensure_ascii=False)};" + f"const l = {json.dumps(logs, ensure_ascii=False)};"
+ "const f = (o: object) => filterLogs(l, { action: '', from: '', to: '', q: '', ...o }).length;" + "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 { createButton, el, showConfirmDialog, showToast } from "./ui_template_elements";
import { readTemplate } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; 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 { calcLabor, type LaborDoc } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api";
import type { EditKind, ItemCardsHandle } from "../M02_MasterTemplete/M02_MasterTemplete_ItemCards"; import type { EditKind, ItemCardsHandle } from "../M02_MasterTemplete/M02_MasterTemplete_ItemCards";
import type { ItemSource } from "../M02_MasterTemplete/M02_MasterTemplete_ItemSource"; 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 why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
const fill = (text: string, value: string): string => text.replace("{value}", value); 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 { export function createItemDetail(opts: ItemDetailOptions): ItemDetail {
const root = el("div", { className: "ui-item-picker__detail" }); const root = el("div", { className: "ui-item-picker__detail" });