From 2d0895d97586516a8354172767be381349cd7c4f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 6 Sep 2026 17:45:43 +0900 Subject: [PATCH 1/8] =?UTF-8?q?fix(B03):=20LAS=20=EC=97=86=EC=9D=B4=20?= =?UTF-8?q?=EC=84=A4=EA=B3=84=20=EC=97=85=EB=A1=9C=EB=93=9C=EA=B0=80=20?= =?UTF-8?q?=EC=A7=80=ED=98=95=20PRJ=C2=B7TFW=20=ED=95=84=EC=88=98=20?= =?UTF-8?q?=EA=B2=80=EC=82=AC=EC=97=90=20=EB=A7=89=ED=9E=88=EB=8D=98=20?= =?UTF-8?q?=EA=B2=83=20=ED=95=B4=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 「LAS 없이 설계」면 지형 한 벌(포인트클라우드·지형 PRJ·TFW)을 필수에서 면제 - 업로드 요청 검사도 완료 검사와 같은 기준으로 노선 PRJ 와 지형 PRJ 를 갈라 셈 (종전에는 확장자만 봐서 노선 PRJ 를 지형 PRJ 로 오인) - 업로드 현황의 「필수 충족」은 stage 0 완료를 우선 근거로 판정 — B04 이동 버튼 복구 Co-Authored-By: Claude Opus 5 (1M context) --- B03_FileInput/B03_FileInput_Router.py | 16 ++++++--- B03_FileInput/B03_FileInput_Router_Helpers.py | 33 +++++++++++++++++-- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/B03_FileInput/B03_FileInput_Router.py b/B03_FileInput/B03_FileInput_Router.py index 6747cb5b..2af94831 100644 --- a/B03_FileInput/B03_FileInput_Router.py +++ b/B03_FileInput/B03_FileInput_Router.py @@ -94,6 +94,9 @@ from B03_FileInput.B03_FileInput_Router_Helpers import ( from B03_FileInput.B03_FileInput_Router_Helpers import ( _write_stage_metadata as _write_stage_metadata, ) +from B03_FileInput.B03_FileInput_Router_Helpers import ( + upload_file_types as upload_file_types, +) from B03_FileInput.B03_FileInput_Schema import ( FileUploadDescriptor, FileUploadResponse, @@ -156,12 +159,13 @@ async def upload_project_files( "message": "LAS 없이 설계를 켠 상태에서는 LAS/LAZ 파일을 올릴 수 없습니다.", }, ) - if not las_free and las_count != 1: + # 지형 파일은 도엽별로 여러 장이 올 수 있다 — 합쳐서 전처리한다(2026-09-06 사용자 확정). + if not las_free and las_count < 1: return JSONResponse( status_code=400, content={ "status": "error", - "message": "LAS 또는 LAZ 파일을 정확히 1개 포함해야 합니다.", + "message": "LAS 또는 LAZ 파일을 1개 이상 포함해야 합니다.", }, ) # 계획노선은 shapefile 또는 CSV 한 벌이다 (2026-08-31) — 문구도 그렇게 맞춘다 @@ -175,7 +179,7 @@ async def upload_project_files( "message": "계획노선 파일(shapefile 의 .shp 또는 .csv)을 정확히 1개 포함해야 합니다.", }, ) - request_file_types = {Path(filename).suffix.lower().lstrip(".") for filename in filenames} + request_file_types = upload_file_types(filenames) missing_required = _missing_required_file_types(request_file_types, las_free) if missing_required: return JSONResponse( @@ -350,8 +354,10 @@ async def get_project_upload_overview( ) for row in sessions ], - required_complete=_REQUIRED_FILE_TYPES <= file_types - and (point_cloud_id is not None or stage0_complete), + # stage 0 을 마쳤으면 서버 필수검사를 이미 통과한 것이다 — LAS 없이 설계는 + # 지형 한 벌(prj·tfw)이 아예 없으므로 여기서 다시 세면 B04 이동이 막힌다. + required_complete=stage0_complete + or (_REQUIRED_FILE_TYPES <= file_types and point_cloud_id is not None), analysis_complete=analysis_complete, ) except Exception: diff --git a/B03_FileInput/B03_FileInput_Router_Helpers.py b/B03_FileInput/B03_FileInput_Router_Helpers.py index 7b895adb..2251fc9c 100644 --- a/B03_FileInput/B03_FileInput_Router_Helpers.py +++ b/B03_FileInput/B03_FileInput_Router_Helpers.py @@ -62,14 +62,34 @@ def _is_point_cloud_result(result: UploadedFileResult) -> bool: return result.file_type.lower() in _POINT_CLOUD_FILE_TYPES +def upload_file_types(filenames: list[str]) -> set[str]: + """업로드 요청의 파일명을 **완료 검사와 같은 기준**으로 유형화한다. + + 확장자만 세면 노선 세트의 `.prj`가 지형 PRJ로 오인돼, 요청 검사는 통과하고 저장 뒤 + 완료 검사에서 누락으로 갈린다(2026-09-06 실측 — LAS 없이 설계가 이 어긋남으로 막혔다). + 노선 도형(`.shp`)과 basename이 같은 PRJ는 화면의 카드 배정과 같은 규칙으로 `route_prj`. + """ + route_stem = next( + (Path(name).stem for name in filenames if Path(name).suffix.lower() == ".shp"), None + ) + types: set[str] = set() + for name in filenames: + suffix = Path(name).suffix.lower().lstrip(".") + if suffix == "prj" and route_stem is not None and Path(name).stem == route_stem: + suffix = "route_prj" + types.add(suffix) + return types + + def _missing_required_file_types(file_types: set[str], las_free: bool = False) -> list[str]: - missing = sorted(_REQUIRED_FILE_TYPES - file_types) + # LAS 없는 설계(도엽등고선 기반, 2026-08-30)는 지형 한 벌(포인트클라우드·지형 PRJ· + # TFW)을 통째로 받지 않는다 — 화면도 그 카드들을 필수에서 뺀다. + missing = [] if las_free else sorted(_REQUIRED_FILE_TYPES - file_types) if not file_types.intersection(_ROUTE_FILE_TYPES): missing.append("csv/shp") # shapefile로 왔으면 형제 파일이 다 있어야 노선을 읽는다. if "shp" in file_types: missing.extend(sorted(_SHAPEFILE_REQUIRED_TYPES - file_types)) - # LAS 없는 설계(도엽등고선 기반, 2026-08-30)는 LAS 필수를 면제한다. if not las_free and not file_types.intersection(_POINT_CLOUD_FILE_TYPES): missing.append("las/laz") return missing @@ -143,6 +163,15 @@ async def _complete_file_input_if_ready( # 아직 다시 만들어지지 않은 뒷단계 화면이 옛 결과를 새 자료 것인 양 보여준다. stored_path = await get_project_storage_relative_path(connection, project_id) project_root = Path(resolve_stored_project_path(stored_path)) + # 지형 파일 여러 장은 합쳐서 전처리한다 — 다른 사업지 파일이 섞이면 합친 범위가 + # 통째로 어긋나므로 여기서 막는다(2026-09-06 사용자 지시). 머리글만 읽어 즉시 끝난다. + from B04_PreProcess.B04_PreProcess_Engine_Structurize import merge_gap_error + from B04_PreProcess.B04_PreProcess_Repository import list_project_point_cloud_paths + + terrain_paths = await list_project_point_cloud_paths(connection, project_id, project_root) + gap_message = merge_gap_error(terrain_paths) + if gap_message: + raise ValueError(gap_message) clear_designing(project_root) discard_initial_snapshot(project_root) await purge_project_outputs(connection, str(project_id), project_root) From 72075563e34095ea10abd84bf75cf735f14b605d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 6 Sep 2026 17:45:59 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat(B03=C2=B7B04):=20=EC=A7=80=ED=98=95=20?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=8B=A4=20=EC=97=AC=EB=9F=AC=20=EC=9E=A5=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=C2=B7=EB=B3=91=ED=95=A9=20=EC=A0=84=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 지형 파일 개수 제한 해제(정확히 1개 → 1장 이상), 한 카드에 여러 장 담기 (화면 표시 「용화_서편.las 외 1장」, 업로드는 한 장씩 차례로 전송) - 구조화 엔진이 여러 파일을 합친 범위로 한 벌 생성 — WF1 자동 전처리·B04 재분석 모두 프로젝트 지형 파일 전부를 대상으로 실행 - 점이 1억 개를 넘으면 씨닝 — 지면 분류점은 전부 남기고 나머지만 0.5m 칸 최저점으로 축소 (용화 실측: 4,900만점 → 249만점, 지면점 1,140,716개 그대로, 1m 지면격자 표고차 0.0000m) - 머리글만 읽어 5km 넘게 떨어진 파일은 업로드 거부 (임도는 길어도 2~3km) - 화면 조립부 700줄 준수를 위해 terrainCoverage 를 판정 모듈로 이동 Co-Authored-By: Claude Opus 5 (1M context) --- B03_FileInput/B03_FileInput_Service_WF1.py | 13 +- B03_FileInput/B03_FileInput_UI_Page.ts | 44 +-- B03_FileInput/B03_FileInput_UI_Page_Flow.ts | 16 +- B03_FileInput/B03_FileInput_UI_Page_Rules.ts | 29 ++ B03_FileInput/B03_FileInput_UI_Support.ts | 25 ++ B03_FileInput/B03_FileInput_UI_Upload.ts | 4 +- B04_PreProcess/B04_PreProcess_Engine.py | 44 ++- .../B04_PreProcess_Engine_Structurize.py | 362 ++++++++++++++---- B04_PreProcess/B04_PreProcess_Repository.py | 21 +- B04_PreProcess/B04_PreProcess_Router.py | 7 +- config/config_system_terrain.py | 14 + 11 files changed, 457 insertions(+), 122 deletions(-) diff --git a/B03_FileInput/B03_FileInput_Service_WF1.py b/B03_FileInput/B03_FileInput_Service_WF1.py index 5ffb7cb3..053b97ac 100644 --- a/B03_FileInput/B03_FileInput_Service_WF1.py +++ b/B03_FileInput/B03_FileInput_Service_WF1.py @@ -79,11 +79,18 @@ async def trigger_wf1_analysis_and_email( await connection.commit() stored_path = await get_project_storage_relative_path(connection, project_id) project_info = await _get_project_notification_info(connection, project_id) - from B04_PreProcess.B04_PreProcess_Repository import get_input_file + from B04_PreProcess.B04_PreProcess_Repository import ( + get_input_file, + list_project_point_cloud_paths, + ) input_file = await get_input_file(connection, project_id, input_file_id) + project_root = Path(resolve_stored_project_path(stored_path)) + # 지형 파일이 여러 장이면 합쳐서 한 벌로 전처리한다(2026-09-06 사용자 확정). + terrain_paths = await list_project_point_cloud_paths( + connection, project_id, project_root + ) - project_root = Path(resolve_stored_project_path(stored_path)) source_path = project_root / Path(str(input_file["raw_file_path"])) # LAS 없는 설계(2026-08-30): 입력이 계획노선 CSV면 도엽등고선 서피스 분석으로 간다. las_free = str(input_file.get("file_type") or "").lower() not in {"las", "laz"} @@ -118,7 +125,7 @@ async def trigger_wf1_analysis_and_email( analysis_result = await asyncio.to_thread( run_surface_analysis, project_root, - source_path, + terrain_paths or [source_path], source_filters=None, methods=methods, force=False, diff --git a/B03_FileInput/B03_FileInput_UI_Page.ts b/B03_FileInput/B03_FileInput_UI_Page.ts index 6a431bef..d4d4d6e2 100644 --- a/B03_FileInput/B03_FileInput_UI_Page.ts +++ b/B03_FileInput/B03_FileInput_UI_Page.ts @@ -24,15 +24,11 @@ import { createUploadFlow } from "./B03_FileInput_UI_Page_Flow"; import { isSlotRequired, slotForOverviewFile, + terrainCoverage, validateFileForSlot, validateSlots, } from "./B03_FileInput_UI_Page_Rules"; -import { - readCrsLabel, - readExtent, - renderSlotPreview, - type PreviewExtent, -} from "./B03_FileInput_UI_Preview"; +import { readCrsLabel, renderSlotPreview } from "./B03_FileInput_UI_Preview"; import { confirmReplaceUpload } from "./B03_FileInput_UI_Upload"; import { createFileCardTemplate, @@ -41,9 +37,11 @@ import { initializeSlots, makeSessionKey, planSlotAssignments, + pushExtraFile, ROUTE_SLOTS, SHAPEFILE_DEPENDENT_SLOTS, slotConfigs, + slotFileLabel, TERRAIN_SLOTS, type FileSlot, type FileSlotState, @@ -209,7 +207,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise { const percent = state.file ? Math.min(100, (state.progressBytes / state.file.size) * 100) : 0; // 로컬 파일이 없어도 서버에 업로드된 파일이 있으면 그 정보(정본)를 보여준다. - if (fileName) fileName.textContent = state.file?.name ?? state.serverUploaded?.name ?? ""; + if (fileName) fileName.textContent = slotFileLabel(state); if (fileSize) { fileSize.textContent = state.file ? formatBytes(state.file.size) @@ -240,7 +238,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise { const preview = card.querySelector(".b03-file__preview"); if (preview) { - const terrain = terrainCoverage(); + const terrain = terrainCoverage(slots); renderSlotPreview(preview, { metadata: state.serverUploaded?.metadata, // 업로드·분석이 끝난 카드에만 보인다 (2026-09-04 사용자 지시). @@ -258,26 +256,6 @@ export async function renderB03FileInput(root: HTMLElement): Promise { * 들어오는지 대조하는 기준. 초기 계산 실패의 주된 원인이 범위 불일치라 * 카드에서 바로 보이게 한다(2026-09-04). */ - function terrainCoverage(): { extent: PreviewExtent | null; crs: string | null } { - let extent: PreviewExtent | null = null; - let crs: string | null = null; - for (const slot of TERRAIN_SLOTS) { - const metadata = slots.get(slot)?.serverUploaded?.metadata; - const next = readExtent(metadata); - if (!next) continue; - crs ??= readCrsLabel(metadata); - extent = extent - ? { - xMin: Math.min(extent.xMin, next.xMin), - xMax: Math.max(extent.xMax, next.xMax), - yMin: Math.min(extent.yMin, next.yMin), - yMax: Math.max(extent.yMax, next.yMax), - } - : next; - } - return { extent, crs }; - } - function showErrorMessage(slot: FileSlot, error: string): void { const state = slots.get(slot); if (!state) return; @@ -297,8 +275,13 @@ export async function renderB03FileInput(root: HTMLElement): Promise { showErrorMessage(state.slot, `${validation} ${file.name}`); return; } - if (!targetSlot && state.file && state.file.name !== file.name) { - showErrorMessage(state.slot, `${L("B03_File_Error_DuplicateSlot")} ${file.name}`); + // 지형 자료는 도엽별로 여러 장이 온다 — 카드에 더 담고 전처리가 합쳐 쓴다. + if (state.file && state.file.name !== file.name) { + if (!pushExtraFile(state, file)) { + showErrorMessage(state.slot, `${L("B03_File_Error_DuplicateSlot")} ${file.name}`); + return; + } + renderSlot(state.slot); return; } // 서버에 이미 완료된 슬롯이면 교체 확인을 받는다(2026-08-04 사용자 지시). 이어올리기로 @@ -382,6 +365,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise { localStorage.removeItem(makeSessionKey(activeProjectId, state.file)); } state.file = undefined; + state.extraFiles = undefined; state.uploadSessionId = undefined; state.uploadStatus = "pending"; state.progressBytes = 0; diff --git a/B03_FileInput/B03_FileInput_UI_Page_Flow.ts b/B03_FileInput/B03_FileInput_UI_Page_Flow.ts index eeda5ac1..bc8c3104 100644 --- a/B03_FileInput/B03_FileInput_UI_Page_Flow.ts +++ b/B03_FileInput/B03_FileInput_UI_Page_Flow.ts @@ -201,14 +201,24 @@ export function createUploadFlow(ctx: UploadFlowContext): UploadFlowHandle { ctx.pageError.textContent = ""; ctx.setUploading(true); try { - for (let index = 0; index < targetStates.length; index += 1) { - const state = targetStates[index]; + // 지형 자료는 한 카드에 여러 장이 담길 수 있다 — 카드 순서대로 한 장씩 올린다. + const jobs = targetStates.flatMap((state) => + [state.file!, ...(state.extraFiles ?? [])].map((file) => ({ state, file })), + ); + for (let index = 0; index < jobs.length; index += 1) { + const { state, file } = jobs[index]; + if (file !== state.file) { + // 앞 파일이 쓰던 전송 세션·진행률을 물려받지 않게 되돌린다. + state.uploadSessionId = undefined; + state.progressBytes = 0; + } await uploadOneFile( ctx.projectId(), state, - index === targetStates.length - 1, + index === jobs.length - 1, () => ctx.renderSlot(state.slot), ctx.lasFreeDesign(), + file, ); } clearDerivedCaches(ctx.projectId()); diff --git a/B03_FileInput/B03_FileInput_UI_Page_Rules.ts b/B03_FileInput/B03_FileInput_UI_Page_Rules.ts index 54e0ab3a..a60aa3f7 100644 --- a/B03_FileInput/B03_FileInput_UI_Page_Rules.ts +++ b/B03_FileInput/B03_FileInput_UI_Page_Rules.ts @@ -10,6 +10,7 @@ import { UPLOAD_MAX_FILES, UPLOAD_MAX_MB } from "@config/config_frontend"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import type { UploadOverviewFile } from "./B03_FileInput_Api_Fetch"; +import { readCrsLabel, readExtent, type PreviewExtent } from "./B03_FileInput_UI_Preview"; import { getExtension, SHAPEFILE_DEPENDENT_SLOTS, @@ -105,3 +106,31 @@ export function slotForOverviewFile( (candidate) => candidate.slot !== "route_prj" && candidate.extensions.includes(extension), )?.slot; } + +/** + * 서버에 올라온 지형 자료가 덮는 범위와 좌표계 — 카드 미리보기가 쓴다. + * 여러 카드(포인트클라우드·좌표계·래스터)의 범위를 합친다. 화면 조립부가 700줄을 + * 넘어 옮겨 온 순수 함수다(2026-09-06). + */ +export function terrainCoverage(slots: SlotMap): { + extent: PreviewExtent | null; + crs: string | null; +} { + let extent: PreviewExtent | null = null; + let crs: string | null = null; + for (const slot of TERRAIN_SLOTS) { + const metadata = slots.get(slot)?.serverUploaded?.metadata; + const next = readExtent(metadata); + if (!next) continue; + crs ??= readCrsLabel(metadata); + extent = extent + ? { + xMin: Math.min(extent.xMin, next.xMin), + xMax: Math.max(extent.xMax, next.xMax), + yMin: Math.min(extent.yMin, next.yMin), + yMax: Math.max(extent.yMax, next.yMax), + } + : next; + } + return { extent, crs }; +} diff --git a/B03_FileInput/B03_FileInput_UI_Support.ts b/B03_FileInput/B03_FileInput_UI_Support.ts index 609e9fad..9caaa008 100644 --- a/B03_FileInput/B03_FileInput_UI_Support.ts +++ b/B03_FileInput/B03_FileInput_UI_Support.ts @@ -28,6 +28,12 @@ export interface SlotConfig { export interface FileSlotState extends SlotConfig { file?: File; + /** + * 같은 카드에 더 담은 파일 — 지형 자료(포인트클라우드)만 여러 장을 받는다. + * 드론 라이다는 사업지가 넓으면 도엽별로 나뉘어 오고, 전처리가 합쳐서 쓴다 + * (2026-09-06 사용자 확정). 업로드는 이 목록을 한 장씩 차례로 올린다. + */ + extraFiles?: File[]; uploadSessionId?: string; uploadStatus: UploadStatus; progressBytes: number; @@ -123,6 +129,25 @@ const SLOT_CONFIGS: readonly SlotConfig[] = [ }, ]; +/** 카드에 적을 파일 이름 — 여러 장이면 「첫 장 외 N장」. */ +export function slotFileLabel(state: FileSlotState): string { + const name = state.file?.name ?? state.serverUploaded?.name ?? ""; + const extras = state.extraFiles?.length ?? 0; + return extras > 0 ? `${name} 외 ${extras}장` : name; +} + +/** + * 같은 카드에 파일을 더 담는다 — 담았으면 true, 이 카드가 한 장짜리면 false. + * 지형 자료(포인트클라우드)만 여러 장을 받는다. 같은 이름은 다시 담지 않는다. + */ +export function pushExtraFile(state: FileSlotState, file: File): boolean { + if (state.slot !== "las_laz") return false; + const extras = state.extraFiles ?? []; + if (!extras.some((item) => item.name === file.name)) state.extraFiles = [...extras, file]; + state.error = undefined; + return true; +} + export function getExtension(fileName: string): string { const index = fileName.lastIndexOf("."); return index >= 0 ? fileName.slice(index).toLowerCase() : ""; diff --git a/B03_FileInput/B03_FileInput_UI_Upload.ts b/B03_FileInput/B03_FileInput_UI_Upload.ts index 3f69e449..b3bceb42 100644 --- a/B03_FileInput/B03_FileInput_UI_Upload.ts +++ b/B03_FileInput/B03_FileInput_UI_Upload.ts @@ -83,8 +83,10 @@ export async function uploadOneFile( completeUpload: boolean, onProgress: () => void, lasFree = false, + // 지형 자료는 한 카드에 여러 장이 담긴다 — 올릴 파일을 지정받는다(2026-09-06). + target?: File, ): Promise { - const file = state.file; + const file = target ?? state.file; if (!file) return []; state.error = undefined; state.uploadStatus = "uploading"; diff --git a/B04_PreProcess/B04_PreProcess_Engine.py b/B04_PreProcess/B04_PreProcess_Engine.py index f1e48d4b..b94ef1c3 100644 --- a/B04_PreProcess/B04_PreProcess_Engine.py +++ b/B04_PreProcess/B04_PreProcess_Engine.py @@ -7,7 +7,7 @@ import json import logging import time -from collections.abc import Callable +from collections.abc import Callable, Sequence from pathlib import Path from typing import Any @@ -37,14 +37,22 @@ GROUND_POINT_SAMPLE_LIMIT = 500_000 GROUND_POINT_CACHE_VERSION = 2 -def _source_identity(las_path: Path) -> dict[str, Any]: - """입력 LAS의 정체성(이름·크기·수정시각)으로 캐시 세대를 식별한다 (PLAN B-2).""" - stat = las_path.stat() - return { - "filename": las_path.name, - "size_bytes": int(stat.st_size), - "mtime": float(stat.st_mtime), - } +def _source_identity(las_paths: list[Path]) -> dict[str, Any]: + """입력 지형 파일들의 정체성(이름·크기·수정시각)으로 캐시 세대를 식별한다 (PLAN B-2). + + 여러 장을 병합하므로 **한 장이라도 바뀌거나 늘고 줄면** 다시 계산해야 한다 + (2026-09-06 다중 입력). + """ + files = [ + { + "filename": path.name, + "size_bytes": int(path.stat().st_size), + "mtime": float(path.stat().st_mtime), + } + for path in sorted(las_paths, key=lambda item: item.name) + ] + # 한 장일 때는 옛 형식과 같은 모양을 유지한다 — 이미 만든 캐시를 헛되이 버리지 않는다. + return files[0] if len(files) == 1 else {"files": files} def _relative_to_project(project_root: Path, path: Path) -> str: @@ -108,7 +116,7 @@ def cache_ground_points( def run_surface_analysis( project_root: Path, - las_path: Path, + las_path: Path | Sequence[Path], *, source_filters: list[str] | None, methods: list[str], @@ -117,6 +125,9 @@ def run_surface_analysis( ) -> dict[str, Any]: """구조화→필터→모델 빌드를 수행하고 산출 메타데이터를 반환한다. + `las_path`는 지형 파일 한 장 또는 여러 장이다 — 여러 장이면 합친 범위로 한 벌을 + 만든다(2026-09-06 사용자 확정). + `source_filters`가 비면 입력 LAS를 보고 기본 필터를 정한다(자동 전처리 경로). 반환 dict: @@ -132,6 +143,9 @@ def run_surface_analysis( on_progress(percent, stage, message) total_started = time.monotonic() + las_paths = [las_path] if isinstance(las_path, Path) else [Path(item) for item in las_path] + if not las_paths: + raise ValueError("지형 파일이 없습니다.") stage_root = project_root / "B04_PreProcess" processed_dir = stage_root / "processed" models_dir = stage_root / "models" @@ -140,7 +154,7 @@ def run_surface_analysis( # 0. 입력 세대 검증: LAS가 바뀌었으면 모든 캐시를 재계산한다 (PLAN B-2) identity_path = processed_dir / "source_identity.json" - current_identity = _source_identity(las_path) + current_identity = _source_identity(las_paths) stored_identity: dict[str, Any] | None = None if identity_path.is_file(): try: @@ -154,10 +168,12 @@ def run_surface_analysis( if rebuild or not structured_path.is_file(): _report(10, "structurize", "LAS 구조화 중") step_started = time.monotonic() - structured_path = structurize_las(las_path, processed_dir) + structured_path = structurize_las(las_paths, processed_dir) atomic_write_json(identity_path, current_identity) logger.info( - "B04 LAS 구조화 완료: %s (%.1fs)", las_path.name, time.monotonic() - step_started + "B04 LAS 구조화 완료: %s (%.1fs)", + ", ".join(path.name for path in las_paths), + time.monotonic() - step_started, ) else: _report(10, "structurize", "구조화 캐시 재사용") @@ -254,7 +270,7 @@ def run_surface_analysis( "z": [float(bounds[2, 0]), float(bounds[2, 1])], } download_geodata( - project_root, processed_dir, las_bounds_dict, las_path.parent, rebuild, report=_report + project_root, processed_dir, las_bounds_dict, las_paths[0].parent, rebuild, report=_report ) # 3-4. 도엽등고선 3D 서피스 — LAS가 있어도 참고용으로 같이 만들어 영구저장한다 diff --git a/B04_PreProcess/B04_PreProcess_Engine_Structurize.py b/B04_PreProcess/B04_PreProcess_Engine_Structurize.py index f9e38b4b..42552faa 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_Structurize.py +++ b/B04_PreProcess/B04_PreProcess_Engine_Structurize.py @@ -1,82 +1,308 @@ -"""B04 LAS/LAZ 고속 구조화 엔진.""" +"""B04 LAS/LAZ 고속 구조화 엔진 — 여러 장을 한 벌로 병합한다 (2026-09-06 사용자 확정). +드론 라이다는 사업지가 넓으면 도엽별로 여러 장이 온다. 여기서 **합친 범위**로 한 벌을 +만들고, 뒤 단계(지면필터·모델·등고선·배수)는 받는 형식이 그대로라 손대지 않는다. + +점이 임계를 넘으면 **칸(기본 0.5m)마다 최저점 하나만** 남긴다(씨닝). 설계가 쓰는 격자가 +1m(지면필터 2m·CSF 천 1.5m)라 0.5m 는 설계보다 촘촘해 결과 표고가 사실상 같고, 30GB 두 +장이 메모리 29GB → 1.4GB 로 내려간다. 임계 아래면 원본 점을 그대로 쓴다 — 작은 자료의 +결과는 바뀌지 않는다. +""" + +import logging import os import tempfile -from collections.abc import Callable +from collections.abc import Callable, Sequence from pathlib import Path +from typing import Any import laspy import numpy as np from common_util.common_util_json import replace_with_retry -from config.config_system import SURFACE_DEFAULT_RGB_VALUE, SURFACE_LAS_CHUNK_SIZE +from config.config_system import ( + SURFACE_DEFAULT_RGB_VALUE, + SURFACE_LAS_CHUNK_SIZE, + SURFACE_MERGE_MAX_GAP_M, + SURFACE_THIN_CELL_SIZE_M, + SURFACE_THIN_MAX_CELLS, + SURFACE_THIN_TRIGGER_POINTS, +) + +logger = logging.getLogger(__name__) + +ProgressCallback = Callable[[int], None] +PathLike = str | Path +# 청크에서 점과 함께 옮기는 속성들 — 파일에 없으면 기본값이 남는다. +_ATTRIBUTES = ("intensity", "rgb", "return_number", "number_of_returns", "classification") + + +def _as_list(las_path: PathLike | Sequence[PathLike]) -> list[Path]: + if isinstance(las_path, (str, Path)): + return [Path(las_path)] + return [Path(item) for item in las_path] + + +def point_cloud_extent(path: PathLike) -> tuple[int, tuple[float, float, float, float]]: + """머리글만 읽어 점 수와 XY 범위를 돌려준다 — 파일 크기와 무관하게 즉시 끝난다.""" + with laspy.open(Path(path)) as las_file: + header = las_file.header + return int(header.point_count), ( + float(header.mins[0]), + float(header.mins[1]), + float(header.maxs[0]), + float(header.maxs[1]), + ) + + +def merge_gap_error( + paths: Sequence[PathLike], gap_m: float = SURFACE_MERGE_MAX_GAP_M +) -> str | None: + """서로 멀리 떨어진 지형 파일이 섞였는지 — 문제면 안내 문구, 없으면 None. + + 다른 사업지 파일이나 좌표계가 다른 파일이 섞이면 합친 범위가 통째로 어긋나 격자가 + 터진다. 도엽으로 나뉜 자료는 경계가 맞닿으므로 여유를 두고 **어느 파일과도 만나지 + 않는 파일**만 걸러 낸다. + """ + sources = _as_list(paths) + if len(sources) < 2: + return None + boxes = [(path, point_cloud_extent(path)[1]) for path in sources] + for index, (path, box) in enumerate(boxes): + near = any( + box[0] - gap_m <= other[2] + and other[0] - gap_m <= box[2] + and box[1] - gap_m <= other[3] + and other[1] - gap_m <= box[3] + for other_index, (_, other) in enumerate(boxes) + if other_index != index + ) + if not near: + return ( + f"지형 파일 「{path.name}」의 좌표가 다른 파일과 " + f"{int(gap_m):,}m 넘게 떨어져 있습니다." + " 같은 사업지의 파일인지, 좌표계가 같은지 확인해 주십시오." + ) + return None + + +class _Merged: + """합친 점을 담는 그릇 — 원본 유지형과 씨닝형이 같은 모양으로 낸다.""" + + def __init__(self, capacity: int) -> None: + self.xyz = np.empty((capacity, 3), dtype=np.float64) + self.intensity = np.zeros(capacity, dtype=np.uint16) + self.rgb = np.full((capacity, 3), SURFACE_DEFAULT_RGB_VALUE, dtype=np.uint8) + self.return_number = np.ones(capacity, dtype=np.uint8) + self.number_of_returns = np.ones(capacity, dtype=np.uint8) + self.classification = np.zeros(capacity, dtype=np.uint8) + self.size = 0 + + def arrays(self) -> dict[str, np.ndarray]: + end = self.size + return { + "xyz": self.xyz[:end], + "intensity": self.intensity[:end], + "rgb": self.rgb[:end], + "return_number": self.return_number[:end], + "number_of_returns": self.number_of_returns[:end], + "classification": self.classification[:end], + } + + def append(self, columns: dict[str, np.ndarray]) -> None: + count = len(columns["x"]) + section = slice(self.size, self.size + count) + self.xyz[section, 0] = columns["x"] + self.xyz[section, 1] = columns["y"] + self.xyz[section, 2] = columns["z"] + for key in _ATTRIBUTES: + if key in columns: + getattr(self, key)[section] = columns[key] + self.size += count + + +def _chunk_columns(chunk: Any, dimensions: set[str]) -> dict[str, np.ndarray]: + """청크에서 쓸 값만 꺼낸다. 파일에 없는 항목은 키를 빼서 기본값이 남게 한다.""" + columns: dict[str, np.ndarray] = { + "x": np.asarray(chunk.x, dtype=np.float64), + "y": np.asarray(chunk.y, dtype=np.float64), + "z": np.asarray(chunk.z, dtype=np.float64), + } + if "intensity" in dimensions: + columns["intensity"] = np.asarray(chunk.intensity, dtype=np.uint16) + if {"red", "green", "blue"}.issubset(dimensions): + colors = np.stack( + [ + np.asarray(chunk.red, dtype=np.float64), + np.asarray(chunk.green, dtype=np.float64), + np.asarray(chunk.blue, dtype=np.float64), + ], + axis=1, + ) + if colors.size and float(colors.max()) > 255.0: + colors /= 256.0 + columns["rgb"] = colors.clip(0, 255).astype(np.uint8) + if {"return_number", "number_of_returns"}.issubset(dimensions): + columns["return_number"] = np.asarray(chunk.return_number, dtype=np.uint8) + columns["number_of_returns"] = np.asarray(chunk.number_of_returns, dtype=np.uint8) + if "classification" in dimensions: + columns["classification"] = np.asarray(chunk.classification, dtype=np.uint8) + return columns + + +class _ThinGrid: + """씨닝형 — **지면 분류점은 전부** 남기고, 나머지는 칸마다 최저점 하나만 남긴다. + + 설계 지표면을 만드는 것은 지면점이다(업체가 분류해 준 ASPRS class 2). 그 점을 하나도 + 버리지 않으므로 **지면 결과는 씨닝 전과 완전히 같다**(2026-09-06 용화 실측: 1m 지면 + 격자 141,969칸 전부 표고 차이 0). 지면점은 원본의 2~3%뿐이라 남겨도 가볍다. + + 나머지(수목·구조물·잡음)는 칸마다 최저점만 남긴다 — 분류가 없는 자료에서 CSF·PMF가 + 지면을 찾을 밑그림으로 충분하다(CSF 천 간격 1.5m > 칸 0.5m). + + 한 청크 안에서 같은 칸이 여러 번 나오면 뒤에 쓴 값이 이겨 최저점이 아니게 된다. + 그래서 청크를 (칸, 표고)로 정렬해 **칸마다 첫 점**만 골라 낸 뒤 격자와 견준다. + """ + + #: ASPRS 지면 분류 코드. + GROUND_CLASS = 2 + + def __init__(self, bounds: np.ndarray, cell_size: float) -> None: + self.cell_size = cell_size + self.x_min = float(bounds[0, 0]) + self.y_min = float(bounds[1, 0]) + self.width = int(np.ceil((float(bounds[0, 1]) - self.x_min) / cell_size)) + 1 + self.height = int(np.ceil((float(bounds[1, 1]) - self.y_min) / cell_size)) + 1 + cells = self.width * self.height + if cells > SURFACE_THIN_MAX_CELLS: + raise ValueError( + "지형 자료의 합친 범위가 너무 넓습니다." + " 같은 사업지의 파일인지, 좌표계가 같은지 확인해 주십시오." + ) + self.best_z = np.full(cells, np.inf, dtype=np.float64) + self.x = np.zeros(cells, dtype=np.float64) + self.y = np.zeros(cells, dtype=np.float64) + self.intensity = np.zeros(cells, dtype=np.uint16) + self.rgb = np.full((cells, 3), SURFACE_DEFAULT_RGB_VALUE, dtype=np.uint8) + self.return_number = np.ones(cells, dtype=np.uint8) + self.number_of_returns = np.ones(cells, dtype=np.uint8) + self.classification = np.zeros(cells, dtype=np.uint8) + # 그대로 남길 지면점 — 청크마다 모아 두었다가 마지막에 잇는다. + self.ground: list[dict[str, np.ndarray]] = [] + + def add(self, columns: dict[str, np.ndarray]) -> None: + classification = columns.get("classification") + if classification is not None: + is_ground = classification == self.GROUND_CLASS + if is_ground.any(): + self.ground.append({key: value[is_ground] for key, value in columns.items()}) + keep = ~is_ground + columns = {key: value[keep] for key, value in columns.items()} + x, y, z = columns["x"], columns["y"], columns["z"] + if not len(x): + return + grid_x = np.clip(((x - self.x_min) / self.cell_size).astype(np.int64), 0, self.width - 1) + grid_y = np.clip(((y - self.y_min) / self.cell_size).astype(np.int64), 0, self.height - 1) + cell = grid_y * self.width + grid_x + order = np.lexsort((z, cell)) + sorted_cell = cell[order] + first = np.ones(len(order), dtype=bool) + first[1:] = sorted_cell[1:] != sorted_cell[:-1] + candidate = order[first] + candidate_cell = cell[candidate] + better = z[candidate] < self.best_z[candidate_cell] + chosen = candidate[better] + target = candidate_cell[better] + self.best_z[target] = z[chosen] + self.x[target] = x[chosen] + self.y[target] = y[chosen] + for key in _ATTRIBUTES: + if key in columns: + getattr(self, key)[target] = columns[key][chosen] + + def collect(self) -> _Merged: + occupied = np.flatnonzero(np.isfinite(self.best_z)) + ground_count = sum(len(item["x"]) for item in self.ground) + merged = _Merged(len(occupied) + ground_count) + merged.xyz[: len(occupied), 0] = self.x[occupied] + merged.xyz[: len(occupied), 1] = self.y[occupied] + merged.xyz[: len(occupied), 2] = self.best_z[occupied] + for key in _ATTRIBUTES: + getattr(merged, key)[: len(occupied)] = getattr(self, key)[occupied] + merged.size = len(occupied) + for item in self.ground: + merged.append(item) + return merged + + +def _headers(sources: list[Path]) -> tuple[int, np.ndarray, bool]: + """전체 점 수·합친 범위(3x2)·색 보유 여부를 머리글만 읽어 구한다.""" + total = 0 + has_rgb = False + mins = np.full(3, np.inf, dtype=np.float64) + maxs = np.full(3, -np.inf, dtype=np.float64) + for source in sources: + with laspy.open(source) as las_file: + header = las_file.header + total += int(header.point_count) + mins = np.minimum(mins, np.asarray(header.mins, dtype=np.float64)) + maxs = np.maximum(maxs, np.asarray(header.maxs, dtype=np.float64)) + dimensions = set(header.point_format.dimension_names) + has_rgb = has_rgb or {"red", "green", "blue"}.issubset(dimensions) + if not np.isfinite(mins).all(): + mins = np.zeros(3, dtype=np.float64) + maxs = np.zeros(3, dtype=np.float64) + return total, np.column_stack((mins, maxs)), has_rgb + + +def _merge_sources( + sources: list[Path], + total_points: int, + bounds: np.ndarray, + thin: bool, + progress_callback: ProgressCallback | None, +) -> _Merged: + grid = _ThinGrid(bounds, SURFACE_THIN_CELL_SIZE_M) if thin else None + merged = _Merged(total_points) if grid is None else None + done = 0 + for source in sources: + with laspy.open(source) as las_file: + dimensions = set(las_file.header.point_format.dimension_names) + for chunk in las_file.chunk_iterator(SURFACE_LAS_CHUNK_SIZE): + columns = _chunk_columns(chunk, dimensions) + if grid is not None: + grid.add(columns) + else: + merged.append(columns) + done += len(columns["x"]) + if progress_callback: + progress_callback(int(done / total_points * 100) if total_points else 100) + return grid.collect() if grid is not None else merged def structurize_las( - las_path: str | Path, + las_path: PathLike | Sequence[PathLike], output_dir: str | Path, - progress_callback: Callable[[int], None] | None = None, + progress_callback: ProgressCallback | None = None, ) -> Path: - """LAS/LAZ 속성을 청크로 읽어 B04 structured.npz로 원자적 저장한다.""" - source = Path(las_path) + """지형 파일 한 장 또는 여러 장을 청크로 읽어 B04 structured.npz로 원자적 저장한다.""" + sources = _as_list(las_path) + if not sources: + raise ValueError("구조화할 지형 파일이 없습니다.") target_dir = Path(output_dir) target_dir.mkdir(parents=True, exist_ok=True) target = target_dir / "structured.npz" - with laspy.open(source) as las_file: - header = las_file.header - total_points = int(header.point_count) - point_format = header.point_format - dimensions = set(point_format.dimension_names) - has_rgb = {"red", "green", "blue"}.issubset(dimensions) - has_intensity = "intensity" in dimensions - has_returns = {"return_number", "number_of_returns"}.issubset(dimensions) - has_classification = "classification" in dimensions - bounds = np.array( - [ - [float(header.mins[0]), float(header.maxs[0])], - [float(header.mins[1]), float(header.maxs[1])], - [float(header.mins[2]), float(header.maxs[2])], - ], - dtype=np.float64, - ) - - xyz = np.empty((total_points, 3), dtype=np.float64) - intensity = np.zeros(total_points, dtype=np.uint16) - rgb = np.full((total_points, 3), SURFACE_DEFAULT_RGB_VALUE, dtype=np.uint8) - return_number = np.ones(total_points, dtype=np.uint8) - number_of_returns = np.ones(total_points, dtype=np.uint8) - classification = np.zeros(total_points, dtype=np.uint8) - - offset = 0 - for chunk in las_file.chunk_iterator(SURFACE_LAS_CHUNK_SIZE): - chunk_size = len(chunk) - section = slice(offset, offset + chunk_size) - xyz[section, 0] = np.asarray(chunk.x, dtype=np.float64) - xyz[section, 1] = np.asarray(chunk.y, dtype=np.float64) - xyz[section, 2] = np.asarray(chunk.z, dtype=np.float64) - if has_intensity: - intensity[section] = np.asarray(chunk.intensity, dtype=np.uint16) - if has_rgb: - colors = np.stack( - [ - np.asarray(chunk.red, dtype=np.float64), - np.asarray(chunk.green, dtype=np.float64), - np.asarray(chunk.blue, dtype=np.float64), - ], - axis=1, - ) - if colors.size and float(colors.max()) > 255.0: - colors /= 256.0 - rgb[section] = colors.clip(0, 255).astype(np.uint8) - if has_returns: - return_number[section] = np.asarray(chunk.return_number, dtype=np.uint8) - number_of_returns[section] = np.asarray(chunk.number_of_returns, dtype=np.uint8) - if has_classification: - classification[section] = np.asarray(chunk.classification, dtype=np.uint8) - offset += chunk_size - if progress_callback: - progress_callback(int(offset / total_points * 100) if total_points else 100) + total_points, bounds, has_rgb = _headers(sources) + thin = total_points > SURFACE_THIN_TRIGGER_POINTS + merged = _merge_sources(sources, total_points, bounds, thin, progress_callback) + logger.info( + "B04 구조화: 파일 %d장 원본 %d점 → 저장 %d점 (씨닝 %s)", + len(sources), + total_points, + merged.size, + f"{SURFACE_THIN_CELL_SIZE_M}m 칸" if thin else "없음", + ) temporary_path: Path | None = None try: @@ -90,14 +316,12 @@ def structurize_las( temporary_path = Path(temporary.name) np.savez_compressed( temporary, - xyz=xyz, - intensity=intensity, - rgb=rgb, - return_number=return_number, - number_of_returns=number_of_returns, - classification=classification, + **merged.arrays(), bounds=bounds, - total_points=np.array([total_points], dtype=np.int64), + total_points=np.array([merged.size], dtype=np.int64), + source_point_count=np.array([total_points], dtype=np.int64), + source_file_count=np.array([len(sources)], dtype=np.int64), + thinned=np.array([int(thin)], dtype=np.int8), has_rgb=np.array([int(has_rgb)], dtype=np.int8), ) temporary.flush() @@ -109,6 +333,6 @@ def structurize_las( if temporary_path is not None: temporary_path.unlink(missing_ok=True) - if progress_callback and total_points == 0: + if progress_callback: progress_callback(100) return target diff --git a/B04_PreProcess/B04_PreProcess_Repository.py b/B04_PreProcess/B04_PreProcess_Repository.py index 231c4531..3e30cd68 100644 --- a/B04_PreProcess/B04_PreProcess_Repository.py +++ b/B04_PreProcess/B04_PreProcess_Repository.py @@ -6,7 +6,7 @@ terrain_layers(지형 레이어) 테이블에 메타데이터와 상대 경로 """ import json -from pathlib import PurePosixPath +from pathlib import Path, PurePosixPath from typing import Any from uuid import UUID @@ -230,6 +230,25 @@ async def list_project_point_cloud_inputs( ] +async def list_project_point_cloud_paths( + connection: aiomysql.Connection, project_id: UUID, project_root: Path +) -> list[Path]: + """전처리가 병합할 지형 파일 경로 목록 — 실제로 있는 파일만 (2026-09-06 다중 입력). + + 교체된 옛 행(`SUPERSEDED`)은 조회에서 이미 빠지므로, 지금 살아 있는 파일만 남는다. + """ + rows = await list_project_point_cloud_inputs(connection, project_id) + paths: list[Path] = [] + for row in rows: + raw = str(row.get("raw_file_path") or "") + if not raw: + continue + path = project_root / Path(raw) + if path.is_file() and path not in paths: + paths.append(path) + return paths + + async def list_surface_models( connection: aiomysql.Connection, project_id: UUID ) -> list[dict[str, Any]]: diff --git a/B04_PreProcess/B04_PreProcess_Router.py b/B04_PreProcess/B04_PreProcess_Router.py index 6de4818d..ad43bcaa 100644 --- a/B04_PreProcess/B04_PreProcess_Router.py +++ b/B04_PreProcess/B04_PreProcess_Router.py @@ -26,6 +26,7 @@ from B04_PreProcess.B04_PreProcess_Repository import ( clear_confirmed_surface_models, get_input_file, list_project_point_cloud_inputs, + list_project_point_cloud_paths, list_surface_models, save_surface_analysis_to_db, ) @@ -117,6 +118,10 @@ async def analyze_surface( status_code=404, content={"status": "error", "message": "원본 LAS 파일을 찾을 수 없습니다."}, ) + # 지형 파일이 여러 장이면 합쳐서 다시 만든다 — 자동 전처리와 같은 대상을 쓴다. + terrain_paths = await list_project_point_cloud_paths( + connection, project_id, project_root + ) # 분석 시작 진행률 기록 (별도 스레드의 콜백은 파일에만 원자적 기록). write_surface_progress(project_root, 5, "analyzing", "WF1 분석을 시작합니다.") @@ -128,7 +133,7 @@ async def analyze_surface( result = await asyncio.to_thread( run_surface_analysis, project_root, - las_path, + terrain_paths or [las_path], source_filters=source_filters, methods=methods, force=request.force, diff --git a/config/config_system_terrain.py b/config/config_system_terrain.py index 2d9c66bd..113e576e 100644 --- a/config/config_system_terrain.py +++ b/config/config_system_terrain.py @@ -14,6 +14,20 @@ MESH_SMOOTHING_ITERATIONS = int(os.getenv("MESH_SMOOTHING_ITERATIONS", "0")) SURFACE_LAS_CHUNK_SIZE = int(os.getenv("SURFACE_LAS_CHUNK_SIZE", "500000")) SURFACE_DEFAULT_RGB_VALUE = int(os.getenv("SURFACE_DEFAULT_RGB_VALUE", "128")) SURFACE_GRID_CELL_SIZE_M = float(os.getenv("SURFACE_GRID_CELL_SIZE_M", "2.0")) + +# 지형 파일 여러 장 병합 (2026-09-06 사용자 확정) +# ───────────────────────────────────────────────────────────────────────── +# 다른 사업지 파일이 섞여 들어오면 합친 범위가 통째로 어긋난다 — 어느 파일과도 이 +# 거리 안에서 만나지 않는 파일은 업로드에서 막는다. 임도는 길어도 2~3km 라 5km 면 +# 넉넉하다(2026-09-06 사용자 확정). 도엽이 나뉜 자료는 경계가 맞닿아 걸리지 않는다. +SURFACE_MERGE_MAX_GAP_M = float(os.getenv("SURFACE_MERGE_MAX_GAP_M", "5000")) +# 점이 이 수를 넘으면 아래 칸 크기로 씨닝한다(칸마다 최저점 하나). 넘지 않으면 원본 +# 그대로 쓴다 — 작은 자료의 결과는 바뀌지 않는다. 1억점 = 메모리 약 3.2GB. +SURFACE_THIN_TRIGGER_POINTS = int(os.getenv("SURFACE_THIN_TRIGGER_POINTS", "100000000")) +# 씨닝 칸 크기(m). 설계 격자 1m·지면필터 2m·CSF 천 1.5m 보다 촘촘해야 결과가 안 변한다. +SURFACE_THIN_CELL_SIZE_M = float(os.getenv("SURFACE_THIN_CELL_SIZE_M", "0.5")) +# 씨닝 격자가 이보다 많아지면 범위가 비정상이다(먼 파일이 섞였거나 좌표계 불일치). +SURFACE_THIN_MAX_CELLS = int(os.getenv("SURFACE_THIN_MAX_CELLS", "400000000")) SURFACE_GRID_HEIGHT_THRESHOLD_M = float(os.getenv("SURFACE_GRID_HEIGHT_THRESHOLD_M", "1.5")) # CSF (Cloth Simulation Filter) 지면 분류 파라미터 From 861068cbf48ef6ee159bf13be3afc1d30d5b0e46 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 6 Sep 2026 17:46:09 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat(B02):=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=EB=AA=85=EC=9D=84=20=EA=B3=A0=EC=B9=A0=20=EC=88=98=20?= =?UTF-8?q?=EC=9E=88=EB=8A=94=20=EC=9E=85=EB=A0=A5=20=EC=B9=B8=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=84=ED=99=98=C2=B7=EB=B9=84=EA=B3=A0=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 「저장될 이름」 미리보기 줄을 없애고 프로젝트명 입력 칸 하나로 통합 — 사업연도·사업지역· 임도종류로 자동 채우되 사용자가 사이나 끝에 글을 넣거나 자동 부분을 고칠 수 있음 - 손댄 뒤 위 세 항목을 바꾸면 해당 조각만 갈아 끼워 사용자가 넣은 글은 보존 - 비고 칸을 노선 시작 누가거리 앞으로 이동 Co-Authored-By: Claude Opus 5 (1M context) --- B02_ProjRegister/B02_ProjRegister_UI_Page.ts | 63 +++++++++++++------ .../B02_ProjRegister_UI_Style.css | 7 --- 2 files changed, 44 insertions(+), 26 deletions(-) diff --git a/B02_ProjRegister/B02_ProjRegister_UI_Page.ts b/B02_ProjRegister/B02_ProjRegister_UI_Page.ts index 935e3dba..5ec4ca18 100644 --- a/B02_ProjRegister/B02_ProjRegister_UI_Page.ts +++ b/B02_ProjRegister/B02_ProjRegister_UI_Page.ts @@ -54,15 +54,14 @@ export function renderB02ProjRegister(root: HTMLElement): void { header.append(title, subtitle); // 입력 필드 - // 프로젝트명은 사업연도 + 사업지역 + 임도종류 + 직접 입력값을 이어 붙여 만든다 - // (2026-09-06 사용자 확정). 아래 미리보기 줄이 저장될 이름 그대로다. + // 프로젝트명은 사업연도 + 사업지역 + 임도종류로 **자동으로 채워지되 고칠 수 있는 칸**이다 + // (2026-09-06 사용자 지시 — 종전에는 미리보기 줄이었다). 사용자가 사이나 끝에 글을 넣어도 + // 위 세 항목을 다시 바꾸면 그 조각만 갈아 끼워 손댄 글이 살아남는다. const nameField = createInputField({ - label: "프로젝트명 (직접 입력 부분)", - placeholder: "예: 가리왕산지구", + label: "프로젝트명", + placeholder: "예: 2026 가리왕산 간선임도 1구간", required: true, }); - const namePreview = document.createElement("p"); - namePreview.className = "b02-proj__preview"; const regionField = createInputField({ label: L("B02_Proj_Field_Region"), placeholder: L("B02_Proj_Field_Region_Placeholder"), @@ -164,15 +163,40 @@ export function renderB02ProjRegister(root: HTMLElement): void { const roadTypeText = (): string => roadTypeField.select.options[roadTypeField.select.selectedIndex]?.textContent ?? ""; - const composedName = (): string => - [ - yearField.input.value.trim(), - regionField.input.value.trim(), - roadTypeText(), - nameField.input.value.trim(), - ] - .filter((part) => part.length > 0) - .join(" "); + /** 이름을 이루는 자동 조각 — 사업연도·사업지역·임도종류 순서다. */ + const nameParts = (): string[] => [ + yearField.input.value.trim(), + regionField.input.value.trim(), + roadTypeText(), + ]; + let lastParts = nameParts(); + let nameTouched = false; + nameField.input.addEventListener("input", () => { + nameTouched = true; + }); + /** + * 위 세 항목이 바뀌면 이름 칸을 따라 고친다. + * + * 아직 손대지 않은 칸은 통째로 다시 쓰고, 사용자가 고친 뒤에는 **바뀐 조각만 갈아 끼운다** + * — 사이나 끝에 넣은 글이 지워지지 않게 하기 위해서다. 지웠던 조각은 되살리지 않는다. + */ + const syncName = (): void => { + const next = nameParts(); + if (!nameTouched) { + nameField.input.value = next.filter((part) => part.length > 0).join(" "); + } else { + let text = nameField.input.value; + next.forEach((part, index) => { + const before = lastParts[index]; + if (before && part && part !== before && text.includes(before)) { + text = text.replace(before, part); + } + }); + nameField.input.value = text; + } + lastParts = next; + }; + const composedName = (): string => nameField.input.value.trim(); const routeLength = (): number | null => { const start = isBlank(routeStartField.input.value) ? null @@ -185,14 +209,15 @@ export function renderB02ProjRegister(root: HTMLElement): void { return end > from ? end - from : null; }; const refresh = (): void => { - namePreview.textContent = `저장될 이름: ${composedName() || "(입력 대기)"}`; + syncName(); const length = routeLength(); routeEndField.root.querySelector(".ui-field__label")!.textContent = length === null ? "노선 종료 누가거리 (m)" : `노선 종료 누가거리 (m) — 연장 ${length.toFixed(1)}m`; }; - for (const field of [nameField, regionField, yearField, routeStartField, routeEndField]) { + // 이름 칸 자신은 여기서 제외한다 — 스스로 고치는 도중에 값을 되돌리면 안 된다. + for (const field of [regionField, yearField, routeStartField, routeEndField]) { field.input.addEventListener("input", refresh); } roadTypeField.select.addEventListener("change", refresh); @@ -318,10 +343,10 @@ export function renderB02ProjRegister(root: HTMLElement): void { regionField.root, roadTypeField.root, nameField.root, - namePreview, + // 비고는 노선 구간 앞에 둔다 (2026-09-06 사용자 지시). + memoField.root, routeStartField.root, routeEndField.root, - memoField.root, clientOrgField.root, designDateField.root, pmField.root, diff --git a/B02_ProjRegister/B02_ProjRegister_UI_Style.css b/B02_ProjRegister/B02_ProjRegister_UI_Style.css index 97e37525..a1b10774 100644 --- a/B02_ProjRegister/B02_ProjRegister_UI_Style.css +++ b/B02_ProjRegister/B02_ProjRegister_UI_Style.css @@ -64,13 +64,6 @@ } } -/* 조합된 프로젝트명 미리보기 — 저장될 이름 그대로 보인다 (2026-09-06). */ -.b02-proj__preview { - margin: calc(-1 * var(--spacing-8)) 0 0; - color: var(--color-text-secondary); - font-size: var(--text-caption); -} - /* 취소·등록 버튼 줄 */ .b02-proj__actions { display: flex; From 489a53f25f632a22fb8d1da8a92f436bbc01863e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 6 Sep 2026 17:49:37 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat(B02):=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=EB=93=B1=EB=A1=9D=EC=97=90=EC=84=9C=20=ED=9A=8C?= =?UTF-8?q?=EC=82=AC=20=EB=A1=9C=EA=B3=A0=20=EC=B9=B8=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 프로젝트가 이미 회사에 매여 있어 따로 고를 이유가 없음 — 도면 표제란은 `COALESCE(프로젝트 로고, 회사 로고)` 로 조회하므로 회사 로고가 그대로 쓰임. Co-Authored-By: Claude Opus 5 (1M context) --- B02_ProjRegister/B02_ProjRegister_UI_Page.ts | 38 +++----------------- 1 file changed, 5 insertions(+), 33 deletions(-) diff --git a/B02_ProjRegister/B02_ProjRegister_UI_Page.ts b/B02_ProjRegister/B02_ProjRegister_UI_Page.ts index 5ec4ca18..83e89830 100644 --- a/B02_ProjRegister/B02_ProjRegister_UI_Page.ts +++ b/B02_ProjRegister/B02_ProjRegister_UI_Page.ts @@ -20,13 +20,7 @@ import { showToast, } from "@ui/ui_template_elements"; import { isBlank } from "@util/common_util_validate"; -import { - fetchCompanyAssets, - fetchCompanyMembers, - fetchDashboardMe, - fetchUserCompany, -} from "../B01_Dashboard/B01_Dashboard_Api_Fetch"; -import { createAssetField } from "../B01_Dashboard/B01_Dashboard_UI_AssetPicker"; +import { fetchCompanyMembers, fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch"; import { navigateTo } from "../A00_Common/router"; import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; import "./B02_ProjRegister_UI_Style.css"; @@ -119,19 +113,12 @@ export function renderB02ProjRegister(root: HTMLElement): void { const fieldLeadField = person("분야별책임자 (도면 표제란)"); const designerField = person("설계자 (도면 표제란)"); const personSelects = [pmField.select, fieldLeadField.select, designerField.select]; - // 로고 칸은 회사 자산을 받아야 세울 수 있어 자리만 먼저 잡는다. - const logoSlot = document.createElement("div"); - let logoValue: () => number | null = () => null; + // 로고 칸은 두지 않는다 (2026-09-06 사용자 지시) — 프로젝트가 이미 회사에 매여 있어 + // 도면은 회사 로고를 그대로 쓴다(표제란 조회가 `COALESCE(프로젝트, 회사)`). void (async () => { - const [me, company] = await Promise.all([ - fetchDashboardMe(), - fetchUserCompany().catch(() => null), - ]); - const [members, assets] = await Promise.all([ - fetchCompanyMembers().catch(() => []), - fetchCompanyAssets().catch(() => []), - ]); + const me = await fetchDashboardMe(); + const members = await fetchCompanyMembers().catch(() => []); const memberText = (member: (typeof members)[number]) => member.position ? `${member.name} (${member.position})` : member.name; const addOption = (select: HTMLSelectElement, value: string, text: string) => { @@ -146,19 +133,6 @@ export function renderB02ProjRegister(root: HTMLElement): void { // 「신규 등록…」은 없앴다 — 팀원으로 등록한 뒤 고른다. if (members.some((member) => member.id === me.id)) select.value = String(me.id); } - if (company) { - // 회사 대표 로고가 기본값 — 프로젝트마다 다른 로고를 쓰면 여기서 바꾼다. - const logo = createAssetField( - "회사 로고 (도면 표제란)", - "LOGO", - assets, - company.logo_asset_id ?? null, - company.id, - me, - ); - logoValue = logo.value; - logoSlot.append(logo.root); - } })(); const roadTypeText = (): string => @@ -309,7 +283,6 @@ export function renderB02ProjRegister(root: HTMLElement): void { pm_user_id: idOrNull(pmField.select), field_lead_user_id: idOrNull(fieldLeadField.select), designer_user_id: idOrNull(designerField.select), - logo_asset_id: logoValue(), }), }); @@ -352,7 +325,6 @@ export function renderB02ProjRegister(root: HTMLElement): void { pmField.root, fieldLeadField.root, designerField.root, - logoSlot, ); const actions = document.createElement("div"); From 4b5a630f12ba3c097debfda7f7771b666e77ed29 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 6 Sep 2026 17:59:22 +0900 Subject: [PATCH 5/8] =?UTF-8?q?fix(B02=C2=B7A00):=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EC=A0=9D=ED=8A=B8=EB=AA=85=20=EC=97=B0=EB=8F=99=20=EB=81=8A?= =?UTF-8?q?=EA=B9=80=C2=B7=ED=95=AD=EB=AA=A9=20=EC=88=9C=EC=84=9C=C2=B7?= =?UTF-8?q?=EA=B3=B5=EC=9A=A9=20=ED=85=9C=ED=94=8C=EB=A6=BF=C2=B7=ED=97=A4?= =?UTF-8?q?=EB=8D=94=20=EB=B2=84=ED=8A=BC=20=EA=B0=84=EA=B2=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 프로젝트명 조합기를 글자 단위 소유 추적으로 다시 만듦(`B02_ProjRegister_UI_Name.ts`) — 자동 글자 사이에 글을 끼워 넣어도 사업연도·사업지역·임도종류 연동이 끊기지 않음 (실측: 「울진군 금강송면」의 「금강송면」 앞 삽입 후에도 지역·종류·연도 변경이 반영) - 비고 칸을 맨 마지막으로 이동 - 제목·여백·버튼 줄을 공용 템플릿(`createGeneralLayout` · `ui-general-block__actions`)으로 전환, 화면 전용 제목 스타일 제거 - 헤더의 사용자명·로그아웃(로그인·회원가입) 버튼이 붙어 보이던 것 간격 부여 Co-Authored-By: Claude Opus 5 (1M context) --- A00_Common/app_shell.ts | 7 ++ B02_ProjRegister/B02_ProjRegister_UI_Name.ts | 96 +++++++++++++++++++ B02_ProjRegister/B02_ProjRegister_UI_Page.ts | 71 +++++--------- .../B02_ProjRegister_UI_Style.css | 37 +------ 4 files changed, 130 insertions(+), 81 deletions(-) create mode 100644 B02_ProjRegister/B02_ProjRegister_UI_Name.ts diff --git a/A00_Common/app_shell.ts b/A00_Common/app_shell.ts index 37ed31cb..0266dab6 100644 --- a/A00_Common/app_shell.ts +++ b/A00_Common/app_shell.ts @@ -251,6 +251,13 @@ const SHELL_CSS = ` align-items: center; gap: var(--spacing-8); } +/* 사용자명·로그아웃(또는 로그인·회원가입) 버튼이 서로 붙어 보이던 것을 띄운다 + (2026-09-06 사용자 지적) — 이 칸은 나중에 채워지므로 자체 간격이 필요하다. */ +.app-actions__auth { + display: flex; + align-items: center; + gap: var(--spacing-8); +} .app-outlet { min-height: calc(100vh - 64px - 56px); } diff --git a/B02_ProjRegister/B02_ProjRegister_UI_Name.ts b/B02_ProjRegister/B02_ProjRegister_UI_Name.ts new file mode 100644 index 00000000..cc4db59b --- /dev/null +++ b/B02_ProjRegister/B02_ProjRegister_UI_Name.ts @@ -0,0 +1,96 @@ +/* ============================================================================= + * B02_ProjRegister_UI_Name.ts + * 프로젝트명 조합기 — 사업연도·사업지역·임도종류를 이름 칸에 넣어 두고, 사용자가 그 글을 + * 고쳐도 위 항목과의 연결이 끊기지 않게 지킨다 (2026-09-06 사용자 지시). + * + * 글자마다 「누가 넣은 글자인가」를 같이 들고 다닌다. 문자열을 찾아 바꾸던 종전 방식은 + * 사용자가 자동 글자 사이에 한 글자만 끼워 넣어도 찾기가 실패해 연동이 통째로 끊겼다 + * (실측: 사업지역 「울진군 금강송면」의 「금강송면」 앞에 글자 삽입). + * + * 규칙 + * - 사용자가 이름을 고치기 전에는 위 세 항목으로 통째로 다시 쓴다. + * - 고친 뒤에는 **그 항목이 차지한 구간만** 새 값으로 갈아 끼운다. 사용자가 그 구간 + * 밖(사이·끝)에 넣은 글은 그대로 남는다. + * - 사용자가 지워 버린 항목은 되살리지 않는다 — 지운 것도 사용자의 뜻이다. + * ========================================================================== */ + +/** 이름을 이루는 자동 조각의 종류. 순서가 곧 이름에 놓이는 차례다. */ +export type NameSlot = "year" | "region" | "type"; + +const SLOTS: readonly NameSlot[] = ["year", "region", "type"]; + +export type NameParts = Record; + +export interface NameComposer { + /** 지금 이름. */ + text(): string; + /** 사용자가 이름 칸에 친 글을 반영한다. */ + edit(next: string): void; + /** 위 항목이 바뀌었을 때 — 갈아 끼운 이름을 돌려준다. */ + update(parts: NameParts): string; +} + +export function createNameComposer(): NameComposer { + let text = ""; + /** 글자마다 그 글자를 넣은 항목(사용자가 친 글자는 null). `text`와 길이가 같다. */ + let owners: (NameSlot | null)[] = []; + let touched = false; + + const fill = (slot: NameSlot | null, count: number): (NameSlot | null)[] => + Array.from({ length: count }, () => slot); + + const rebuild = (parts: NameParts): void => { + text = ""; + owners = []; + for (const slot of SLOTS) { + const value = parts[slot].trim(); + if (!value) continue; + if (text.length > 0) { + text += " "; + owners.push(null); + } + text += value; + owners.push(...fill(slot, value.length)); + } + }; + + return { + text: () => text, + + edit(next: string): void { + touched = true; + // 앞뒤로 그대로인 부분을 뺀 **바뀐 구간**만 계산한다 — 어디를 고쳤는지 알아야 + // 나머지 글자의 주인이 밀리지 않는다. + let head = 0; + while (head < text.length && head < next.length && text[head] === next[head]) head += 1; + let tail = 0; + while ( + tail < text.length - head && + tail < next.length - head && + text[text.length - 1 - tail] === next[next.length - 1 - tail] + ) { + tail += 1; + } + const removed = text.length - tail - head; + const inserted = next.slice(head, next.length - tail); + owners.splice(head, removed, ...fill(null, inserted.length)); + text = next; + }, + + update(parts: NameParts): string { + if (!touched) { + rebuild(parts); + return text; + } + for (const slot of SLOTS) { + const first = owners.indexOf(slot); + if (first < 0) continue; // 사용자가 지운 항목은 되살리지 않는다. + const last = owners.lastIndexOf(slot); + const value = parts[slot].trim(); + text = text.slice(0, first) + value + text.slice(last + 1); + owners.splice(first, last - first + 1, ...fill(slot, value.length)); + } + return text; + }, + }; +} diff --git a/B02_ProjRegister/B02_ProjRegister_UI_Page.ts b/B02_ProjRegister/B02_ProjRegister_UI_Page.ts index 83e89830..68faf689 100644 --- a/B02_ProjRegister/B02_ProjRegister_UI_Page.ts +++ b/B02_ProjRegister/B02_ProjRegister_UI_Page.ts @@ -19,8 +19,10 @@ import { showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; +import { createGeneralLayout } from "@ui/ui_template_general_layout"; import { isBlank } from "@util/common_util_validate"; import { fetchCompanyMembers, fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch"; +import { createNameComposer, type NameParts } from "./B02_ProjRegister_UI_Name"; import { navigateTo } from "../A00_Common/router"; import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; import "./B02_ProjRegister_UI_Style.css"; @@ -34,19 +36,6 @@ function L(key: keyof typeof ui_locales): string { * 페이지 진입점 * -------------------------------------------------------------------------- */ export function renderB02ProjRegister(root: HTMLElement): void { - const page = document.createElement("div"); - page.className = "b02-proj"; - - const header = document.createElement("div"); - header.className = "b02-proj__header"; - const title = document.createElement("h1"); - title.className = "b02-proj__title"; - title.textContent = L("B02_Proj_Title"); - const subtitle = document.createElement("p"); - subtitle.className = "b02-proj__subtitle"; - subtitle.textContent = L("B02_Proj_Subtitle"); - header.append(title, subtitle); - // 입력 필드 // 프로젝트명은 사업연도 + 사업지역 + 임도종류로 **자동으로 채워지되 고칠 수 있는 칸**이다 // (2026-09-06 사용자 지시 — 종전에는 미리보기 줄이었다). 사용자가 사이나 끝에 글을 넣어도 @@ -137,38 +126,18 @@ export function renderB02ProjRegister(root: HTMLElement): void { const roadTypeText = (): string => roadTypeField.select.options[roadTypeField.select.selectedIndex]?.textContent ?? ""; - /** 이름을 이루는 자동 조각 — 사업연도·사업지역·임도종류 순서다. */ - const nameParts = (): string[] => [ - yearField.input.value.trim(), - regionField.input.value.trim(), - roadTypeText(), - ]; - let lastParts = nameParts(); - let nameTouched = false; + /** 이름 조합기 — 글자마다 주인을 기억해 사용자가 사이에 글을 넣어도 연동이 안 끊긴다. */ + const nameComposer = createNameComposer(); + const nameParts = (): NameParts => ({ + year: yearField.input.value.trim(), + region: regionField.input.value.trim(), + type: roadTypeText(), + }); nameField.input.addEventListener("input", () => { - nameTouched = true; + nameComposer.edit(nameField.input.value); }); - /** - * 위 세 항목이 바뀌면 이름 칸을 따라 고친다. - * - * 아직 손대지 않은 칸은 통째로 다시 쓰고, 사용자가 고친 뒤에는 **바뀐 조각만 갈아 끼운다** - * — 사이나 끝에 넣은 글이 지워지지 않게 하기 위해서다. 지웠던 조각은 되살리지 않는다. - */ const syncName = (): void => { - const next = nameParts(); - if (!nameTouched) { - nameField.input.value = next.filter((part) => part.length > 0).join(" "); - } else { - let text = nameField.input.value; - next.forEach((part, index) => { - const before = lastParts[index]; - if (before && part && part !== before && text.includes(before)) { - text = text.replace(before, part); - } - }); - nameField.input.value = text; - } - lastParts = next; + nameField.input.value = nameComposer.update(nameParts()); }; const composedName = (): string => nameField.input.value.trim(); const routeLength = (): number | null => { @@ -202,7 +171,6 @@ export function renderB02ProjRegister(root: HTMLElement): void { variant: "filled", onClick: onB02_Proj_Submit_Click, }); - submitBtn.classList.add("b02-proj__submit"); const cancelBtn = createButton({ label: L("Common_Btn_Cancel"), variant: "ghost", @@ -316,8 +284,6 @@ export function renderB02ProjRegister(root: HTMLElement): void { regionField.root, roadTypeField.root, nameField.root, - // 비고는 노선 구간 앞에 둔다 (2026-09-06 사용자 지시). - memoField.root, routeStartField.root, routeEndField.root, clientOrgField.root, @@ -325,12 +291,21 @@ export function renderB02ProjRegister(root: HTMLElement): void { pmField.root, fieldLeadField.root, designerField.root, + // 비고는 맨 끝 (2026-09-06 사용자 지시). + memoField.root, ); + // 제목·버튼 줄은 공용 템플릿을 따른다 (2026-09-06 사용자 지시) — 다른 화면과 같은 + // 제목 크기·여백을 쓰고, 버튼 줄은 `ui-general-block__actions` 로 세운다. const actions = document.createElement("div"); - actions.className = "b02-proj__actions"; + actions.className = "ui-general-block__actions b02-proj__actions"; actions.append(cancelBtn, submitBtn); const card = createCard({ body: [grid, actions], raised: true }); - page.append(header, card); - root.append(page); + const layout = createGeneralLayout({ + pageClass: "b02-proj", + title: L("B02_Proj_Title"), + subtitle: L("B02_Proj_Subtitle"), + content: card, + }); + root.append(layout.root); } diff --git a/B02_ProjRegister/B02_ProjRegister_UI_Style.css b/B02_ProjRegister/B02_ProjRegister_UI_Style.css index a1b10774..f83aa46a 100644 --- a/B02_ProjRegister/B02_ProjRegister_UI_Style.css +++ b/B02_ProjRegister/B02_ProjRegister_UI_Style.css @@ -3,31 +3,8 @@ * 프로젝트 등록 페이지 전용 스타일 (theme.css 변수만 사용) * ========================================================================== */ -.b02-proj { - max-width: 720px; - margin: 0 auto; - padding: var(--spacing-40) var(--spacing-24); - display: flex; - flex-direction: column; - gap: var(--spacing-24); -} - -.b02-proj__header { - display: flex; - flex-direction: column; - gap: var(--spacing-8); -} - -.b02-proj__title { - font-family: var(--font-display); - font-size: var(--text-heading); - color: var(--color-plum-velvet); -} - -.b02-proj__subtitle { - font-size: var(--text-body-sm); - color: var(--color-text-muted); -} +/* 제목·여백·버튼 줄은 공용 템플릿(ui_template_general_layout)을 쓴다 — 여기서는 + 이 화면에만 필요한 것(2열 그리드·셀렉트 화살표·버튼 오른쪽 정렬)만 둔다. */ /* 2열 필드 그리드 (좁은 화면에서는 1열) */ .b02-proj__grid { @@ -53,20 +30,14 @@ padding-right: var(--spacing-32); } -.b02-proj__submit { - align-self: flex-start; - margin-top: var(--spacing-8); -} - @media (max-width: 640px) { .b02-proj__grid { grid-template-columns: 1fr; } } -/* 취소·등록 버튼 줄 */ +/* 취소·등록 버튼 줄 — 나머지는 공용 `.ui-general-block__actions` 가 맡는다. */ .b02-proj__actions { - display: flex; - gap: var(--spacing-8); justify-content: flex-end; + margin-top: var(--spacing-16); } From 2df959830bedf51f01fe3dac15a685661e048b09 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 6 Sep 2026 18:01:12 +0900 Subject: [PATCH 6/8] =?UTF-8?q?fix(B02):=20=EB=93=B1=EB=A1=9D=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=ED=8F=AD=EC=9D=84=20=EC=A2=85=EC=A0=84=20720px=20?= =?UTF-8?q?=EB=A1=9C=20=EB=90=98=EB=8F=8C=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 공용 템플릿으로 바꾸면서 페이지 기본 폭(--page-max-width)을 따라가 넓어졌음 — 템플릿은 그대로 쓰되 이 화면의 안쪽 폭만 720px 로 좁힘. Co-Authored-By: Claude Opus 5 (1M context) --- B02_ProjRegister/B02_ProjRegister_UI_Style.css | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/B02_ProjRegister/B02_ProjRegister_UI_Style.css b/B02_ProjRegister/B02_ProjRegister_UI_Style.css index f83aa46a..4186b3f8 100644 --- a/B02_ProjRegister/B02_ProjRegister_UI_Style.css +++ b/B02_ProjRegister/B02_ProjRegister_UI_Style.css @@ -4,7 +4,13 @@ * ========================================================================== */ /* 제목·여백·버튼 줄은 공용 템플릿(ui_template_general_layout)을 쓴다 — 여기서는 - 이 화면에만 필요한 것(2열 그리드·셀렉트 화살표·버튼 오른쪽 정렬)만 둔다. */ + 이 화면에만 필요한 것(폭·2열 그리드·셀렉트 화살표·버튼 오른쪽 정렬)만 둔다. */ + +/* 입력 칸 두 줄짜리 폼이라 종전처럼 720px 로 좁혀 가운데 둔다 (2026-09-06 사용자 지시 — + 템플릿을 쓰되 폭은 바꾸지 않는다). */ +.b02-proj .ui-general-layout__inner { + max-width: 720px; +} /* 2열 필드 그리드 (좁은 화면에서는 1열) */ .b02-proj__grid { From d50956381dbabab10229e6cd86964c74dcae8948 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 6 Sep 2026 18:41:02 +0900 Subject: [PATCH 7/8] =?UTF-8?q?feat(B01):=20=EB=8C=80=EC=8B=9C=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20=ED=91=9C=C2=B7=EA=B8=B0=EB=B3=B8=EC=A0=95=EB=B3=B4?= =?UTF-8?q?=20=EC=A0=95=EB=B9=84=20(2026-09-06=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EC=A7=80=EC=8B=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 프로젝트 표의 진행도(%) 열 삭제 — 워크플로 배지가 같은 것을 보여 줌. 상태 문자열로 따로 세던 서버 계산도 제거(배지는 `project_workflow_stages` 표가 근거라 둘이 어긋났음) - 시스템 로그 표 = 이메일 · 동작 · 대상 · 일시. 「관리」 문구를 돌려 쓰던 것을 가르고, 대상(resource_type·id)을 새로 보이며, 일시는 날짜/시각 두 줄(아랫줄 작은 글씨라 행 높이 불변) - 표 안 관리 버튼을 한 줄로 — 작은 버튼 + 줄바꿈 금지, 폭이 모자라면 표가 가로 스크롤 (프로젝트 행 높이 105px → 61px, 사용자 행 55px) - 기본정보는 로그인 본인 화면 — 이메일 라벨을 「팀원 이메일」에서 「이메일」로 바꾸고 본인 서명 칸을 사용자 수정 모달과 같은 부품으로 추가 - 제목·여백을 공용 템플릿(`createGeneralLayout`)으로 통일, 역할 배지는 제목 줄 오른쪽 Co-Authored-By: Claude Opus 5 (1M context) --- B01_Dashboard/B01_Dashboard_Api_Fetch.ts | 1 - B01_Dashboard/B01_Dashboard_Repository.py | 15 ++++-- B01_Dashboard/B01_Dashboard_UI_Admin.ts | 38 +++++++++++++-- B01_Dashboard/B01_Dashboard_UI_Common.ts | 46 ++++++++++++++--- B01_Dashboard/B01_Dashboard_UI_Page.ts | 34 ++++++------- B01_Dashboard/B01_Dashboard_UI_Profile.ts | 33 +++++++++++-- B01_Dashboard/B01_Dashboard_UI_Projects.ts | 3 +- B01_Dashboard/B01_Dashboard_UI_Style.css | 57 ++++++++++++++-------- ui_template/ui_template_locale_b1.ts | 5 +- 9 files changed, 169 insertions(+), 63 deletions(-) diff --git a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts index b2795995..2d4e01a6 100644 --- a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts +++ b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts @@ -63,7 +63,6 @@ export interface ProjectItem { member_user_ids?: number[]; owner_name?: string | null; workflow_stage: number; - progress_percent: number; workflow_state?: WorkflowState; updated_at?: string | null; } diff --git a/B01_Dashboard/B01_Dashboard_Repository.py b/B01_Dashboard/B01_Dashboard_Repository.py index 106f1fa6..fc772cbf 100644 --- a/B01_Dashboard/B01_Dashboard_Repository.py +++ b/B01_Dashboard/B01_Dashboard_Repository.py @@ -42,10 +42,16 @@ async def role_for_company(company_id: int | None) -> str: return "USER" -def _stage_from_status(status: str | None) -> tuple[int, int]: +def _stage_from_status(status: str | None) -> int: + """프로젝트 상태 문자열에서 워크플로 단계만 뽑는다. + + 진행도(%)는 내지 않는다 (2026-09-06 사용자 지시) — 화면은 워크플로 배지로 보여 주고, + 배지는 `project_workflow_stages` 표를 근거로 삼는다. 상태 문자열로 따로 세면 근거가 + 둘이 되어 배지와 숫자가 어긋났다. + """ value = status or "NEW" if value in {"WF1_ANALYZING", "WF1_FAILED"}: - return 1, round(1 / 7 * 100) + return 1 order = [ ("FILE_UPLOADED", 1), ("WF1_COMPLETE", 2), @@ -61,12 +67,11 @@ def _stage_from_status(status: str | None) -> tuple[int, int]: for token, idx in order: if token in value: stage = max(stage, idx) - return stage, round(stage / 7 * 100) + return stage def _project_row(row: dict[str, Any]) -> dict[str, Any]: - stage, progress = _stage_from_status(row.get("status")) - return {**row, "workflow_stage": stage, "progress_percent": progress} + return {**row, "workflow_stage": _stage_from_status(row.get("status"))} async def _project_rows(cursor: Any, rows: list[dict[str, Any]]) -> list[dict[str, Any]]: diff --git a/B01_Dashboard/B01_Dashboard_UI_Admin.ts b/B01_Dashboard/B01_Dashboard_UI_Admin.ts index 3da6941b..5bd3cbd4 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Admin.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Admin.ts @@ -7,7 +7,13 @@ import { openEditUserModal, } from "./B01_Dashboard_UI_Modals"; import { table, text } from "@ui/ui_template_general_blocks"; -import { DASHBOARD_VISIBLE_ROWS, formatDate, L } from "./B01_Dashboard_UI_Common"; +import { + DASHBOARD_VISIBLE_ROWS, + formatDate, + formatTime, + L, + stackedCell, +} from "./B01_Dashboard_UI_Common"; export function userTable(users: DashboardUser[], currentUser: DashboardUser): HTMLElement { return table( @@ -69,14 +75,38 @@ export function userTable(users: DashboardUser[], currentUser: DashboardUser): H ); } +/** 무엇에 한 일인지 — 표에 저장된 대상 종류·번호를 사람이 읽는 말로. */ +function auditTarget(log: AuditLog): string { + // 저장값은 소문자(`project`)로 들어온다 — 대문자로 맞춰 찾는다. + const key = (log.resource_type ?? "").toUpperCase(); + const kind = TARGET_LABELS[key] ?? log.resource_type ?? ""; + if (!kind) return "-"; + return log.resource_id ? `${kind} #${log.resource_id}` : kind; +} + +const TARGET_LABELS: Record = { + PROJECT: "프로젝트", + COMPANY: "회사", + USER: "사용자", + ASSET: "자산", +}; + export function auditLogTable(logs: AuditLog[]): HTMLElement { return table( [ L("B01_Dashboard_Table_Email"), - L("B01_Dashboard_Table_Action"), - L("B01_Dashboard_Table_Updated"), + // 관리 버튼 열과 같은 말(「관리」)을 돌려 쓰던 것을 갈랐다 (2026-09-06 사용자 지적). + L("B01_Dashboard_Table_Event"), + L("B01_Dashboard_Table_Target"), + L("B01_Dashboard_Table_When"), ], - logs.map((log) => [text(log.email), text(log.action), text(formatDate(log.timestamp))]), + logs.map((log) => [ + text(log.email), + text(log.action), + text(auditTarget(log)), + // 날짜와 시각을 두 줄로 — 아랫줄이 작은 글씨라 행 높이는 그대로다. + stackedCell(formatDate(log.timestamp), formatTime(log.timestamp)), + ]), DASHBOARD_VISIBLE_ROWS, ); } diff --git a/B01_Dashboard/B01_Dashboard_UI_Common.ts b/B01_Dashboard/B01_Dashboard_UI_Common.ts index 17f51a19..b7ba551d 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Common.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Common.ts @@ -40,6 +40,32 @@ 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) : ""; +} + +/** + * 위·아래 두 줄짜리 표 칸. 아랫줄은 작은 글씨라 **행 높이는 한 줄일 때와 같다**. + * 날짜/시각처럼 한 칸에 두 값을 넣을 때 쓴다. + */ +export function stackedCell(top: string, bottom: string): HTMLElement { + const cell = document.createElement("div"); + cell.className = "b01-dashboard__stacked"; + const first = document.createElement("span"); + first.textContent = top; + cell.append(first); + if (bottom) { + const second = document.createElement("span"); + second.className = "b01-dashboard__stacked-sub"; + second.textContent = bottom; + cell.append(second); + } + return cell; +} + /* ----------------------------------------------------------------------------- * 모달 바깥 클릭으로 닫기 (2026-09-04 사용자 지시) * @@ -149,13 +175,17 @@ export function attachModalDismiss( * (2026-09-06 사용자 지시, 템플릿 일원화). 순서는 이름 > 직급 > 이메일 > 부서 > 전화이며 * 이메일은 계정 식별자라 읽기 전용이다. */ -export function buildUserFields(source: { - name: string; - email?: string; - position?: string | null; - department?: string | null; - phone?: string | null; -}): { +export function buildUserFields( + source: { + name: string; + email?: string; + position?: string | null; + department?: string | null; + phone?: string | null; + }, + /** 본인 정보 화면에서는 「팀원 이메일」이 아니라 「이메일」이다 (2026-09-06 사용자 지시). */ + options: { self?: boolean } = {}, +): { grid: HTMLElement; validate: () => boolean; values: () => { @@ -175,7 +205,7 @@ export function buildUserFields(source: { value: source.position ?? "", }); const email = createInputField({ - label: L("B01_Dashboard_Field_MemberEmail"), + label: L(options.self ? "B01_Dashboard_Field_Email" : "B01_Dashboard_Field_MemberEmail"), type: "email", value: source.email ?? "", }); diff --git a/B01_Dashboard/B01_Dashboard_UI_Page.ts b/B01_Dashboard/B01_Dashboard_UI_Page.ts index 6e821eea..6b445015 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Page.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Page.ts @@ -7,6 +7,7 @@ import { showToast, } from "@ui/ui_template_elements"; import { section } from "@ui/ui_template_general_blocks"; +import { createGeneralLayout } from "@ui/ui_template_general_layout"; import { navigateTo } from "../A00_Common/router"; import { fetchAllCompanies, @@ -130,9 +131,8 @@ async function loadRoleData(state: DashboardState): Promise { } function buildPage(state: DashboardState): HTMLElement { + // 제목·여백은 공용 템플릿을 따른다 (2026-09-06 사용자 지시) — B02 등 다른 화면과 같은 모양. const page = document.createElement("div"); - page.className = "b01-dashboard"; - page.append(buildHeader(state.user)); const grid = document.createElement("div"); grid.className = "b01-dashboard__grid"; @@ -185,22 +185,18 @@ function buildPage(state: DashboardState): HTMLElement { grid.append(section(L("B01_Dashboard_AuditLogs"), auditLogTable(state.auditLogs), true)); } page.append(grid); - return page; -} - -function buildHeader(user: DashboardUser): HTMLElement { - const header = document.createElement("header"); - header.className = "b01-dashboard__header"; - const text = document.createElement("div"); - const title = document.createElement("h1"); - title.className = "b01-dashboard__title"; - title.textContent = L("B01_Dashboard_Title"); - const subtitle = document.createElement("p"); - subtitle.className = "b01-dashboard__subtitle"; - subtitle.textContent = L("B01_Dashboard_Subtitle"); - text.append(title, subtitle); - const tag = createTag(roleLabel(user.role), user.role === "SYSTEM_ADMIN" ? "accent" : "neutral"); + const layout = createGeneralLayout({ + pageClass: "b01-dashboard", + title: L("B01_Dashboard_Title"), + subtitle: L("B01_Dashboard_Subtitle"), + content: page, + }); + // 역할 배지는 제목 줄 오른쪽에 둔다(자리는 CSS 격자가 잡는다). + const tag = createTag( + roleLabel(state.user.role), + state.user.role === "SYSTEM_ADMIN" ? "accent" : "neutral", + ); tag.classList.add("b01-dashboard__role"); - header.append(text, tag); - return header; + layout.root.querySelector(".ui-general-layout__header")?.append(tag); + return layout.root; } diff --git a/B01_Dashboard/B01_Dashboard_UI_Profile.ts b/B01_Dashboard/B01_Dashboard_UI_Profile.ts index 90db3ab9..f7c39ec3 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Profile.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Profile.ts @@ -1,13 +1,40 @@ import { isBlank } from "@util/common_util_validate"; import { createButton, createInputField } from "@ui/ui_template_elements"; -import { changePassword, updateUserProfile, type DashboardUser } from "./B01_Dashboard_Api_Fetch"; +import { + changePassword, + fetchCompanyAssets, + updateCompanyAsset, + updateUserProfile, + type DashboardUser, +} from "./B01_Dashboard_Api_Fetch"; +import { createAssetField } from "./B01_Dashboard_UI_AssetPicker"; import { buildUserFields, L, runRequest } from "./B01_Dashboard_UI_Common"; const PASSWORD_MIN_LENGTH = 8; export function buildProfileForm(user: DashboardUser): HTMLElement { - const fields = buildUserFields(user); + // 로그인한 본인의 정보다 — 이메일 라벨도 「이메일」로 나간다 (2026-09-06 사용자 지시). + const fields = buildUserFields(user, { self: true }); const grid = fields.grid; + // 본인 서명 — 사용자 수정 모달과 같은 칸이다. 도면 표제란이 이 사람 자리를 채울 때 + // 그대로 실리므로 본인이 여기서 바로 걸 수 있게 둔다 (2026-09-06 사용자 지시). + const signatureSlot = document.createElement("div"); + if (user.company_id) { + void fetchCompanyAssets(user.company_id).then((assets) => { + const owned = assets.find((asset) => asset.kind === "SIGNATURE" && asset.user_id === user.id); + signatureSlot.append( + createAssetField("서명", "SIGNATURE", assets, owned?.id ?? null, user.company_id!, user, { + owner: { id: user.id, name: user.name }, + onChange: async (assetId) => { + if (assetId === null) return; + const picked = assets.find((asset) => asset.id === assetId); + if (picked) + await updateCompanyAsset(assetId, { label: picked.label, user_id: user.id }); + }, + }).root, + ); + }); + } const save = createButton({ label: L("B01_Dashboard_SaveProfile"), onClick: async function onB01_Profile_Save_Click() { @@ -16,7 +43,7 @@ export function buildProfileForm(user: DashboardUser): HTMLElement { }, }); const wrap = document.createElement("div"); - wrap.append(grid, save); + wrap.append(grid, signatureSlot, save); return wrap; } diff --git a/B01_Dashboard/B01_Dashboard_UI_Projects.ts b/B01_Dashboard/B01_Dashboard_UI_Projects.ts index 7352226e..a07fe807 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Projects.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Projects.ts @@ -13,7 +13,7 @@ export function projectTable(projects: ProjectItem[], currentUser: DashboardUser [ L("B01_Dashboard_Table_Project"), L("B01_Dashboard_Table_Region"), - L("B01_Dashboard_Table_Progress"), + // 진행도(%) 열은 없앴다 (2026-09-06 사용자 지시) — 워크플로 배지가 같은 것을 보여 준다. L("B01_Dashboard_Table_Workflow"), L("B01_Dashboard_Table_Updated"), L("B01_Dashboard_Table_Action"), @@ -44,7 +44,6 @@ export function projectTable(projects: ProjectItem[], currentUser: DashboardUser return [ text(project.name), text(project.region), - text(`${project.progress_percent}%`), workflow(project), text(formatDate(project.updated_at)), actCell, diff --git a/B01_Dashboard/B01_Dashboard_UI_Style.css b/B01_Dashboard/B01_Dashboard_UI_Style.css index 15fde60c..9eb0b919 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Style.css +++ b/B01_Dashboard/B01_Dashboard_UI_Style.css @@ -1,28 +1,19 @@ -.b01-dashboard { - max-width: var(--page-max-width); - margin: 0 auto; - padding: var(--spacing-40) var(--spacing-24) var(--spacing-64); +/* 폭·제목·여백은 공용 템플릿(ui_template_general_layout)이 잡는다 (2026-09-06 사용자 지시). + 여기서는 역할 배지를 제목 줄 오른쪽에 세우는 것만 한다. */ +.b01-dashboard .ui-general-layout__header { + display: grid; + grid-template-columns: 1fr auto; + align-items: end; } -.b01-dashboard__header { - display: flex; - justify-content: space-between; - gap: var(--spacing-24); - align-items: flex-end; - margin-bottom: var(--spacing-32); -} - -.b01-dashboard__title { - font-size: var(--text-heading); -} - -.b01-dashboard__subtitle { - margin: var(--spacing-8) 0 0; - color: var(--color-text-secondary); - font-size: var(--text-body); +.b01-dashboard .ui-general-layout__title, +.b01-dashboard .ui-general-layout__subtitle { + grid-column: 1; } .b01-dashboard__role { + grid-column: 2; + grid-row: 1 / span 2; flex-shrink: 0; } @@ -46,6 +37,32 @@ gap: var(--spacing-8); } +/* 표 안의 관리 버튼은 **한 줄로** 세운다 (2026-09-06 사용자 지시) — 두 줄로 접히면 + 행 높이가 두 배가 된다. 버튼을 작게 만들고 줄바꿈을 막되, 폭이 모자라면 표가 + 가로로 스크롤한다(`.b01-dashboard__table-wrap` 이 이미 그렇게 돼 있다). */ +.b01-dashboard__table .b01-dashboard__actions { + flex-wrap: nowrap; + gap: var(--spacing-4); +} + +.b01-dashboard__table .b01-dashboard__actions .ui-btn { + padding: var(--spacing-4) var(--spacing-8); + font-size: var(--text-caption); + white-space: nowrap; +} + +/* 한 칸에 두 줄(날짜/시각) — 아랫줄이 작아 행 높이는 한 줄일 때와 같다. */ +.b01-dashboard__stacked { + display: flex; + flex-direction: column; + line-height: 1.2; +} + +.b01-dashboard__stacked-sub { + color: var(--color-text-muted); + font-size: var(--text-caption); +} + .b01-dashboard__table-wrap { overflow-x: auto; border: 1px solid var(--color-border); diff --git a/ui_template/ui_template_locale_b1.ts b/ui_template/ui_template_locale_b1.ts index af121860..e12e1569 100644 --- a/ui_template/ui_template_locale_b1.ts +++ b/ui_template/ui_template_locale_b1.ts @@ -125,7 +125,6 @@ export const ui_locales_b1 = { B01_Dashboard_SaveProfile: ["기본정보 저장", "Save profile"], B01_Dashboard_Table_Project: ["프로젝트명", "Project"], B01_Dashboard_Table_Region: ["지역", "Region"], - B01_Dashboard_Table_Progress: ["진행도", "Progress"], B01_Dashboard_Table_Workflow: ["워크플로우", "Workflow"], B01_Dashboard_Table_Updated: ["수정일", "Updated"], B01_Dashboard_Table_Email: ["이메일", "Email"], @@ -137,12 +136,16 @@ export const ui_locales_b1 = { B01_Dashboard_Table_Company: ["회사명", "Company"], B01_Dashboard_Table_Requested: ["신청일", "Requested"], B01_Dashboard_Table_Action: ["관리", "Action"], + B01_Dashboard_Table_Event: ["동작", "Event"], + B01_Dashboard_Table_Target: ["대상", "Target"], + B01_Dashboard_Table_When: ["일시", "When"], B01_Dashboard_Table_Owner: ["소유자", "Owner"], B01_Dashboard_Field_BusinessNumber: ["사업자등록번호", "Business number"], B01_Dashboard_Field_Address: ["주소", "Address"], B01_Dashboard_Field_Owner: ["대표자명", "Owner"], B01_Dashboard_Field_Search: ["검색어", "Search"], B01_Dashboard_Field_MemberEmail: ["팀원 이메일", "Member email"], + B01_Dashboard_Field_Email: ["이메일", "Email"], B01_Dashboard_Metric_Cpu: ["CPU", "CPU"], B01_Dashboard_Metric_Memory: ["메모리", "Memory"], B01_Dashboard_Metric_Disk: ["디스크", "Disk"], From 4066504ba95fb69ed14fb100ca212eac3d57266d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 6 Sep 2026 18:57:22 +0900 Subject: [PATCH 8/8] =?UTF-8?q?feat(B01):=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=97=90=20=EB=8C=80=EC=83=81=C2=B7=EC=A0=91?= =?UTF-8?q?=EC=86=8D=20=EC=A0=95=EB=B3=B4=20=EA=B8=B0=EB=A1=9D=20+=201?= =?UTF-8?q?=EB=85=84=20=EB=B3=B4=EA=B4=80=20=EC=A0=95=EB=A6=AC=20(2026-09-?= =?UTF-8?q?06=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=ED=99=95=EC=A0=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기록을 `common_util_audit.record_audit()` 한 곳으로 모음 — 여섯 자리에 흩어져 있던 raw INSERT 제거 - 대상 식별자 칸 신설(`017_audit_log_detail.sql`, 적용 완료) — 기존 `resource_id` 는 INT 라 프로젝트 UUID 를 못 담아 늘 NULL 이었음. 프로젝트 생성·수정·삭제·회사 생성이 대상을 남김 - 접속 주소·브라우저 기록 — 라우터가 요청을 넘겨 주고, 프록시 뒤에서는 X-Forwarded-For 우선 - 보관 기간 `AUDIT_LOG_RETENTION_DAYS` 기본 365일, 임시 보관함 정리 루프에 얹어 함께 정리 - 화면: 시스템 로그 표에 대상·접속 주소 열 추가(대상은 UUID 앞 8자만) 자체검증 — 프로젝트 생성·하드삭제를 실화면에서 돌려 두 줄 모두 `프로젝트 e1040640… · 127.0.0.1 · 2026-09-06 09:54/09:55` 로 남는 것 확인. Co-Authored-By: Claude Opus 5 (1M context) --- B01_Dashboard/B01_Dashboard_Api_Fetch.ts | 3 + B01_Dashboard/B01_Dashboard_Repository.py | 31 ++++--- .../B01_Dashboard_Repository_Company.py | 31 ++++--- B01_Dashboard/B01_Dashboard_Router.py | 13 ++- B01_Dashboard/B01_Dashboard_UI_Admin.ts | 10 ++- .../B02_ProjRegister_Repository.py | 13 ++- B02_ProjRegister/B02_ProjRegister_Router.py | 4 +- common_util/common_util_audit.py | 84 +++++++++++++++++++ common_util/common_util_project_delete.py | 15 ++-- common_util/common_util_temp_cleanup.py | 3 + config/config_system.py | 2 + db_management/017_audit_log_detail.sql | 13 +++ ui_template/ui_template_locale_b1.ts | 1 + 13 files changed, 186 insertions(+), 37 deletions(-) create mode 100644 common_util/common_util_audit.py create mode 100644 db_management/017_audit_log_detail.sql diff --git a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts index 2d4e01a6..e258ca87 100644 --- a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts +++ b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts @@ -117,6 +117,9 @@ export interface AuditLog { action: string; resource_type?: string | null; resource_id?: number | null; + /** 대상 식별자 문자열 — 프로젝트는 UUID 라 숫자 칸에 못 담는다 (2026-09-06). */ + resource_ref?: string | null; + ip_address?: string | null; timestamp: string; } diff --git a/B01_Dashboard/B01_Dashboard_Repository.py b/B01_Dashboard/B01_Dashboard_Repository.py index fc772cbf..9bed97a0 100644 --- a/B01_Dashboard/B01_Dashboard_Repository.py +++ b/B01_Dashboard/B01_Dashboard_Repository.py @@ -10,6 +10,7 @@ from typing import Any import aiomysql import psutil +from common_util.common_util_audit import record_audit from config.config_db import get_db_pool from config.config_system import ADMIN_EMAIL, EMAIL_REVERIFY_DAYS @@ -256,7 +257,9 @@ async def get_project(project_id: str) -> dict[str, Any] | None: return await cursor.fetchone() -async def update_project(project_id: str, data: dict[str, Any], actor_id: int) -> bool: +async def update_project( + project_id: str, data: dict[str, Any], actor_id: int, request: Any | None = None +) -> bool: pool = get_db_pool() async with pool.acquire() as connection, connection.cursor() as cursor: await connection.begin() @@ -300,16 +303,19 @@ async def update_project(project_id: str, data: dict[str, Any], actor_id: int) - ) changed = await cursor.fetchone() is not None else: - await cursor.execute( - """INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id) - VALUES (%s, 'PROJECT_UPDATE', 'project', NULL)""", - (actor_id,), + await record_audit( + cursor, + actor_id=actor_id, + action="PROJECT_UPDATE", + resource_type="project", + resource_ref=project_id, + request=request, ) await connection.commit() return changed -async def soft_delete_project(project_id: str, actor_id: int) -> bool: +async def soft_delete_project(project_id: str, actor_id: int, request: Any | None = None) -> bool: pool = get_db_pool() async with pool.acquire() as connection, connection.cursor() as cursor: await connection.begin() @@ -320,10 +326,13 @@ async def soft_delete_project(project_id: str, actor_id: int) -> bool: ) changed = cursor.rowcount > 0 if changed: - await cursor.execute( - """INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id) - VALUES (%s, 'PROJECT_DELETE', 'project', NULL)""", - (actor_id,), + await record_audit( + cursor, + actor_id=actor_id, + action="PROJECT_DELETE", + resource_type="project", + resource_ref=project_id, + request=request, ) await connection.commit() return changed @@ -447,7 +456,7 @@ async def list_audit_logs(limit: int, offset: int) -> dict[str, Any]: total = (await cursor.fetchone())["total"] await cursor.execute( """SELECT l.id, l.user_id, u.email, l.action, l.resource_type, - l.resource_id, l.timestamp + l.resource_id, l.resource_ref, l.ip_address, l.timestamp FROM system_audit_logs l LEFT JOIN users u ON u.id = l.user_id ORDER BY l.timestamp DESC LIMIT %s OFFSET %s""", (limit, offset), diff --git a/B01_Dashboard/B01_Dashboard_Repository_Company.py b/B01_Dashboard/B01_Dashboard_Repository_Company.py index e7a4eb12..c4a6ae6a 100644 --- a/B01_Dashboard/B01_Dashboard_Repository_Company.py +++ b/B01_Dashboard/B01_Dashboard_Repository_Company.py @@ -10,6 +10,7 @@ from typing import Any import aiomysql +from common_util.common_util_audit import record_audit from config.config_db import get_db_pool from .B01_Dashboard_Repository import role_for_company @@ -77,7 +78,9 @@ async def search_companies(query: str) -> list[dict[str, Any]]: return list(await cursor.fetchall()) -async def create_company(user_id: int, data: dict[str, Any]) -> dict[str, Any]: +async def create_company( + user_id: int, data: dict[str, Any], request: Any | None = None +) -> dict[str, Any]: pool = get_db_pool() async with pool.acquire() as connection, connection.cursor() as cursor: try: @@ -104,10 +107,13 @@ async def create_company(user_id: int, data: dict[str, Any]) -> dict[str, Any]: WHERE id = %s""", (company_id, user_id), ) - await cursor.execute( - """INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id) - VALUES (%s, 'COMPANY_CREATE', 'company', %s)""", - (user_id, company_id), + await record_audit( + cursor, + actor_id=user_id, + action="COMPANY_CREATE", + resource_type="company", + resource_ref=company_id, + request=request, ) await connection.commit() return {"company_id": company_id, "status": "ACTIVE"} @@ -116,7 +122,9 @@ async def create_company(user_id: int, data: dict[str, Any]) -> dict[str, Any]: raise -async def create_system_company(actor_id: int, data: dict[str, Any]) -> dict[str, Any]: +async def create_system_company( + actor_id: int, data: dict[str, Any], request: Any | None = None +) -> dict[str, Any]: pool = get_db_pool() async with pool.acquire() as connection, connection.cursor() as cursor: try: @@ -135,10 +143,13 @@ async def create_system_company(actor_id: int, data: dict[str, Any]) -> dict[str ), ) company_id = cursor.lastrowid - await cursor.execute( - """INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id) - VALUES (%s, 'COMPANY_CREATE', 'company', %s)""", - (actor_id, company_id), + await record_audit( + cursor, + actor_id=actor_id, + action="COMPANY_CREATE", + resource_type="company", + resource_ref=company_id, + request=request, ) await connection.commit() return {"company_id": company_id, "status": "ACTIVE"} diff --git a/B01_Dashboard/B01_Dashboard_Router.py b/B01_Dashboard/B01_Dashboard_Router.py index 119651f8..1f4740cf 100644 --- a/B01_Dashboard/B01_Dashboard_Router.py +++ b/B01_Dashboard/B01_Dashboard_Router.py @@ -13,6 +13,7 @@ from fastapi import ( HTTPException, Path, Query, + Request, Response, UploadFile, ) @@ -196,10 +197,11 @@ async def user_company_search( @router.post("/user/company/create") async def user_company_create( + request: Request, payload: CreateCompanyRequest, session: dict[str, Any] = Depends(verify_session), ): - result = await create_company(int(session["user_id"]), payload.model_dump()) + result = await create_company(int(session["user_id"]), payload.model_dump(), request) return {"status": "success", **result} @@ -369,6 +371,7 @@ async def admin_projects(session: dict[str, Any] = Depends(require_company_admin @router.put("/projects/{project_id}") async def dashboard_update_project( project_id: str, + request: Request, payload: UpdateProjectRequest, session: dict[str, Any] = Depends(verify_session), ): @@ -387,7 +390,7 @@ async def dashboard_update_project( ) await check_project_refs(int(project["company_id"]), data) member_ids = data.pop("member_user_ids", None) - if not await update_project(project_id, data, int(session["user_id"])): + if not await update_project(project_id, data, int(session["user_id"]), request): raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.") if member_ids is not None: # 만든 사람은 늘 참여자로 남는다. @@ -398,6 +401,7 @@ async def dashboard_update_project( @router.delete("/projects/{project_id}") async def dashboard_delete_project( project_id: str, + request: Request, session: dict[str, Any] = Depends(verify_session), ): project = await get_project(project_id) @@ -413,7 +417,7 @@ async def dashboard_delete_project( # 개발 PC에서만 하드 삭제. 배포 기본값은 지금까지처럼 소프트 삭제다. delete_project = hard_delete_project if PROJECT_DELETE_HARD_ENABLED else soft_delete_project - if not await delete_project(project_id, int(session["user_id"])): + if not await delete_project(project_id, int(session["user_id"]), request): raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.") return {"status": "success"} @@ -426,10 +430,11 @@ async def system_companies(session: dict[str, Any] = Depends(require_system_admi @router.post("/admin/companies") async def system_create_company( + request: Request, payload: CreateCompanyRequest, session: dict[str, Any] = Depends(require_system_admin), ): - result = await create_company(int(session["user_id"]), payload.model_dump()) + result = await create_company(int(session["user_id"]), payload.model_dump(), request) return {"status": "success", **result} diff --git a/B01_Dashboard/B01_Dashboard_UI_Admin.ts b/B01_Dashboard/B01_Dashboard_UI_Admin.ts index 5bd3cbd4..7aca5024 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Admin.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Admin.ts @@ -80,8 +80,11 @@ function auditTarget(log: AuditLog): string { // 저장값은 소문자(`project`)로 들어온다 — 대문자로 맞춰 찾는다. const key = (log.resource_type ?? "").toUpperCase(); const kind = TARGET_LABELS[key] ?? log.resource_type ?? ""; - if (!kind) return "-"; - return log.resource_id ? `${kind} #${log.resource_id}` : kind; + const reference = log.resource_ref ?? (log.resource_id ? String(log.resource_id) : ""); + if (!kind) return reference || "-"; + // 프로젝트 UUID 는 길어 앞 8자만 — 어느 프로젝트인지 가리기에는 충분하다. + const shortened = reference.length > 12 ? `${reference.slice(0, 8)}…` : reference; + return shortened ? `${kind} ${shortened}` : kind; } const TARGET_LABELS: Record = { @@ -98,12 +101,15 @@ export function auditLogTable(logs: AuditLog[]): HTMLElement { // 관리 버튼 열과 같은 말(「관리」)을 돌려 쓰던 것을 갈랐다 (2026-09-06 사용자 지적). L("B01_Dashboard_Table_Event"), L("B01_Dashboard_Table_Target"), + L("B01_Dashboard_Table_Origin"), L("B01_Dashboard_Table_When"), ], logs.map((log) => [ text(log.email), text(log.action), text(auditTarget(log)), + // 접속 주소 — 기록이 없는 옛 줄은 빈칸으로 남는다 (2026-09-06부터 기록). + text(log.ip_address ?? "-"), // 날짜와 시각을 두 줄로 — 아랫줄이 작은 글씨라 행 높이는 그대로다. stackedCell(formatDate(log.timestamp), formatTime(log.timestamp)), ]), diff --git a/B02_ProjRegister/B02_ProjRegister_Repository.py b/B02_ProjRegister/B02_ProjRegister_Repository.py index 9c3d68b0..4714daa9 100644 --- a/B02_ProjRegister/B02_ProjRegister_Repository.py +++ b/B02_ProjRegister/B02_ProjRegister_Repository.py @@ -9,6 +9,7 @@ from uuid import uuid4 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_workflow import load_project_workflow @@ -43,6 +44,7 @@ def _initialize_project_storage(project_root: Path, project_id: str) -> None: async def create_project( *, + request: Any | None = None, user_id: int, company_id: int, name: str, @@ -110,10 +112,13 @@ async def create_project( # 워크플로우 단계별 상태 초기화 시드 await initialize_project_stages(cursor, project_id) - await cursor.execute( - """INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id) - VALUES (%s, 'PROJECT_CREATE', 'project', NULL)""", - (user_id,), + await record_audit( + cursor, + actor_id=user_id, + action="PROJECT_CREATE", + resource_type="project", + resource_ref=project_id, + request=request, ) _initialize_project_storage(project_root, project_id) await connection.commit() diff --git a/B02_ProjRegister/B02_ProjRegister_Router.py b/B02_ProjRegister/B02_ProjRegister_Router.py index f8127d0c..54934c5f 100644 --- a/B02_ProjRegister/B02_ProjRegister_Router.py +++ b/B02_ProjRegister/B02_ProjRegister_Router.py @@ -2,7 +2,7 @@ from typing import Any -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from B01_Dashboard.B01_Dashboard_Repository_Members import check_project_refs from common_util.common_util_auth import require_company @@ -15,6 +15,7 @@ router = APIRouter(prefix="/api/b02", tags=["B02_ProjRegister"]) @router.post("/project", response_model=CreateProjectResponse) async def post_project( + request: Request, payload: CreateProjectRequest, session: dict[str, Any] = Depends(require_company), ) -> CreateProjectResponse: @@ -50,6 +51,7 @@ async def post_project( try: result = await create_project( + request=request, user_id=int(session["user_id"]), company_id=int(company_id), name=payload.name.strip(), diff --git a/common_util/common_util_audit.py b/common_util/common_util_audit.py new file mode 100644 index 00000000..e0dfd3a5 --- /dev/null +++ b/common_util/common_util_audit.py @@ -0,0 +1,84 @@ +"""시스템 로그 기록·정리 한 곳 (2026-09-06 사용자 확정). + +같은 INSERT 문이 여섯 자리에 흩어져 있었고, 대상(무엇에 한 일인가)과 접속 정보(어디서 +했는가)는 칸만 있고 값이 비어 있었다. 기록은 이 함수 하나로 모은다. + +보관 기간은 `AUDIT_LOG_RETENTION_DAYS`(기본 365일) — 사고 추적에 1년이면 충분하다는 +사용자 판단(2026-09-06). 임시 보관함 정리 루프가 돌 때 함께 지운다. +""" + +from __future__ import annotations + +import logging +from datetime import datetime, timedelta, timezone +from typing import Any + +from config.config_db import get_db_pool +from config.config_system import AUDIT_LOG_RETENTION_DAYS + +logger = logging.getLogger(__name__) + +# 브라우저 문자열은 길다 — 표 칸은 TEXT 지만 화면·로그가 감당할 만큼만 자른다. +_USER_AGENT_MAX = 300 + + +def request_origin(request: Any | None) -> tuple[str | None, str | None]: + """요청에서 접속 주소와 브라우저 문자열을 꺼낸다. 없으면 (None, None). + + 프록시 뒤에서는 `X-Forwarded-For` 의 **첫 주소**가 실제 사용자다. + """ + if request is None: + return None, None + try: + forwarded = request.headers.get("x-forwarded-for") + address = forwarded.split(",")[0].strip() if forwarded else None + if not address and request.client is not None: + address = request.client.host + agent = (request.headers.get("user-agent") or "")[:_USER_AGENT_MAX] or None + return address, agent + except Exception: # 기록이 본 작업을 막으면 안 된다. + return None, None + + +async def record_audit( + cursor: Any, + *, + actor_id: int, + action: str, + resource_type: str | None = None, + resource_ref: str | int | None = None, + request: Any | None = None, +) -> None: + """시스템 로그 한 줄을 적는다 — 호출부의 트랜잭션(cursor)에 얹는다. + + `resource_ref` 는 프로젝트 UUID 처럼 문자열이어도 되고 숫자여도 된다. 숫자면 옛 + `resource_id` 칸에도 같이 넣어 예전 기록과 같은 모양을 지킨다. + """ + reference = None if resource_ref is None else str(resource_ref) + numeric = int(resource_ref) if isinstance(resource_ref, int) else None + address, agent = request_origin(request) + await cursor.execute( + """INSERT INTO system_audit_logs + (user_id, action, resource_type, resource_id, resource_ref, ip_address, user_agent) + VALUES (%s, %s, %s, %s, %s, %s, %s)""", + (actor_id, action, resource_type, numeric, reference, address, agent), + ) + + +async def purge_expired_audit_logs() -> int: + """보관 기간이 지난 시스템 로그를 지운다. 지운 줄 수를 돌려준다.""" + cutoff = datetime.now(timezone.utc) - timedelta(days=max(1, AUDIT_LOG_RETENTION_DAYS)) + pool = get_db_pool() + try: + async with pool.acquire() as connection, connection.cursor() as cursor: + await cursor.execute("DELETE FROM system_audit_logs WHERE timestamp < %s", (cutoff,)) + removed = cursor.rowcount + if removed: + await connection.commit() + logger.info( + "시스템 로그 정리: %d건 삭제 (보관 %d일)", removed, AUDIT_LOG_RETENTION_DAYS + ) + return removed + except Exception: + logger.exception("시스템 로그 정리 실패") + return 0 diff --git a/common_util/common_util_project_delete.py b/common_util/common_util_project_delete.py index 3008e4a2..7aa318bb 100644 --- a/common_util/common_util_project_delete.py +++ b/common_util/common_util_project_delete.py @@ -8,14 +8,16 @@ import logging import shutil +from typing import Any +from common_util.common_util_audit import record_audit from common_util.common_util_storage import resolve_project_root_for_delete from config.config_db import get_db_pool logger = logging.getLogger(__name__) -async def hard_delete_project(project_id: str, actor_id: int) -> bool: +async def hard_delete_project(project_id: str, actor_id: int, request: Any | None = None) -> bool: """프로젝트를 DB와 영구저장소에서 완전히 지운다. 되돌릴 수 없다. 자식 테이블은 나열하지 않는다 — `projects.id`를 참조하는 테이블이 전부 @@ -60,10 +62,13 @@ async def hard_delete_project(project_id: str, actor_id: int) -> bool: await connection.rollback() return False # 감사 기록은 프로젝트가 사라진 뒤에도 남는다. resource_id는 FK가 없어 고아가 되지 않는다. - await cursor.execute( - """INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id) - VALUES (%s, 'PROJECT_HARD_DELETE', 'project', NULL)""", - (actor_id,), + await record_audit( + cursor, + actor_id=actor_id, + action="PROJECT_HARD_DELETE", + resource_type="project", + resource_ref=project_id, + request=request, ) await connection.commit() diff --git a/common_util/common_util_temp_cleanup.py b/common_util/common_util_temp_cleanup.py index cc81af03..d864c547 100644 --- a/common_util/common_util_temp_cleanup.py +++ b/common_util/common_util_temp_cleanup.py @@ -14,6 +14,7 @@ from B03_FileInput.B03_FileInput_Repository_Temp import ( delete_temp_batch, list_expired_temp_batches, ) +from common_util.common_util_audit import purge_expired_audit_logs from common_util.common_util_storage import resolve_temp_batch_path, temp_upload_root from config.config_db import get_db_pool from config.config_system import ( @@ -81,4 +82,6 @@ async def cleanup_expired_temp_uploads_loop() -> None: removed = await cleanup_expired_temp_uploads() if removed: logger.info("임시 보관함 정리 완료: %d건 삭제", removed) + # 시스템 로그 보관 기간 정리도 같은 주기에 얹는다 — 루프를 따로 두지 않는다. + await purge_expired_audit_logs() await asyncio.sleep(interval_seconds) diff --git a/config/config_system.py b/config/config_system.py index 701e782e..1d29b57c 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -98,6 +98,8 @@ CHUNK_RETENTION_HOURS = int(os.getenv("CHUNK_RETENTION_HOURS", "24")) # 운영하며 조정할 값이라 여기서 관리한다(2026-08-08 사용자 지시). TEMP_UPLOAD_DIR_NAME = os.getenv("TEMP_UPLOAD_DIR_NAME", "tmp") TEMP_UPLOAD_RETENTION_DAYS = int(os.getenv("TEMP_UPLOAD_RETENTION_DAYS", "30")) +# 시스템 로그(누가 무엇을 했나) 보관 기간 — 사고 추적에 1년 (2026-09-06 사용자 확정). +AUDIT_LOG_RETENTION_DAYS = int(os.getenv("AUDIT_LOG_RETENTION_DAYS", "365")) TEMP_UPLOAD_CLEANUP_INTERVAL_HOURS = int(os.getenv("TEMP_UPLOAD_CLEANUP_INTERVAL_HOURS", "6")) MERGE_TIMEOUT_SECONDS = int(os.getenv("MERGE_TIMEOUT_SECONDS", "3600")) SEND_ANALYSIS_COMPLETION_EMAIL = ( diff --git a/db_management/017_audit_log_detail.sql b/db_management/017_audit_log_detail.sql new file mode 100644 index 00000000..8afbbed2 --- /dev/null +++ b/db_management/017_audit_log_detail.sql @@ -0,0 +1,13 @@ +-- 017_audit_log_detail.sql +-- 시스템 로그에 「무엇에」 한 일인지와 「어디서」 했는지를 남긴다 (2026-09-06 사용자 확정). +-- +-- 기존 `resource_id` 는 INT 라 프로젝트 id(UUID 문자열)를 담지 못해 늘 NULL 로 들어갔다. +-- 문자 칸을 따로 두어 프로젝트·회사·사용자 어느 쪽이든 그대로 적는다. +-- IP·브라우저 칸(`ip_address`·`user_agent`)은 004 에서 이미 만들어 두었으나 값을 넣는 +-- 코드가 없었다 — 이 마이그레이션 뒤부터 기록한다. + +ALTER TABLE system_audit_logs + ADD COLUMN IF NOT EXISTS resource_ref VARCHAR(64) NULL + COMMENT '대상 식별자 (프로젝트 UUID 등 문자열). 숫자 대상은 resource_id 와 같이 채운다'; + +-- 보관 기간(기본 365일) 정리가 날짜로 훑으므로 인덱스는 004 의 timestamp 인덱스를 그대로 쓴다. diff --git a/ui_template/ui_template_locale_b1.ts b/ui_template/ui_template_locale_b1.ts index e12e1569..69fe4d01 100644 --- a/ui_template/ui_template_locale_b1.ts +++ b/ui_template/ui_template_locale_b1.ts @@ -138,6 +138,7 @@ export const ui_locales_b1 = { B01_Dashboard_Table_Action: ["관리", "Action"], B01_Dashboard_Table_Event: ["동작", "Event"], B01_Dashboard_Table_Target: ["대상", "Target"], + B01_Dashboard_Table_Origin: ["접속 주소", "From"], B01_Dashboard_Table_When: ["일시", "When"], B01_Dashboard_Table_Owner: ["소유자", "Owner"], B01_Dashboard_Field_BusinessNumber: ["사업자등록번호", "Business number"],