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:
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
/**
|
||||
* 위·아래 두 줄짜리 표 칸. 아랫줄은 작은 글씨라 **행 높이는 한 줄일 때와 같다**.
|
||||
|
||||
@@ -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] }),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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]),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
# ── 프로그램 몫 ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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["시트"]}
|
||||
# 새 판에 없는 프로그램 몫(보강검토 — 저장본 없음)은 뺌
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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]] = {}
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)] # 화면이 지우기 확인 창 전에 막음
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 ""
|
||||
@@ -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);
|
||||
@@ -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";
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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;"
|
||||
|
||||
@@ -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" });
|
||||
|
||||
Reference in New Issue
Block a user