diff --git a/A00_Common/calc_sheet.ts b/A00_Common/calc_sheet.ts index 4197de7a9..cb14d8581 100644 --- a/A00_Common/calc_sheet.ts +++ b/A00_Common/calc_sheet.ts @@ -16,6 +16,7 @@ import { el } from "@ui/ui_template_elements"; import { createInputField, createSelectField } from "@ui/ui_template_input"; import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createText } from "@ui/ui_template_text"; +import { formatLineMoney, formatMoney } from "../common_util/common_util_money"; import "./calc_sheet.css"; export interface SheetLine { @@ -29,6 +30,8 @@ export interface SheetLine { 장비?: boolean | null; 기준단위?: string | null; 환산근거?: string; + /** (753-1) 옵션으로 빠진 줄 — 그리지 않음(소계 · 쓰는 입력 칸은 그대로) */ + 옵션꺼짐?: boolean; /** 714-1b 장비 줄 펼침 — 부른 기계경비 로직의 시간당 줄(조종원 · 주연료 · 잡재료비 · 기계손료) */ 펼침?: SheetUnfold | null; } @@ -146,7 +149,7 @@ const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | const bundleName = (b: string): string => (b in TEXT ? t(b as keyof typeof TEXT) : b); const won = (v: number | null, digits = 0): string => - v === null ? "-" : v.toLocaleString("ko-KR", { maximumFractionDigits: digits }); + v === null ? "-" : digits ? formatLineMoney(v) : formatMoney(v); const out = (v: number | null, key = false, digits = 0): HTMLElement => el("span", { className: `ui-calc-row__out${key ? " is-key" : ""}`, text: won(v, digits) }); @@ -270,6 +273,10 @@ function headRows( const subtotal = (g: SheetGroup): HTMLElement => createCalcFormulaRow(`${g.비목}${g.끝수 ? ` (${g.끝수})` : ""}`, null, out(g.소계, true)); +/** 옵션으로 빠진 줄(753-1)은 안 그림 — 장비 · 하위 몫 줄은 몫이 다 빠졌을 때만 */ +const shown = (l: SheetLine | SheetMerged): boolean => + "몫" in l ? l.몫.some((x) => !x.줄.옵션꺼짐) : !l.옵션꺼짐; + /** 계산서 줄들 — 부르는 쪽 세로 칸(flex column)에 그대로 붙임 */ export function calcSheetRows(o: SheetOptions): HTMLElement[] { const rows: HTMLElement[] = []; @@ -278,7 +285,8 @@ export function calcSheetRows(o: SheetOptions): HTMLElement[] { for (const b of bundleLines(o.묶음)) { const name = bundleName(b.묶음); rows.push(...headRows(name, b.쓰는입력, o, specs, b.기준단위, b.환산근거)); - for (const line of b.줄) rows.push(...("몫" in line ? mergedRows(line) : [lineRow(line)])); + for (const line of b.줄.filter(shown)) + rows.push(...("몫" in line ? mergedRows(line) : [lineRow(line)])); rows.push(createCalcFormulaRow(name, null, out(b.소계, true, 1))); } rows.push(...o.묶음.map(subtotal)); // 맨 아래 비목 소계(서버 끝수 그대로) — 장비 노무는 여기서 인력 노무와 더해짐 @@ -286,7 +294,7 @@ export function calcSheetRows(o: SheetOptions): HTMLElement[] { for (const g of o.묶음) { const uses = [...new Set(g.줄.flatMap((l) => l.쓰는입력 ?? []))]; rows.push(...headRows(g.비목, uses, o, specs)); - rows.push(...g.줄.map((line) => lineRow(line)), subtotal(g)); + rows.push(...g.줄.filter(shown).map((line) => lineRow(line)), subtotal(g)); } } rows.push(createCalcFormulaRow(o.sumLabel, null, out(o.계, true))); diff --git a/A00_Common/design/design_save.ts b/A00_Common/design/design_save.ts index ebf731acc..013e61792 100644 --- a/A00_Common/design/design_save.ts +++ b/A00_Common/design/design_save.ts @@ -52,6 +52,22 @@ import { designSignature, markDesignClean } from "./design_leave"; import { routeGradeTouch } from "./design_pipeline_page"; import type { GradeTouch } from "../../B05_Profile/logic/B05_Profile_Pipeline_Grade"; +/** [저장] · [확정] 막음(755-3) — 글을 돌려주면 알림하고 안 냄 · `root` 가 화면에서 빠지면 풂. */ +const saveHolds: Array<{ root: HTMLElement; hold: () => string | null }> = []; +export function holdDesignSave(root: HTMLElement, hold: () => string | null): void { + saveHolds.push({ root, hold }); +} +function heldSave(): string | null { + for (let index = saveHolds.length - 1; index >= 0; index -= 1) { + if (!saveHolds[index].root.isConnected) saveHolds.splice(index, 1); + } + for (const { hold } of saveHolds) { + const why = hold(); + if (why) return why; + } + return null; +} + export interface DesignSaveContext { projectId: string; routeId: () => number | null; @@ -249,6 +265,8 @@ export async function saveDesign( const confirm = options.confirm ?? false; const routeId = ctx.routeId(); if (routeId === null) return false; + const held = heldSave(); + if (held) return (showToast(held, "warning"), false); saving.add(ctx.projectId); let locked = confirm; if (locked) showLoadingOverlay(); diff --git a/B04_PreProcess/B04_PreProcess_Engine_Contour.py b/B04_PreProcess/B04_PreProcess_Engine_Contour.py index 30a6946a1..b89aa00b2 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_Contour.py +++ b/B04_PreProcess/B04_PreProcess_Engine_Contour.py @@ -122,6 +122,23 @@ def extract_contours_from_grid( return contours_geojson_list +def contour_cache_is_stale(contour_path: Path, model_path: Path) -> bool: + """등고선 캐시가 없거나 그 면을 낳은 파일보다 옛것이면 True(754-1). + + 낳은 파일 = 모델 npz · 같은 필터 `dtm_{필터}.npz`(외곽 자르기) · implicit 굳힌 격자. + """ + stem = Path(model_path).stem.removesuffix("_smooth") + folder = Path(model_path).parent + sources = [Path(model_path), folder / f"dtm_{stem.split('_', 1)[-1]}.npz"] + if stem.startswith("implicit_"): + sources.append(folder / f"{stem}_grid.npz") + try: + built = Path(contour_path).stat().st_mtime_ns + except OSError: + return True + return any(path.is_file() and path.stat().st_mtime_ns > built for path in sources) + + def _load_footprint_mask( model_npz_path: Path, x_coords: np.ndarray, y_coords: np.ndarray ) -> np.ndarray | None: @@ -137,10 +154,10 @@ def _load_footprint_mask( if not dtm_path.exists(): return None try: - d = np.load(dtm_path) - dtm_x = np.asarray(d["x"]).ravel() - dtm_y = np.asarray(d["y"]).ravel() - dtm_mask = np.asarray(d["valid_mask"], dtype=bool) + with np.load(dtm_path) as d: + dtm_x = np.asarray(d["x"]).ravel() + dtm_y = np.asarray(d["y"]).ravel() + dtm_mask = np.asarray(d["valid_mask"], dtype=bool) except Exception: return None if len(dtm_x) < 2 or len(dtm_y) < 2: @@ -360,7 +377,21 @@ def extract_contours( model_npz_path = Path(model_npz_path) if not model_npz_path.exists(): raise FileNotFoundError(f"모델 파일이 존재하지 않습니다: {model_npz_path}") - data = np.load(model_npz_path) + # 실패해도 모델 파일을 닫음 — 열린 채 남으면 Windows 에서 다시 짓기(교체)가 막힘(754-1) + with np.load(model_npz_path) as data: + return _extract_loaded( + data, model_npz_path, representation, interval, target_grid_m, scene_center + ) + + +def _extract_loaded( + data: Any, + model_npz_path: Path, + representation: str, + interval: float, + target_grid_m: float, + scene_center: tuple[float, float, float] | None, +) -> list[dict[str, Any]]: if representation == "regular_grid": x_coords, y_coords, z_grid, valid_mask = ( diff --git a/B04_PreProcess/B04_PreProcess_Engine_Pipeline.py b/B04_PreProcess/B04_PreProcess_Engine_Pipeline.py index f2f8766bf..3fab1e129 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_Pipeline.py +++ b/B04_PreProcess/B04_PreProcess_Engine_Pipeline.py @@ -16,6 +16,7 @@ import numpy as np from B04_PreProcess.B04_PreProcess_Engine_Contour import ( CONTOUR_EXTRACTOR_VERSION, + contour_cache_is_stale, extract_contours, ) from B04_PreProcess.B04_PreProcess_Engine_ModelBuild import BUILDERS @@ -191,11 +192,9 @@ def _build_all_terrain_models( output_dir.mkdir(parents=True, exist_ok=True) manifest_path = output_dir / "manifest.json" filters = tuple(key for key in config["source_filters"] if key in ground_masks) - methods_list = [key for key in config["precompute"] if key in BUILDERS] - if "dtm" in methods_list: - methods_list.remove("dtm") - methods_list.insert(0, "dtm") - methods = tuple(methods_list) + # dtm 은 늘 맨 앞 — 같은 필터 외곽 · implicit 굳힌 격자 · 등고선 자르기의 바탕이라 + # 어느 표현을 지어도 낡았으면(manifest 항목 없음 · 지문 다름) 같이 다시 지음(754-1). + methods = ("dtm", *(key for key in config["precompute"] if key in BUILDERS and key != "dtm")) # 무거운 implicit 은 기본 필터(첫 필터)에만 — 분류 LAS 의 csf 까지 두 번 짓지 않음(요구 558). later_methods = tuple(method for method in methods if method != "implicit") signature = config_signature(config) @@ -244,12 +243,14 @@ def _build_all_terrain_models( if not force and _cache_is_valid(output_dir, stem, method, entry, config): interval = float(config.get("contour_interval_meters", 1.0)) contour_path = output_dir / f"contour_{filter_key}_{method}_{interval}m.json" - needs_contours = not contour_path.exists() + needs_contours = contour_cache_is_stale(contour_path, output_dir / f"{stem}.npz") if method in config.get("smoothing_methods", ("dtm", "tin")): smooth_contour_path = ( output_dir / f"contour_{filter_key}_{method}_smooth_{interval}m.json" ) - needs_contours = needs_contours or not smooth_contour_path.exists() + needs_contours = needs_contours or contour_cache_is_stale( + smooth_contour_path, output_dir / f"{stem}_smooth.npz" + ) if needs_contours: _cache_contours_safely( output_dir, diff --git a/B04_PreProcess/B04_PreProcess_Router_Contour.py b/B04_PreProcess/B04_PreProcess_Router_Contour.py index 4c1ea20f9..50e1d4bea 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Contour.py +++ b/B04_PreProcess/B04_PreProcess_Router_Contour.py @@ -16,6 +16,7 @@ from fastapi.responses import JSONResponse, Response from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B04_PreProcess.B04_PreProcess_Engine_Contour import ( CONTOUR_EXTRACTOR_VERSION, + contour_cache_is_stale, extract_contours, ) from common_util.common_util_atomic import atomic_write_bytes @@ -37,10 +38,10 @@ MODEL_REPRESENTATIONS = { def _is_contour_cache_current(contour_path: Path, model_path: Path) -> bool: - """캐시가 현재 추출기 버전이고 기반 모델 npz보다 최신인지 검사한다.""" + """캐시가 현재 추출기 버전이고 모델 · 같은 필터 dtm · 굳힌 격자보다 최신인지 검사한다.""" + if contour_cache_is_stale(contour_path, model_path): + return False try: - if contour_path.stat().st_mtime < model_path.stat().st_mtime: - return False with contour_path.open("rb") as cache_file: head = cache_file.read(256).decode("utf-8", errors="ignore") except OSError: diff --git a/B05_Profile/B05_Profile_Router_PlanSource.py b/B05_Profile/B05_Profile_Router_PlanSource.py index e4fdcebb2..7ce38b51b 100644 --- a/B05_Profile/B05_Profile_Router_PlanSource.py +++ b/B05_Profile/B05_Profile_Router_PlanSource.py @@ -2,7 +2,9 @@ 계산 자리 ③ 서버 단독 · 읽기만(파일 안 만듦). 원본 = 올린(받은) 노선 파일 꼭짓점을 사업지 좌표로 · 사용 범위(`projects.route_start_m · route_end_m`) · 지표면 트림까지 — 체인 예정노선 -(`load_design_route`)과 같은 차례에서 **조밀화만 뺌**. 고친 꼭짓점은 `POST /route/follow` 가 +(`load_design_route`)과 같은 차례에서 **조밀화만 뺌**. 끝 = 체인이 자른 `expected_route.csv` +양끝(755-3 · 지표면이 그 뒤 넓어져도 화면 예상노선 · 저장 csv 와 같은 길이). +고친 꼭짓점은 `POST /route/follow` 가 같은 간격(`spacing_m`)으로 조밀화한 뒤 추적 → 안 고친 원본 = 체인 초기선과 거리 0(`test_635_2`). GET /api/projects/{id}/route/plan/source @@ -13,15 +15,23 @@ from __future__ import annotations import asyncio +import math from pathlib import Path from typing import Any from uuid import UUID from fastapi import APIRouter from fastapi.responses import JSONResponse +from shapely.geometry import LineString, Point from B05_Profile.B05_Profile_Router_Replan import _PROJECT_PATH_MISSING, _project_paths -from common_util.common_util_route_geometry import load_design_route, trim_route_to_surface +from common_util.common_util_route_geometry import ( + clip_route_by_chainage, + expected_route_csv_path, + load_design_route, + read_planned_route_csv, + trim_route_to_surface, +) from common_util.common_util_surface_confirmation import get_surface_confirmation_params from common_util.common_util_surface_sampler import build_surface_sampler from config.config_db import get_db_pool @@ -57,9 +67,23 @@ def source_vertices( sampler = None if sampler is not None: points = trim_route_to_surface(points, sampler) + points = clip_to_expected(root, points) return [[float(x), float(y)] for x, y in points] if len(points) >= 2 else [] +def clip_to_expected(root: Path, points: list[tuple[float, float]]) -> list[tuple[float, float]]: + """체인이 자른 예정노선 파일 양끝에서 자름(755-3) — 파일 없음 · 2점 미만 = 그대로.""" + path = expected_route_csv_path(root) + stored = read_planned_route_csv(path) if path.is_file() else None + if stored is None or len(stored.vertices) < 2 or len(points) < 2: + return points + line = LineString(points) + ends = [line.project(Point(v.x, v.y)) for v in (stored.vertices[0], stored.vertices[-1])] + clipped = clip_route_by_chainage(points, min(ends), max(ends)) + # 파일 좌표 반올림(0.1 mm) = 끝이 꼭짓점 바로 앞에 떨어짐 — 1 mm 안 겹친 점은 하나만 + return [p for i, p in enumerate(clipped) if not i or math.dist(p, clipped[i - 1]) > 1e-3] + + @router.get("/{project_id}/route/plan/source", response_model=None) async def read_plan_source(project_id: UUID) -> dict[str, Any] | JSONResponse: paths = await _project_paths(project_id) diff --git a/B05_Profile/B05_Profile_UI_ExpectedDraw.ts b/B05_Profile/B05_Profile_UI_ExpectedDraw.ts index 053e3f0d4..0adcb7599 100644 --- a/B05_Profile/B05_Profile_UI_ExpectedDraw.ts +++ b/B05_Profile/B05_Profile_UI_ExpectedDraw.ts @@ -1,19 +1,24 @@ /* ============================================================================= * B05_Profile_UI_ExpectedDraw.ts - * B05 평면 예정노선 수정(PLAN 626-3 · 635-3 · 요구 530 · 535-1 · 536 · 546) — 예정노선 키트(`createPlanKit` 「b05」)를 엮음. - * 왼쪽 칸 = 키트 「위치」(주소 · 장소 찾기 → 지도 이동만) · 「그리기」(머리 「예정노선 수정」 켬끔 · 받은 노선 켬끔 · - * 길이 비교 · [받은 노선으로]) — 자리 = `B05_Profile_UI_Panel_Plan`. 숨김 = 기능 표 「b05」(`D03_PlanAnalysis_UI_Features`). + * B05 평면 예정노선 수정(PLAN 626-3 · 635-3 · 755 · 요구 530 · 535-1 · 536 · 546 · 589) — 예정노선 키트(`createPlanKit` 「b05」)를 엮음. + * 왼쪽 칸 = 키트 「그리기」 하나(머리 「예정노선 수정」 켬끔 · 받은 노선 켬끔 · 길이 비교 · [받은 노선으로]) — + * 자리 = `B05_Profile_UI_Panel_Plan`. 숨김 = 기능 표 「b05」(`D03_PlanAnalysis_UI_Features` · 위치 칸 숨김). * 켬 = 지도 누름 · 키를 먼저 받음(키트 `bindPlanInput` capture) · 다른 계획노선 그림 어둡게(`--draw-map-dim` · 장면 `overAll.dim`) · * 가운데 · 오른쪽 끌기(지도 이동) · 휠은 그대로. 끔 = 지금 B05 평면 편집 그대로(예정노선은 보기만). * 고칠 선 = 받은 원본 꼭짓점(`GET /route/plan/source` · 635-2) — 고친 뒤엔 고친 꼭짓점. - * 한 번 고침(긋기 끝 · 놓음 · 지움) → (켠 뒤 처음만) 확인창 → `POST /route/follow`(저장 0) → `onFollow(평면, 예정노선)` = - * 편집기 한 걸음(되돌리기 됨) → 페이지 [저장] = 손 편집과 같은 길 + 초안 `expected`(`expected_route_edit.csv`). + * 켠 동안(755) = 판만 고침 · 끊김 허용(가운데 묶음 지움) · 되돌리기 = 이 판 사진첩(Ctrl+Z · 도구줄 단추) · + * 재기 켬 = D03 재기 겹(고치는 판 따라 · 두 점 곧은 거리 · `createPlanMeasure`). + * 끌 때 한 번 = 끊겨 있으면 알림하고 안 끔 · 바뀌었으면 확인창 → `POST /route/follow`(저장 0 · 폴리라인 두 단계) → + * `onFollow(평면, 예정노선)` = 편집기 한 걸음 → 페이지 [저장] = 손 편집과 같은 길 + 초안 `expected`(`expected_route_edit.csv`). * [받은 노선으로] = 받은 꼭짓점으로 다시 추적 · 초안 `expected` = [](고친 예정노선 지움) — 한 걸음 · 페이지 [초기화] 는 그대로. + * 켠 채 판을 고쳤으면(755-3) = [저장] · [확정] 막고 알림(`holdDesignSave`) · 떠남 = 공용 떠남 물음 · 탭 닫기 = 브라우저 물음. * 예정노선 세 벌 = 받은 노선(`expected_route.csv` · 안 덮음 · 고쳤으면 옅은 점선) · 저장본(`expected_edited`) · 이번 고침(`edit`). * 같은 예정노선 다른 세트(635-4) = 흐린 겹 보기만(`B05_Profile_UI_PlanContext`) · 그 끝 = 긋기 · 끌기 붙을 자리 · 벗어나면 안내 한 줄. * ========================================================================== */ import { keyIs } from "../A00_Common/key_registry"; +import { guardProjectLeave } from "../A00_Common/b_project_switch"; +import { holdDesignSave } from "../A00_Common/design/design_save"; import { listenWhileAlive } from "@ui/ui_template_window_watch"; import { createButton, showToast } from "@ui/ui_template_elements"; import { askConfirm } from "@ui/ui_template_modal"; @@ -21,12 +26,12 @@ import { createText } from "@ui/ui_template_text"; import { createToggle } from "@ui/ui_template_toggle"; import { themeColor } from "@ui/ui_template_palette"; import type { ViewState } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; -import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { heightAt as gridHeightAt } from "../common_util/common_util_surface_sampler"; import type { PlanNetwork } from "../D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan"; +import { strokeCased } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint"; import { bindPlanInput, createPlanKit } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Kit"; -import type { ScreenMap } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Map"; -import { createPlaceFind } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Site"; +import type { PlanMapLayer, ScreenMap } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Map"; +import { createPlanMeasure } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Measure"; import { requestJson, type RoutePlanResponse } from "./B05_Profile_Api_Replan"; import { loadDesignTerrainGrid, type DesignTerrainGrid } from "./B05_Profile_Api_TerrainGrid"; import { createPlanCoords, fillPlanPanel } from "./B05_Profile_UI_Panel_Plan"; @@ -39,10 +44,9 @@ import { flattenServerPlan, type FlattenedPlan } from "./logic/B05_Profile_UI_Ro type Vertex = [number, number]; -const HINT = - "[긋기] · 우클릭 「여기서 긋기 시작」 · 선 끝에서 이어 긋기 · 고르고 Delete · 노드 끌기 — 한 번 고칠 때마다 평면선을 다시 추적합니다."; -const EMPTY_HINT = "예정노선이 없습니다 — 빈 땅 우클릭 「여기서 긋기 시작」으로 새로 긋습니다."; const OFF_HINT = "「예정노선 수정」을 켜면 지도에서 예정노선을 고칩니다."; +const HELD = + "예정노선 수정 중 — 「예정노선 수정」을 끈 뒤 [저장]합니다(켠 동안 고친 판은 끌 때 평면선에 들어감)."; /** `POST /route/follow` 응답 — GET `/route/plan` 칸 이름(626-2). */ type FollowResponse = Pick; @@ -58,11 +62,13 @@ export interface ExpectedDrawParams { rotation: () => Pick; pxPerMeter: () => number; view: () => ViewState; - /** 바탕 틀(경위도 ↔ 화면) · 보기 바꿈 — 위치 찾기 → 지도 이동. */ - frame: () => { meta: VWorldMeta | null; setView: (next: ViewState) => void }; redraw: () => void; /** 켬 — 편집기가 고른 노드 · 곡선 판을 풂. */ onOpen: () => void; + /** 도구줄 재기 단추 켬 — 켠 동안 D03 재기 겹이 누름을 받음(755-2). */ + measuring: () => boolean; + /** 켠 동안 사진첩이 바뀜 — 도구줄 되돌리기 단추 맞춤. */ + onHistory: () => void; /** 다시 추적한 평면 · 고친 예정노선(빈 = 받은 노선으로) — 편집기 한 걸음. */ onFollow: (plan: FlattenedPlan, expected: Vertex[]) => void; } @@ -78,6 +84,15 @@ export interface ExpectedDraw { paint: (context: CanvasRenderingContext2D) => void; /** 아래 그림 불투명(켠 동안 `--draw-map-dim`). */ dim: () => number; + /** 켠 동안 되돌리기 — 이 판 사진첩(끔 = null · 페이지 사진첩). */ + history: () => ExpectedHistory | null; +} + +export interface ExpectedHistory { + undo: () => void; + redo: () => void; + canUndo: () => boolean; + canRedo: () => boolean; } const sameLine = (a: ReadonlyArray, b: ReadonlyArray): boolean => @@ -99,10 +114,14 @@ export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw { /** 켠 뒤 확인창을 물었나 — 다시 추적 뒤 평면은 손 고침이 없음(켠 동안 계획선 안 집힘). */ let asked = false; let grid: DesignTerrainGrid | null = null; - /** 고치는 판(예정노선 꼴 · 한 선) · 판을 지은 꼭짓점 · 긋기 시작 때 판(끝나면 바뀜을 봄). */ + /** 고치는 판(예정노선 꼴 · 켠 동안 끊김 허용) · 판을 지은 꼭짓점. */ let network: PlanNetwork = { lines: [], pieces: [] }; let base: Vertex[] = []; - let strokeBase: PlanNetwork | null = null; + /** 켠 동안 사진첩(755-1) — `commit` 한 번 = 한 걸음 · 판을 다시 지으면 비움. */ + let undoStack: PlanNetwork[] = []; + let redoStack: PlanNetwork[] = []; + /** 재기 겹이 받던 때(켬끔 바뀜 = 잰 것 지움). */ + let measured = false; const line = (): Vertex[] => edit === null ? (saved.length > 1 ? saved : original) : edit.length > 1 ? edit : original; @@ -113,26 +132,28 @@ export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw { const shown = line(); base = (shown === original ? received() : shown).map(([x, y]): Vertex => [x, y]); const vertices = base.map(([x, y]): Vertex => [x, y]); + undoStack = []; + redoStack = []; return { lines: base.length > 1 ? [{ id: "main", vertices, branch: null }] : [], pieces: [] }; }; - - // ── 위치 칸 — 찾기 → 지도 가운데로(지형 받기 · 기준점 없음 · 기능 표 「terrain」 숨김) · 좌표 = 서버 변환(635-5) ── - const coords = createPlanCoords(params.projectId, () => kit.props.render()); - const find = createPlaceFind((hit) => { - void coords.toPlan(hit.lon, hit.lat).then((at) => { - const view = params.view(); - if (!at || view.width <= 0) return; - const [sx, sy] = params.toScreen(at); - params.frame().setView({ - ...view, - offsetX: view.offsetX + view.width / 2 - sx, - offsetY: view.offsetY + view.height / 2 - sy, - }); - params.redraw(); - }); + const measuring = (): boolean => open && params.measuring(); + /** 켠 채 판을 고침 — [저장] · 떠남이면 잃음(755-3). */ + const pending = (): boolean => open && undoStack.length > 0; + holdDesignSave(canvas, () => (pending() ? HELD : null)); + guardProjectLeave(canvas, { + dirty: pending, + save: async () => (showToast(HELD, "warning"), false), + message: HELD, }); + // 속성 판 경위도 = 서버 변환(635-5) + const coords = createPlanCoords(params.projectId, () => kit.props.render()); const others = createPlanContextLayer(params.projectId, params.redraw); + // 재기 = 예정노선 페이지 재기 한 벌(첫 누름 선 위 = 선 따라 · 밖 = 두 점 곧은 거리 · 높이 = 설계 지형 격자) + const measure = createPlanMeasure( + () => z, + () => network, + ); // ── 예정노선 키트(긋기 · 고르기 · 메뉴 · 속성 판 · 칸 = 예정노선 페이지와 같은 한 벌) ── const kit = createPlanKit({ @@ -146,21 +167,44 @@ export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw { heightAt: () => z, lonLat: coords.lonLat, editable: () => open && !busy, - idle: () => open, - onMode, + idle: () => open && !measuring(), + onMode: () => params.redraw(), onSelect: () => (kit.props.render(), params.redraw()), snaps: others.joints, - cards: { site: { parts: [find.root] } }, + onKey: (event) => + keyIs(event, "plan.undo") + ? (step("undo"), true) + : keyIs(event, "plan.redo") + ? (step("redo"), true) + : measuring() && event.key === "Escape" + ? (measure.clear(), true) + : false, + cards: {}, }); const { draw, edit: editor, props, menu } = kit; const layers = kit.layers([], []); + const measureLayers = kit.layers([], [measure.layer]); + /** 지금 겹 — 재기 켬이면 재기 겹(고르기 · 노드 끌기 쉼) · 켬끔이 바뀌면 잰 것 지움 · 긋기 끝. */ + const liveLayers = (): PlanMapLayer[] => { + const on = measuring(); + if (on !== measured) { + measured = on; + measure.clear(); + if (on && draw.mode() !== "idle") draw.setMode("idle"); + } + return on ? measureLayers : layers; + }; props.panel.classList.add("b05-routeedit__expected-props"); viewport.append(props.panel); menu.mount(viewport); // ── 그리기 칸 — 머리 「예정노선 수정」 · 받은 노선 · 길이 비교 · [받은 노선으로] ── const drawCard = kit.cards.find((card) => card.contains(draw.panel))!; - const toggle = createToggle({ label: "예정노선 수정", size: 20, onChange: (on) => setOpen(on) }); + const toggle = createToggle({ + label: "예정노선 수정", + size: 20, + onChange: (on) => void setOpen(on), + }); toggle.root.addEventListener("click", (event) => event.stopPropagation()); // 칸 접기로 안 올라감 drawCard.firstElementChild!.append(toggle.root); const originalToggle = createToggle({ @@ -182,10 +226,7 @@ export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw { row.className = "b05-routeedit__expected-row"; row.append(originalToggle.root, resetButton); drawCard.append(row, compare, jointHint, hint); - fillPlanPanel({ - site: kit.cards.find((card) => card.contains(find.root)) ?? null, - draw: drawCard, - }); + fillPlanPanel(drawCard); function syncCard(): void { const edited = changed(); @@ -196,15 +237,17 @@ export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw { compare.hidden = !edited; jointHint.textContent = others.note(line()) ?? ""; jointHint.hidden = !jointHint.textContent; - hint.textContent = !open ? OFF_HINT : network.lines.length ? HINT : EMPTY_HINT; + hint.hidden = open; // 켠 동안 설명 걷음(589-7) } - function setOpen(next: boolean): void { + async function setOpen(next: boolean): Promise { if (draw.mode() !== "idle") draw.setMode("idle"); + if (!next && open && !(await finish())) return toggle.set(true); open = next; asked = false; network = fromLine(); editor.select([]); + measure.clear(); draw.setEditable(open); toggle.set(open); if (open) { @@ -215,53 +258,56 @@ export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw { } syncCard(); props.render(); + params.onHistory(); params.redraw(); } - /** 꼴 한 걸음(예정노선 `commit`) — 긋는 중이면 판만 · 쉴 때(놓음 · 지움) = 다시 추적. */ + /** 꼴 한 걸음(예정노선 `commit`) — 켠 동안 판만 · 이 판 사진첩에 쌓음(평면선 = 끌 때 한 번). */ function commit(next: PlanNetwork): void { + undoStack.push(network); + redoStack = []; network = next; - props.render(); - params.redraw(); - if (draw.mode() === "idle") void apply(); - } - - /** 긋기 들어감 = 판 기억 · 긋기 끝 = 바뀌었으면 다시 추적. */ - function onMode(): void { - if (draw.mode() === "draw") { - strokeBase ??= network; - return params.redraw(); - } - const before = strokeBase; - strokeBase = null; - if (before && JSON.stringify(before.lines) !== JSON.stringify(network.lines)) void apply(); + kit.sync(); // 지운 고름 · 판 맞춤(예정노선 페이지 `refresh` 와 같음) + params.onHistory(); params.redraw(); } - function revert(): void { - network = fromLine(); + function step(which: "undo" | "redo"): void { + const [from, to] = which === "undo" ? [undoStack, redoStack] : [redoStack, undoStack]; + const last = from.pop(); + if (!last) return; + if (draw.mode() !== "idle") draw.setMode("idle"); + to.push(network); + network = last; + editor.select([]); kit.sync(); - syncCard(); + params.onHistory(); params.redraw(); } - /** 고친 판 → 다시 추적(한 선 · 점 2 이상 · 판을 지은 꼭짓점과 다를 때만). */ - async function apply(): Promise { - if (busy || !open) return; + /** 끌 때 한 번 — 끊겼으면 알림 · 안 끔 · 바뀌었으면 다시 추적(실패 · 물음 취소 = 안 끔). 참 = 꺼도 됨. */ + async function finish(): Promise { + if (busy) return false; const drawn = network.lines.filter((entry) => entry.vertices.length > 1); - syncCard(); - if (!drawn.length) return; // 다 지움 — 새로 그을 때까지 평면 그대로 if (drawn.length > 1) { - showToast("예정노선은 한 선입니다.", "info"); - return revert(); + showToast( + `예정노선이 ${drawn.length} 토막으로 끊겨 있습니다 — 선 끝에서 이어 긋거나 [잇기]로 이은 뒤 끕니다.`, + "info", + ); + return false; + } + if (!drawn.length) { + if (base.length > 1) + showToast("예정노선이 비어 고친 것을 버렸습니다 — 평면선 그대로.", "info"); + return true; } const points = drawn[0].vertices.map(([x, y]): Vertex => [x, y]); - if (!sameLine(points, base)) await follow(points, points); + return sameLine(points, base) || (await follow(points, points)); } - /** 켠 뒤 처음만 확인창 → `/route/follow`(저장 0) → 편집기 한 걸음(초안 `expected` = `expected`). */ - async function follow(points: Vertex[], expected: Vertex[]): Promise { - if (busy || points.length < 2) return; + /** 확인창(켠 뒤 처음만) → `/route/follow`(저장 0) → 편집기 한 걸음(초안 `expected` = `expected`). 참 = 됨. */ + async function follow(points: Vertex[], expected: Vertex[]): Promise { + if (busy || points.length < 2) return false; if (!asked) { const ok = await askConfirm({ title: "평면선 다시 추적", @@ -270,7 +316,7 @@ export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw { "받은 노선(원본)은 그대로 · [저장] 전까지는 되돌리기로 물릴 수 있습니다.", okLabel: "다시 추적", }); - if (!ok) return revert(); + if (!ok) return false; asked = true; } busy = true; @@ -286,12 +332,13 @@ export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw { flattenServerPlan(plan.nodes ?? [], plan.curves ?? [], plan.node_edits), expected, ); + return true; } catch (error) { showToast( `평면선을 다시 추적하지 못했습니다.${error instanceof Error ? ` ${error.message}` : ""}`, "error", ); - revert(); + return false; } finally { busy = false; syncCard(); @@ -331,14 +378,13 @@ export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw { return params.rotation().unrotate(event.clientX - rect.left, event.clientY - rect.top); }, screen: map, - layers: () => layers, + layers: liveLayers, redraw: params.redraw, capture: { active: () => open, busy: () => busy }, }); const onKey = (event: KeyboardEvent): void => { if (!open) return; - if (keyIs(event, "plan.undo") || keyIs(event, "plan.redo")) return; // 되돌리기 = 페이지 사진첩 if (!kit.key(event)) return; event.stopImmediatePropagation(); event.preventDefault(); @@ -357,16 +403,15 @@ export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw { if (i) context.lineTo(sx, sy); else context.moveTo(sx, sy); }); - context.globalAlpha = 0.7; - context.setLineDash([2, 5]); - context.lineWidth = 2; - context.strokeStyle = themeColor("--draw-text-secondary", "#9ca3af"); // 받은 노선 = 옅은 점선 - context.stroke(); + context.lineCap = "round"; + context.lineJoin = "round"; + // 받은 노선 = 분홍 점선 + 검은 테(589-6 · 계획 주황 · 예정 민트 · 추천 노랑 · 등고선과 가름 · 테마 안 따름) + strokeCased(context, themeColor("--draw-map-route-3", "#ff8cbc"), 2.5, [8, 5]); context.restore(); } if (!open) return; const screen = map(); - for (const layer of layers) { + for (const layer of liveLayers()) { context.save(); layer.draw(context, screen); // 고치는 예정노선 = 맨 위(D9) · 고름 · 미리보기 context.restore(); @@ -385,9 +430,14 @@ export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw { busy, compare: compare.textContent, joint: jointHint.textContent, + undo: undoStack.length, + redo: redoStack.length, + measuring: measuring(), }), + measure: () => measure.result(), others: others.probe, line, + network: () => network, original: () => original, source: () => source, edit: () => edit, @@ -418,7 +468,7 @@ export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw { setEdit(next) { edit = next; syncCard(); - if (draw.mode() !== "idle") return; // 긋는 중 되돌림 — 긋기가 끝나면 그 판으로 다시 추적 + if (draw.mode() !== "idle") return; // 긋는 중 = 긋기 판 그대로 network = fromLine(); editor.sync(); props.render(); @@ -427,5 +477,14 @@ export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw { line, paint, dim: () => (open ? parseFloat(themeColor("--draw-map-dim", "0.35")) || 0.35 : 1), + history: () => + open + ? { + undo: () => step("undo"), + redo: () => step("redo"), + canUndo: () => undoStack.length > 0, + canRedo: () => redoStack.length > 0, + } + : null, }; } diff --git a/B05_Profile/B05_Profile_UI_Panel_Plan.ts b/B05_Profile/B05_Profile_UI_Panel_Plan.ts index 769258e6c..15d7656f8 100644 --- a/B05_Profile/B05_Profile_UI_Panel_Plan.ts +++ b/B05_Profile/B05_Profile_UI_Panel_Plan.ts @@ -1,9 +1,9 @@ /* ============================================================================= * B05_Profile_UI_Panel_Plan.ts - * 종단 보기 왼쪽 「위치」 · 「그리기」 칸 자리(PLAN 635-3 · 요구 546) — 예정노선 키트(`createPlanKit` 「b05」) 칸을 앉힘. - * 칸은 페이지가 먼저 세우고 편집기는 노선을 읽은 뒤 섬 → 칸 = 빈 자리(order 1 · 2) · 편집기가 `fillPlanPanel` 로 채움. + * 종단 보기 왼쪽 「그리기」 칸 자리(PLAN 635-3 · 755-2 · 요구 546 · 589-8 위치 칸 걷음) — 예정노선 키트(`createPlanKit` 「b05」) 칸을 앉힘. + * 칸은 페이지가 먼저 세우고 편집기는 노선을 읽은 뒤 섬 → 칸 = 빈 자리(order 1) · 편집기가 `fillPlanPanel` 로 채움. * 늦게 온 쪽이 붙임 · 새 칸 자리(페이지 다시 세움) = 떠난 편집기 칸(문서 밖) 버림 · 비면 자리 숨김. - * 좌표 변환(635-5) = 서버(`POST /route/coords` · 사업지 좌표계 pyproj) — 찾기 이동 · 속성 판 경위도 때만 부름. + * 좌표 변환(635-5) = 서버(`POST /route/coords` · 사업지 좌표계 pyproj) — 속성 판 경위도 때만 부름. * ========================================================================== */ import { registerPanelSection } from "../A00_Common/design/design_panel"; @@ -11,55 +11,43 @@ import { requestJson } from "./B05_Profile_Api_Replan"; type Vertex = [number, number]; -type Slot = "site" | "draw"; -const holders: Partial> = {}; -const cards: Partial> = {}; +let holder: HTMLElement | null = null; +let card: HTMLElement | null = null; -const attach = (slot: Slot): void => { - const holder = holders[slot]; +const attach = (): void => { if (!holder) return; - const card = cards[slot]; holder.replaceChildren(...(card ? [card] : [])); holder.hidden = !card; }; -for (const [slot, order, title] of [ - ["site", 1, "위치"], - ["draw", 2, "그리기"], -] as const) - registerPanelSection({ - id: `plan-${slot}`, - title, - views: ["profile"], - order, - build: () => { - const holder = document.createElement("div"); - holder.className = "b05-route__plan-slot"; - holders[slot] = holder; - if (!cards[slot]?.isConnected) cards[slot] = null; // 떠난 편집기 칸 - attach(slot); - return holder; - }, - }); +registerPanelSection({ + id: "plan-draw", + title: "그리기", + views: ["profile"], + order: 1, + build: () => { + holder = document.createElement("div"); + holder.className = "b05-route__plan-slot"; + if (!card?.isConnected) card = null; // 떠난 편집기 칸 + attach(); + return holder; + }, +}); /** 키트 칸을 앉힘(새 편집기가 갈음). */ -export function fillPlanPanel(next: Record): void { - for (const slot of ["site", "draw"] as const) { - cards[slot] = next[slot]; - attach(slot); - } +export function fillPlanPanel(next: HTMLElement | null): void { + card = next; + attach(); } export interface PlanCoords { - /** 경위도 → 사업지 [x, y] m(못 받으면 null). */ - toPlan: (lon: number, lat: number) => Promise; /** 사업지 → 경위도 — 받은 점이면 바로 · 처음이면 null 주고 한 번 물음(오면 `onLonLat`). */ lonLat: (x: number, y: number) => Vertex | null; } -/** 경위도 ↔ 사업지 좌표 서버 변환 · 같은 점은 캐시(끌기 중엔 안 부름 — 판은 고름 · 놓음 때만 그림). */ +/** 사업지 → 경위도 서버 변환 · 같은 점은 캐시(끌기 중엔 안 부름 — 판은 고름 · 놓음 때만 그림). */ export function createPlanCoords(projectId: string, onLonLat: () => void): PlanCoords { - const ask = (to: "plan" | "lonlat", point: Vertex): Promise => + const ask = (to: "lonlat", point: Vertex): Promise => requestJson<{ points: Vertex[] }>( `/projects/${projectId}/route/coords`, { method: "POST", body: JSON.stringify({ to, points: [point] }) }, @@ -70,7 +58,6 @@ export function createPlanCoords(projectId: string, onLonLat: () => void): PlanC ); const known = new Map(); return { - toPlan: (lon, lat) => ask("plan", [lon, lat]), lonLat(x, y) { const key = `${x},${y}`; if (known.has(key)) return known.get(key) ?? null; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 20c5f1a06..a92848894 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -129,9 +129,10 @@ export async function mountRouteEditor( rotation: () => rotation, pxPerMeter: () => pxPerMeter(), view: () => view, - frame: () => ({ meta, setView: (next: ViewState) => (view = next) }), redraw: () => (draw(), sheetFollow?.poke()), // 켠 동안 예정노선이 도엽 밖으로 가면 다시 받음(589-10) onOpen: () => ((picked = -1), syncCurveBar()), + measuring: () => measureControls.mode(), // 켠 동안 재기 = D03 재기 겹(755-2) + onHistory: () => historyControls.sync(), // 켠 동안 되돌리기 = 예정노선 판(755-1) onFollow: (flat, line) => restoreSnapshot( { ...flat, apexLock: [], picked: -1, rec: initial.tag(), expected: line }, @@ -500,6 +501,7 @@ export async function mountRouteEditor( const historyControls = bindHistoryControls({ overlay, getHistory: () => history, + local: () => expectedDraw.history(), restore: (snapshot, message) => { initial.restoreTag(snapshot.rec); // 「현재 추천」도 그 걸음 것으로 — 노선과 짝. restoreSnapshot(snapshot, message, false); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_History.ts b/B05_Profile/B05_Profile_UI_RouteEdit_History.ts index b2ad1504b..ed3f22127 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_History.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_History.ts @@ -143,6 +143,13 @@ export interface HistoryControlsParams { overlay: HTMLElement; /** 아직 노선을 못 읽었으면 null 이다(단추는 꺼진 채로 둔다). */ getHistory: () => RouteEditHistory | null; + /** 예정노선 수정 켠 동안 사진첩(755-1) — 있으면 단추가 이쪽을 씀. */ + local?: () => { + undo: () => void; + redo: () => void; + canUndo: () => boolean; + canRedo: () => boolean; + } | null; /** 사진 한 벌을 화면에 되살린다. */ restore: (snapshot: RouteEditSnapshot, message: string) => void; } @@ -162,12 +169,14 @@ export function bindHistoryControls(params: HistoryControlsParams): { const redoBtn = overlay.querySelector('[data-act="redo"]')!; const sync = (): void => { - const history = getHistory(); + const history = params.local?.() ?? getHistory(); undoBtn.disabled = !history?.canUndo(); redoBtn.disabled = !history?.canRedo(); }; const step = (which: "undo" | "redo"): void => { + const local = params.local?.(); + if (local) return (local[which](), sync()); const history = getHistory(); if (!history) return; const snapshot = history[which](); diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index e6342c6ee..03cf5124a 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -557,6 +557,14 @@ export function createStructuresSection( groupSelect.replaceChildren(new Option("— 선택 —", ""), ...groupOptions(next, pick)); groupSelect.value = ""; syncTypeOptions(); + // 늦게 온 종류 목록 = 고른 관 폼을 새 목록으로 다시 엶 — 고름만 남고 폼이 비던 흠(754-2) · + // 구조물 폼은 뒤따르는 `setStructures` 가 다시 엶 · 임시 배치 중 입력은 둠 + if (tempPipeChainage !== null) return; + const at = selectedPipeChainage; + const pipe = pipeFacilities.find( + (entry) => at !== null && Math.abs(entry.chainage_m - at) < PIPE_MATCH_M, + ); + if (pipe) loadPipeForm(pipe); }, setStructures(next) { // 서버 정본 주입 — onChange를 울리지 않는다. 울리면 Page가 다시 저장을 걸어 diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Features.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Features.ts index b3b650a14..479f873b1 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Features.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Features.ts @@ -34,7 +34,12 @@ export interface PlanFeatureRow { } export const PLAN_FEATURES: readonly PlanFeatureRow[] = [ - { key: "site", name: "주소 · 장소 찾기(B05 = 지도 이동만)", pages: { d03: "use", b05: "use" } }, + { + key: "site", + name: "주소 · 장소 찾기", + pages: { d03: "use", b05: "hide" }, + why: "B 사업지 = 전처리 확정", + }, { key: "draw", name: "긋기 네 방식 · 길이 · 기울기 · Tab · Shift · 부채꼴 · ± 색", @@ -92,9 +97,8 @@ export const PLAN_FEATURES: readonly PlanFeatureRow[] = [ }, { key: "join", - name: "[잇기] 두 선 끝 이음", - pages: { d03: "use", b05: "hide" }, - why: "B 계획선 한 줄", + name: "[잇기] 두 선 끝 이음(B05 = 켠 동안 끊긴 곳 이음)", + pages: { d03: "use", b05: "use" }, }, { key: "others", diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts index 0c524a5a8..69607ffe8 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts @@ -158,7 +158,8 @@ export function buildFormula(host: HTMLElement, ctx: FormulaContext): void { onInput ? specs.find((x) => x.이름 === name) : undefined; // 737-2 비목마다 구분 상자 — 맨 위 옵션 칸 · 아래 두 칸 식 줄들 · 소계 const boxes = text.groups.map((g) => { - const lines = g.줄.map((line, i) => { + const visible = g.줄.filter((l) => !l.옵션꺼짐); // 753-2 옵션으로 빠진 줄은 안 그림 + const lines = visible.map((line, i) => { const pair = String(line.짝 ?? `${g.비목}${i}`); const open = ctx.open?.(line); return el("div", { diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts index c7df158cc..563c5a692 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts @@ -615,9 +615,12 @@ function boxCard( const notes = [...new Set(lines.map((e) => e.textLine?.환산근거).filter(Boolean))]; const { root, tbody } = tableShell(COLS, { density: "data" }); root.classList.add("m01lab__ho"); + // 753-2 옵션으로 빠진 줄은 안 그림(옵션 칸 · 소계는 그대로 — 옵션을 바꾸면 보이는 줄이 바뀜) + const off = (e: Entry): boolean => !!(e.line?.옵션꺼짐 || e.textLine?.옵션꺼짐); + const rows = b.줄.filter((r) => !("몫" in r ? r.몫.every(off) : off(r))); tbody.append( - ...(b.줄.length - ? b.줄.flatMap((r) => ("몫" in r ? mergedRows(r, ctx) : [lineRow(r, ctx)])) + ...(rows.length + ? rows.flatMap((r) => ("몫" in r ? mergedRows(r, ctx) : [lineRow(r, ctx)])) : [emptyRow(COLS, tl("Empty_Group"))]), ); const sub = tableRow(COLS, [ diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts index 0176da2d4..d4cae374e 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts @@ -105,6 +105,8 @@ export interface CalcLine { 까닭?: string; /** (M02 744-1) 둘째 기초 세부 줄 — `수량`은 M01 원문 · 이 칸은 1 기준단위 몫 비목(금액 · 비목은 머리 수량 곱한 상자 몫) */ 원문?: { 비목?: Record }; + /** (753-1) 옵션으로 빠진 줄 — 화면은 안 그림 */ + 옵션꺼짐?: boolean; } /** % 덧줄 표기(149-4) — 율 있으면 수량 = 율 · 단위 % · 이름 뒤 규격 괄호 · 금액은 그대로(M01 · M02 한 벌) */ @@ -160,6 +162,8 @@ export interface TextLine { 환산근거?: string; /** (M02 750-1) SK 셈 답 줄 cid — 상세 줄과 이음 */ cid?: string; + /** (753-1) 옵션으로 빠진 줄(수량 0 이 만약 갈래 탓) — 화면은 그 줄을 안 그림(금액 · 차례 그대로) */ + 옵션꺼짐?: boolean; } /** `POST /text` 답 — 서버가 만든 읽는 식 줄(화면은 그대로 찍음) */ export type TextAnswer = diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts b/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts index 5b282de38..5014c2dfa 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts @@ -262,7 +262,8 @@ export function answerView(answer: CalcAnswer, draft: boolean, money: boolean): } const parts: HTMLElement[] = [...note]; if (answer.lines) { - const rows = answer.lines.map((line) => + const visible = answer.lines.filter((line) => !line.옵션꺼짐); // 753-2 옵션으로 빠진 줄은 안 그림 + const rows = visible.map((line) => el("tr", { children: [ el("td", { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts index 8b90df4f8..35dee7c48 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts @@ -26,7 +26,7 @@ import type { LaborCalc } from "./M02_MasterTemplete_Labor_Api"; import { markCells, markHeads } from "./M02_MasterTemplete_Labor_MatMarks"; import { moneyCell, PRICE_SLOTS } from "./M02_MasterTemplete_Labor_Rows"; import { tc } from "./M02_MasterTemplete_Labor_Text"; -import { formatPrice } from "../common_util/common_util_money"; +import { formatNumber, formatPrice } from "../common_util/common_util_money"; import { tableOf } from "./logic/M02_MasterTemplete_Hopyo_Draft"; /** 드롭다운 — 저장 값(서버 뺌 까닭) → 화면 글 */ @@ -62,8 +62,7 @@ export interface RowsContext { options: (line: HopyoLine, at: number) => void; } -const num = (v: number): string => - Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 }); +const num = formatNumber; const small = (text: string, cls = "m02-labrow__note"): HTMLElement => el("small", { className: cls, text, attrs: { title: text } }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Compare.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Compare.ts index 80426135e..827e5cdf5 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Compare.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Compare.ts @@ -12,6 +12,7 @@ import { t as L, type LocaleKey } from "@ui/ui_template_locale"; import { createSegment } from "@ui/ui_template_segment"; import { createTable } from "@ui/ui_template_table"; import { createText } from "@ui/ui_template_text"; +import { formatNumber, formatPrice } from "../common_util/common_util_money"; import type { CompareLook, ElemCand, @@ -26,7 +27,7 @@ const LOOK: Record = { 기초만: { tone: "neutral", key: "M02_CmpBase" }, }; const LOOKS = Object.keys(LOOK) as CompareLook[]; -const num = (n: number | null): string => (n === null ? "—" : String(Number(n.toPrecision(4)))); +const num = (n: number | null): string => (n === null ? "—" : formatNumber(n)); /** 선택 칸 글 뒤 요약 — 「같음 3 · 품 다름 1」 · 견줌 못함 = "" */ export const compareBrief = (c: SangeunCompare | null | undefined): string => @@ -112,7 +113,7 @@ export function elemChoices(cands: ElemCand[], pick: (c: ElemCand) => void): HTM const box = el("div", { className: "ui-toolbar", attrs: { "data-elem": "" } }); box.title = L("M02_ElemTitle"); for (const c of cands.slice(0, 3)) { - const price = c.단가 ? ` · ${c.단가.toLocaleString()}` : ""; + const price = c.단가 ? ` · ${formatPrice(c.단가)}` : ""; const b = createButton({ label: `${c.이름}${c.규격 ? ` ${c.규격}` : ""} [${c.단위 ?? ""}]${price}`, size: 24, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts index c9cb1e618..04549c6a8 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts @@ -38,6 +38,7 @@ import type { Layer } from "./M02_MasterTemplete_Api_Fetch"; import { baseHeads } from "./M02_MasterTemplete_Sangeun_BaseHead"; import { calcSangeun, removedStrip } from "./M02_MasterTemplete_Sangeun_Cover"; import { openSangeunLinePicker } from "./M02_MasterTemplete_Sangeun_Pick"; +import { createSangeunSide, tabOf, type SourceTab } from "./M02_MasterTemplete_Sangeun_Side"; import { baseUnitOf, buildUnitSwap, type SwapHow } from "./M02_MasterTemplete_Sangeun_Unit"; import { dropBase, @@ -63,6 +64,8 @@ export interface SangeunDetailOptions { owner: string | null; readOnly: boolean; onChanged: (dirty: boolean) => void; + /** 752-1 오른쪽 원문 패널 — 단가산출 화면만 켬(일위대가 줄 모달 안 상세는 끔) */ + source?: boolean; } const LIB = { read: "/m02/logic-lib", calc: "/m02/logic-lib" }; @@ -90,6 +93,9 @@ export function mountSangeunDetail( let fail = ""; let extra: LogicInput[] = []; // 더한줄 로직 입력 중 바탕에 없는 것 = 옵션 칸(725-6 · 727-3) const inputsOf = new Map(); + const infoOf = new Map(); // 더한줄 로직 → 원문 탭(752-1) + let extraKeys: string[] = []; + const side = opt.source ? createSangeunSide() : null; let seq = 0; let alive = true; @@ -141,19 +147,17 @@ export function mountSangeunDetail( touch(false); void loadBase(true); } - const baseHint = createText(L("M02_SangeunBaseViews")); - host.replaceChildren( - el("div", { - className: "m02-sk", - attrs: { "data-sangeun-detail": doc.키 ?? "" }, - children: [ - head, - notes, - el("div", { className: "m02-sk__head", children: [views.root, addButton, baseHint] }), - editor, - ], - }), - ); + const main = el("div", { + className: "m02-sk", + attrs: { "data-sangeun-detail": doc.키 ?? "" }, + children: [ + head, + notes, + el("div", { className: "m02-sk__head", children: [views.root, addButton] }), + editor, + ], + }); + host.replaceChildren(side ? side.wrap(main) : main); const opened = (): boolean => (doc.열린입력 ?? []).length > 0; // 바탕 없는 SK 의 지문 = 이름 · 단위 따라 바뀜 — 바탕이 있을 때만 M01 개정으로 봄(725-3) · 둘째 기초 지문(727-2) @@ -185,11 +189,15 @@ export function mountSangeunDetail( .filter((k) => !inputsOf.has(k)) .map((k) => fetchLogic(k).then( - (r) => void inputsOf.set(k, r.logic.입력 ?? []), + (r) => { + inputsOf.set(k, r.logic.입력 ?? []); + infoOf.set(k, tabOf(r.logic)); + }, () => void inputsOf.set(k, []), ), ), ); + extraKeys = keys; const seen = new Set((base?.logic.입력 ?? []).map((i) => i.이름)); extra = keys .flatMap((k) => inputsOf.get(k) ?? []) @@ -306,7 +314,6 @@ export function mountSangeunDetail( if (!alive) return; drawHead(); drawNotes(); - baseHint.hidden = !base; const rows = ans ? rowsOf(ans, bare, doc) : []; const names = new Map((bare ? rowsOf(bare, null, {}) : []).map((r) => [r.cid, r.이름])); const removed = [ @@ -382,6 +389,10 @@ export function mountSangeunDetail( cover: { ho, est, open: lineOpener(hoCtx) }, // 748-2 텍스트 수식 변수 · 값 누름 = 같은 줄 모달 }); drawCalc(); + side?.set([ + ...(base ? [tabOf(base.logic)] : []), + ...extraKeys.flatMap((k) => (k === baseKey() ? [] : infoOf.has(k) ? [infoOf.get(k)!] : [])), + ]); } /** 재료 고름(줄 모달 · 시험 계산 재료 칸) = SK 문서 `고름`(cid → MT) — 둘째 기초 세부 줄은 보기만 */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Side.ts new file mode 100644 index 000000000..f75ee3381 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Side.ts @@ -0,0 +1,83 @@ +/* ============================================================================= + * M02_MasterTemplete_Sangeun_Side.ts + * 단가산출(SK) 상세 오른쪽 원문 패널(PLAN 752-1 · 요구 588-3) — 일위대가 상세(47-4 · 85-4)와 같은 부품: + * 오른쪽 서랍(`createSidePanels`) + 원문 보기(`createSourceView` · M02 로그인 길 `/m02/logic-lib/source`) + * 기초마다 탭 — 바탕 로직 · 둘째 기초(더한줄 로직) 각각의 출처 · 기초가 하나면 탭 없음 · 없으면 안내 + * 처음 = 넓은 화면만 열림(좁으면 세로 띠로 접힘) · 열림 · 폭 기억 = `m02-sangeun-side` + * 일위대가 줄 모달 안 상세는 안 만듦(부르는 쪽이 `source` 를 안 켬). + * ========================================================================== */ + +import { createSegment } from "@ui/ui_template_segment"; +import { el } from "@ui/ui_template_elements"; +import { t as L } from "@ui/ui_template_locale"; +import { createSidePanels } from "@ui/ui_template_side_panels"; +import { createText } from "@ui/ui_template_text"; +import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; + +export interface SourceTab { + label: string; + src: string; +} + +/** 기초 로직 → 원문 탭(「원문번호 이름」 · 출처 글) */ +export const tabOf = (logic: { + 키: string; + 원문번호?: string; + 이름: string; + 출처?: string; +}): SourceTab => ({ + label: `${logic.원문번호 || logic.키} ${logic.이름}`, + src: logic.출처 ?? "", +}); + +/** 이 폭 이상일 때만 처음부터 열림 — 상세 표 여섯 칸이 좁아지지 않게 */ +const OPEN_FROM = 1500; + +export function createSangeunSide(): { + wrap: (main: HTMLElement) => HTMLElement; + set: (tabs: SourceTab[]) => void; +} { + const view = createSourceView({ + path: "/m02/logic-lib/source", + pdf: "/m02/logic-lib/source-pdf", + }); + const host = el("div", { attrs: { "data-sangeun-source": "" } }); + let tabs: SourceTab[] = []; + let at = 0; + const side = createSidePanels({ + prefKey: "m02-sangeun-side", + follow: false, // 일위대가와 같음 — 상세 고정 높이 칸을 채움 · 본문과 따로 스크롤 + defaultOpen: window.innerWidth >= OPEN_FROM ? "source" : null, + panels: [{ id: "source", title: tc("Labor_Source"), body: host }], + onChange: () => paint(), + }); + function paint(): void { + if (side.current() !== "source") return; // 접힘 = 찾지 않음 + if (!tabs.length) + return void host.replaceChildren(createText(L("M02_SangeunSourceNone"), "muted")); + at = Math.min(at, tabs.length - 1); + view.show(tabs[at].src); + const bar = + tabs.length > 1 + ? [ + createSegment({ + options: tabs.map((x, i) => ({ value: String(i), label: x.label })), + value: String(at), + size: 28, + ariaLabel: tc("Labor_Source"), + onChange: (v) => ((at = Number(v)), paint()), + }).root, + ] + : []; + host.replaceChildren(...bar, view.root); + } + return { + wrap: (main) => el("div", { className: "ui-side-panels-row", children: [main, side.root] }), + set: (next) => { + if (JSON.stringify(next) === JSON.stringify(tabs)) return; + tabs = next; + paint(); + }, + }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_View.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_View.ts index e31ca48ec..4a3b91584 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_View.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_View.ts @@ -18,5 +18,5 @@ export function mountSangeunView( doc: unknown, opt: SangeunViewOptions, ): { getDoc: () => unknown; destroy: () => void } { - return mountSangeunDetail(host, doc, { ...opt, owner: opt.owner ?? null }); + return mountSangeunDetail(host, doc, { ...opt, owner: opt.owner ?? null, source: true }); // 752-1 } diff --git a/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts b/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts index 665688e37..f5b0a1a44 100644 --- a/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts +++ b/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts @@ -91,6 +91,8 @@ export interface SkLine { 원문?: { 수량: number | null; 금액: number | null; 비목?: Record }; /** 재료 줄이 잡은 품목(시험 계산 재료 칸 · 줄 모달) */ 품목?: CalcLine["품목"]; + /** (753-1) 옵션으로 빠진 줄 — 화면은 안 그림 */ + 옵션꺼짐?: boolean; } export interface SkMat { @@ -153,6 +155,8 @@ export interface CoverRow { sub: { k: number; cid: string; scale: number } | null; /** 화면배(728-2) — 문서 품 = 화면 품 × t · `was` = 문서 몫 */ t: number; + /** (753-2) 옵션으로 빠진 줄 — 계산서에서 안 그림 */ + off: boolean; } /* ── 순수 셈 ── */ @@ -239,7 +243,13 @@ export function rowsOf(ans: SkAnswer, bare: SkAnswer | null, doc: SkDoc): CoverR 금액: number | null; }, parts: Record, - extra: { 묶음?: string | null; 율?: number; apart?: boolean; 바탕값?: SkLine["바탕값"] }, + extra: { + 묶음?: string | null; + 율?: number; + apart?: boolean; + 바탕값?: SkLine["바탕값"]; + 옵션꺼짐?: boolean; + }, ): CoverRow => { const hit = ADD_RE.exec(cid); const added = hit ? Number(hit[1]) : null; @@ -283,6 +293,7 @@ export function rowsOf(ans: SkAnswer, bare: SkAnswer | null, doc: SkDoc): CoverR apartPick: one?.종류 === "재료", sub: at ? { ...at, scale: Number(owner?.수량 ?? 1) } : null, t: pct || at ? 1 : t, // 세부 줄 품 = M01 원문 그대로(744-1) — 화면배 안 곱함 + off: !!extra.옵션꺼짐, }; }; for (const l of flatLines(ans)) rows.push(row(l.cid, l, partsOf(l), l)); @@ -422,6 +433,7 @@ export function sheetGroups(rows: CoverRow[]): SheetGroup[] { 금액: amount, 묶음: r.묶음, 장비: entries.length > 1 && (r.sub?.cid ?? r.cid).startsWith("logic:"), + ...(r.off ? { 옵션꺼짐: true } : {}), }); } } diff --git a/docs/raw/plans/2026-11-02_plan_736-757.md b/docs/raw/plans/2026-11-02_plan_736-757.md new file mode 100644 index 000000000..a765e8943 --- /dev/null +++ b/docs/raw/plans/2026-11-02_plan_736-757.md @@ -0,0 +1,172 @@ +# 계획 이관 — 736 ~ 757 (2026-11-02) + +## 736 일위대가 [항목 추가] · 찾기 창 하나로 (요구 568 · 570 · 575-1 · sub7) + +- 사용자 확정(575-1 동의): 일위대가표 · 구조물 조합 카드 [로직 추가] → [항목 추가] · [자재 추가] 걷음 · 불러오기 엑셀 연결 · 별칭 장부 찾기 = 새 찾아 넣기 창 +- 정함(브레인): 재료 고름 = 「자재 목록」 자리 · 중복 검사(로직 안 자재 · 이미 있음) 그대로 · 자리마다 맞지 않는 탭 막음(별칭 장부 = 기초 · 자재만) · B09 단가 연결은 그대로(M02 밖) · 부품 모음 견본에 734 갈래 색 단추(옵션 · 요소 연결) 한 칸 +- [x] 736-1 일위대가표 · 구조물 조합 카드 단추 · 안내 글 · 재료 → 자재 목록 · 불러오기 엑셀 연결(`Hopyo_Choose.ts:300-318`) · 별칭 장부(`Hopyo_Ledger.ts:219`) → 찾아 넣기 창(탭 막음) · 견본 — 확인 = 단추 하나 · 재료 넣기 → 자재 목록 · 중복 막힘 · 엑셀 연결 한 창에서 기초 · 재료 · 기계 · 단가산출 · 사진 + - 검증완료 — 시험 55 통과(721 · 717-4 · 175 · 자재 개인 · 호표 화면 · 729-3 · 호표 흐름) · typecheck 0 · 일위대가표 · 구조물 조합 상세 단추 [항목 추가] 하나 · 재료 11,354 → 자재 목록 · 같은 자재 둘째 = 「이미 담은 자재」 막힘 · 엑셀 연결 자재 줄 = 재료 탭만(탭 줄 없음) · 부품 모음 견본 칸 밝음 · 어두움 · 사진 밝음 · 어두움 · 좁은 폭(860) · 임시 일위대가 지움 · 별칭 장부는 화면 줄 없어 글 시험으로만 · 개인 층 [신규 자재] 길은 단추와 함께 빠짐(M02_Material_Pick.ts 는 남김) +- [x] 736-2 (sub7) 빠진 길 둘 — 개인 층 [신규 자재](옛 [자재 추가] 단추 안 길 · `M02_Material_Pick.ts`)를 찾아 넣기 창 재료 탭에 「신규 자재」로 · 엑셀 연결 창 = 줄 갈래대로 탭(노임 줄 = 인력 · 기계 줄 = 기계(M01 EQ) · 단가산출 줄 = 기초 · 단가산출 · 일위대가 · 자재 줄 = 재료) — 확인 = 개인 층 신규 자재 만들어 넣기 · 노임 · 기계 엑셀 줄 이음 · 사진 + - 검증완료 — 시험 55 통과(721 · 717-4 · 175 · 자재 개인 · 호표 화면 · 729-3 · 호표 흐름 · 신규 736-2 글 시험) · typecheck 0 · 개인 층 [신규 자재] → 자재 목록 줄(PM000002 · 원 1,234) · 기계 줄 고르기 = 기계 탭 단독(EQ 25 줄) · 노임 줄 = 인력 탭 단독 · 남은 줄 3 → 2 → 1 · 금액 계산 코드 안 건드림 · 밝음 · 어두움 · 좁은 폭 · 높이 768 · 임시 문서 · 실무자료 · 개인자재 지움 +- [x] 736-3 (sub7) 기계(M01 EQ) — 단가산출 · 일위대가 [항목 추가] 찾아 넣기 창에서 기계 고름 = 고정 줄(이음 기계 · 단가 = 한 시간 노 · 재 · 경 · 서버 `/logic-lib/machine-hourly`) + - 검증완료 — 시험 21 통과(721 · 717-4 · 705-1) · typecheck 0 · EQ000019 굴착기 0.6㎥ 한 시간 81,811.6(노 35,412.1 · 재 22,976.5 · 경 23,423.0) · 일위대가 수량 1 금액 81,811.6 · 단가산출 계 81,812 · 임시 문서 지움 + +## 737 M02 단가산출 상세 옵션 6 열 · 합계 한 줄 바닥 고정 (요구 576 · sub2) + +- 사용자 확정: 공통 옵션 · 인력 · 재료 · 경비 옵션 칸 = 6 열(좁으면 줄어 1 열까지) · 노무비 · 재료비 · 경비 · 계 = 1 행 · 화면 맨 아래 고정 +- 정함(브레인): 같은 공용 상세를 쓰는 M01 단가산출 상세 · 일위대가 줄 모달도 같은 꼴(모달은 모달 바닥에 고정) · 열 수 = 칸 폭 따라 6 → 4 → 3 → 2 → 1 · 부품 상자 꼴 +- [x] 737-1 옵션 칸 6 열 격자(좁으면 줄어듦) · 합계 줄 1 행 바닥 고정 — 확인 = 1920 · 1366 · 768 · 390 폭 열 수 · 합계 줄 굴려도 보임 · 금액 그대로 · 사진(밝음 · 어두움) + - 검증완료 — 옵션 열 수 1920 → 6 · 1366 → 6 · 768 → 4 · 390 → 1(상자 폭 기준 300 · 450 · 600 · 900) · 합계 줄 한 줄 4 칸(윗면 같음) · 굴림 위 · 아래 모두 화면 바닥 고정(foot 바닥 1080 = 높이 · 끝에서 자리) · 일위대가 줄 모달 안 바닥 고정(모달 바닥 863 · 768 높이 697) · 금액 24,673 전후 같음 · M01 상세도 같은 꼴 · 시험 29 통과(신규 737 지킴 5) · typecheck 0 · ORCA 사용자 길 확인(6 열 · 바닥 1093 = 높이) · 밝음 · 어두움 +- [x] 737-2 (요구 577) 텍스트 수식 보기 = 노무비 · 재료비 · 경비 구분 상자 · 상자 맨 위 옵션 고르기(6 열 · 좁으면 줄어듦) · 계 바닥 고정 없음 · 항목별 옵션 고르기 · 바꿈 꼬리표 걷음 — 확인 = 세 상자 · 옵션 바꿈 → 금액 = 상세 보기와 같음 · 사진 + - 검증완료 — 텍스트 수식 = 노무비 · 재료비 · 경비 세 구분 상자 · 상자 맨 위 옵션 칸(지역 · 보정작업 6 열 격자) · 줄마다 붙던 옵션 꼬리표(알약) 0 · 소계 12,428 · 2,000 · 1,515 서버 그대로 · M01 텍스트 수식 같은 부품 · 밝음 · 어두움 · 768 +- [x] 737-3 (요구 578) 기본정보 다음 상자 제목 = 「단가산출 상세」(두 보기 같음 · 안 내용만 바뀜) · 제목 아래 「지금 입력 · 옵션」 꼬리표 걷음 · 공통 옵션 따로 상자 걷고 각 구분 상자 위 옵션에 같이 보임(한 값 · 어디서 바꿔도 모두 따라감) — 확인 = 제목 · 꼬리표 없음 · 공통 옵션 바꿈 → 세 상자 같은 값 · 금액 같음 · 사진 + - 검증완료 — 단가산출 상세 · 텍스트 수식 두 보기 상자 제목 모두 「단가산출 상세」 · 「지금 입력」 꼬리표 0 · 공통 옵션 따로 상자 걷고 모든 상자 맨 위에 같이 · 지역 서울로 바꿈(2번째 상자에서) → 세 상자 모두 서울 · 단가산출 상세 보기 계 24,673 → 24,761 → 되돌림 24,673 · 텍스트 수식 계 15,901 → 15,943 → 15,901 · 밝음 · 어두움 · 1366 · 768 · 390 + +## 738 M02 열기 기본 = 개인 층 · 단가산출 상세 삭제 단추 · 검색 추가 = 출처 + 수정 가능 (요구 579) + +- 사용자 확정: 단가산출 · 일위대가 열기 = 개인 단추 누른 것처럼 · 단가산출 상세 [저장] 왼쪽 [삭제] · 검색으로 더한 항목 = 어디서 왔는지 알고 · 내부 값은 따로 고칠 수 있게 +- [x] 738-1 (sub6) M02 들어올 때 단가산출 · 일위대가 · 구조물 조합 [열기] 길 — 단가산출 · 일위대가로 오면 구조물 조합 상세 대신 그 층(기본 개인) 목록 = 개인 단추 누른 것과 같음 — 확인 = 세 길 · 주소 · 새로고침 · 권한 셋 사진 + - 검증완료 — 시스템 · 회사 관리자 · 일반 단가산출 · 일위대가 열기 = 개인 층 목록 · 구조물 조합 그대로 · 새로고침 · 대시보드 카드 길 · 밝음 · 어두움 사진 · 시험 2 통과 · typecheck 0 +- [x] 738-2 (sub2 · 737 뒤) 단가산출 상세 [저장] 왼쪽 [삭제](735 경고 · 참조 줄 지움 길 그대로) — 확인 = 쓰는 곳 있음 · 없음 · 권한 밖 + - 검증완료 — 단가산출 · 일위대가 상세 머리 단추 차례 새로고침 · 초기화 · 삭제 · 저장(1600 · 760) · 삭제 = 쓰는 곳 없음 한 줄 물음 → 목록으로 · 쓰는 곳 있음 경고 표(일위대가 · 이름 · 키 · 1째 · 층) → 확인 뒤 그 줄 지움(담은로직 2 → 1) · 취소 = 그대로 · 마스터 읽기 층 일반 · 회사 관리자 = 머리 단추 새로고침뿐(초기화 · 삭제 안 보임) · 시험 38 통과(새 8) · typecheck 0 · ORCA 8004 · 5177 일반 개인 층 · 밝음 · 어두움 · 768 · 760 +- [x] 738-3 검색 추가 = 출처 + 수정 가능 — 조사 끝(_분석_579-2) · 사용자 답(요구 581) → PLAN 740 으로 + +## 739 별칭 장부 중앙 관리 (요구 579-4 · 580 · sub1) + +- 사용자 확정: 중앙 관리 · 진행 +- 정함(브레인): 자동으로 쓰이는 장부 = M01 중앙(마스터 층 · 승인된 짝)만 · 불러오기에서 이은 짝 = 그 불러오기에서 쓰고 중앙 승인 대기로 · 승인 전 내 제안 = 다음 불러오기에서 자동 안 붙고 「내 제안」 후보로 맨 위에 보임(한 번 눌러 고름 · 요구 20-3 「승인 받아 다음부터 자동」) · 층 장부 파일 = 버리지 않고 제안으로 읽음 · 불러오기 장부 화면 = 내 제안 목록(보기 · 고치기 · 거두기) +- [x] 739-1 장부 읽는 차례 = 마스터(승인)만 자동 · 층 짝 = 「내 제안」 후보 · 불러오기 짝 → 승인 대기 · 장부 화면 = 내 제안 · M01 별칭 장부 승인 · 반려 그대로 — 확인 = 승인 전 다음 불러오기 자동 0 · 후보 맨 위 · 승인 뒤 자동 · 기존 층 짝 후보로 보임 · 시험 · 사진 + - 검증완료 — 시험 105 통과(739-1 새 3 · hopyo ledger · match · flow · build · screen · 729 · 733 · 718-2 m01 alias · 572) · typecheck 0 · 실측 북부청 임도 한 층 두 번(임시 storage) = 1 차 같은 불러오기 자동 248 → 2 차 승인 전 자동 0 · 내 제안 248 → M01 후보 52 승인 뒤 자동 248 · ORCA 5174 = 확인 창 「고르기 · 내 제안」 · 고르기 창 「내 제안」 1위 위 · 한 번 눌러 GC000109 체크 · 장부 화면 「내 제안 목록」 승인 칸 대기 · 거두기 · 어두움 · M01 모인 후보에 보임(승인 안 누름) · 임시 불러오기 · 장부 줄 지움 + +## 740 단가산출 · 일위대가 서로 독립 · 연결 = M01 키 · 초기화 (요구 579-2 · 581 · 근거 _분석_579-2 · sub3) + +- 사용자 확정(581): SK · SC 서로 독립 · 연결 고리 = M01 기초 데이터 키만(알 수만 있게) · M02 문서를 넣으면 M02 키가 아니라 그 안 M01 키로 · 사용자 고친 값 초기화 = 연결된 M01 키로 다시 가져옴 · 단가산출 초기화 = 기본정보 밖 항목을 한 번에 M01 키 값으로 · [초기화] = [삭제] 왼쪽 +- 정함(브레인): 줄 = M01 키(기초 · 인력 · 재료 · 기계) + 그 문서 덮개(고친 값) · M02 → M02 참조(SK 안 SK · SC 안 SK · SC 안 SC) 없앰 → 넣을 때 그 문서 줄을 M01 키 줄 + 덮개 사본으로 펼쳐 담음 · 출처 = `본뜬`(정보만 · 셈에 안 씀) · M01 값(품 · 단가 책)은 셈 때 읽고 M01 개정 알림 그대로 · 초기화 = 덮개 · 뺀줄 걷음 · 더한줄 항목은 남기고 값만 M01 · 옵션(입력값) 남김 · 일위대가도 같은 초기화 · 기존 참조 줄(저장소 SC 참조 1) 펼쳐 옮김 · 구조물 조합 → SC 연결은 이번 범위 밖(그대로) +- [x] 740-1 (sub3) 서버 — 넣기 펼침(SK · SC → M01 키 줄 + 덮개 사본 · 본뜬) · 참조 셈 길 걷음(옛 문서 읽기는 펼쳐 읽음) · 초기화 길(SK · SC) · 기존 참조 펼쳐 옮김 · 733 지문 · 735 삭제 · B09 굳힘 맞춤(B09 호표 블록이 덮개 반영하는지 같이 확인) — 확인 = 넣은 뒤 원본 고쳐도 안 바뀜 · 초기화 = M01 값 · 금액 전후 · 시험 + - 검증완료 — 시험 374 통과(740-1 새 10 · 727 · 728 · 729 · 733 · 735 · 715 · 717 · 719 · 725 · B09 145 · 155 · 702 · 703 · 166 · 157 · 204 · 불러오기 705 · 718 · hopyo build · flow · 572) · 실측 E1 = 옛 참조 129,073 → 481,406 · 펼친 것 129,073 그대로 · SK 넣기 펼침 전후 204,070 같음 · B09 SC 덮개 줄 = 옛 산근 6,451 대 M02 210,866 → 새 대가 블록 210,864(끝수) · 하위 호표 = 사본 블록(거창 엑셀 대조 같음 14 그대로 · 같은 기초 겹침도 블록 안) · 옮기기 실 storage 보기만 = 1/3 SC-000002 → 사본 블록 · 전후 1,446,078 같음(브레인이 --apply) · 초기화 이음 기계 = M01 한 시간 값 다시 받음 · 시험 378 통과 · ORCA 8005 · 5178 일반 계정 개인 층 SK 넣기 저장 → 상세 = 펼친 줄(변경 항목 · 계 204,070) · 원본 고침 447,400 뒤 넣은 쪽 그대로 · 초기화 미리 보기 1 · 만든 문서 지움 · 화면 파일 안 고침 +- [x] 740-2 (sub2 · 737 · 740-1 뒤) 화면 — 단가산출 · 일위대가 상세 [초기화](삭제 왼쪽 · 물음) · [삭제](738-2) · 넣은 줄 출처 꼬리표 — 확인 = 사진 · 초기화 전후 금액 + - 검증완료 — 초기화 미리 보기 = 고친 값 2 개 · 빼 둔 줄 1 개 → 물음 → 단가산출 계 24,761 → 21,893(서버 다시 셈 같음) · 옵션 지역 서울 · 덮개 · 뺀줄 걷힘 · 본뜬 그대로 · 일위대가도 같음 · 한 번 더 누름 = 「되돌릴 고침이 없음」 · 저장 안 한 고침 있음 = 먼저 물음 · 출처 꼬리표 = 단가산출 줄 이름 옆 「본뜬 SK-…」(줄 높이 그대로) + 풍선(키 · 이름 · 층 · 때) · 일위대가표 비고 칸 + 풍선 · 밝음 · 어두움 · 시험 38 통과 · 낡은 시험 4 고침(이어 붙임 목록) +- 이음(740-1 → 740-2 · 736): 넣기 = `POST /api/m02/layers/{층}/{kind}-flat {참조, 수량?(넣을 SK 단위 수 · 기본 1), 문서?(지금 화면 문서 · 겹침 검사)}` → SK `{더한줄:[…]}` · SC `{담은로직:[…], 자재:[…]}`(화면이 끝에 붙임 · 못 펼침 400 까닭) · 초기화 = `POST …/{kind}-reset {키 | 문서, 미리보기, 판?}` → `{걷힘: 수, 문서}`(미리보기 false = 그 층에 씀 · 쓰기 권한) · 줄 `본뜬 {층, 주인, 키, 이름, 때}` = 출처 꼬리표(SK 더한줄 · SC 줄 · 자재) · 넣은 SK 한 줄 펼침 세부 cid `add:#k/add:#j` = 안 더한줄(읽기만) · 저장 · 읽기 때 옛 참조 줄도 서버가 펼침 · SC 안 SC = 사본 블록 줄 `{참조: {층: 사본, 주인: 몸 지문, 키: 본 SC, 몸: {종류, 이름, 단위, 담은로직, 자재, 본뜬}}, 별칭, 수량, 본뜬}`(원본 연결 없음 · 키 = 출처 정보 · 줄 누르면 서고 원본 대신 몸을 보임 · 셈 · 알약 키 `참조|사본|<지문>|SC-…` = 옛 참조 줄과 같은 길 · 하위 호표 끝수 · B09 하위 대가 그대로) · SC 넣기 길 답 = 그 줄 하나 · 초기화 = 사본 몸 안까지 · 이음 기계 고정 줄 단가 = M01 기계 한 시간 값 다시 받음 +- 이음(740-2 sub2 → 남): 머리 단추 [초기화] [삭제] = 새 파일 `M02_MasterTemplete_Main_Actions.ts`(UI_Main 머리에 두 단추 · `bar()` 한 줄) · 출처 꼬리표 = 단가산출 `hoView`(logic/M02_Sangeun_Cover_Model.ts) 꼬리표 칸에 `본뜬` 글 · 공용 Ho `sourceTag` 한 줄(첫 줄 = 보임 · 둘째 줄 = 풍선) · 일위대가 상세 표 이름 칸 꼬리표는 sub8 742 와 같은 표 파일이라 먼저 몫 맞춤 + +## 741 불러오기 엑셀 보기 열 폭 (요구 582-5 · sub5) + +- [x] 741-1 엑셀 보기 열 폭 = 원본 엑셀 열 폭 따름(없으면 글 길이로 자동) · 머리 경계 끌어 열 폭 바꿈 · 두 번 눌러 맞춤 — 확인 = 실무 파일 열 폭 · 끌기 · 굴림 · 고른 칸 테두리(730) · 사진(밝음 · 어두움 · 좁은 폭) + - 검증완료 — 시험 24 통과(741 새 4 · 704-3 · 730-2 · hopyo grid · 730 grid) + autofit 노드 시험 · typecheck 0 · xlsx 폭 없는 파일 열 폭 전부 64 px · 잘린 글 55 → 0 · xlsx 파일 폭(15열) 서버가 보냄 · 끌기 D 222 → 272 px(행 높이 같음) · 경계 오른쪽에서 두 번 눌러 맞춤 272 → 222 · 굴림 · 고른 호표 테두리 그대로(ORCA 5180 밝음 · 어두움 · Playwright 좁은 폭 760 · 높이 768) · 편집 격자 끌기 · 맞춤 같음 · 임시 불러오기 지움 + +## 742 일위대가 안 줄 수량 = 사용자 입력 저장 (요구 582-1 · 584-1 · 근거 _분석_582-123 · sub8) + +- 사용자 확정(584-1): 일위대가 = 기초 · 요소의 조합 · 그 안 단가산출 항목 = 1 기준단위 정보(안쪽 품은 그대로) · 일위대가 하나에 단가산출이 얼마나 드는지 = 사용자 입력 · 단가산출처럼 기준단위 단가와 수량 반영 금액을 따로 보임 · 일위대가 값이 구조물 조합 · 프로젝트에서 연결됨 +- 정함(브레인): 723-1 「안 줄 수량 null」 걷음 → 줄 수량 = 저장(사용자 입력 · 처음 1) · 구조물 조합 · 공사에서 알약 · 프로젝트 값이 이어져 있으면 그 값이 앞섬(723-4 차례 = 굳힌 손 → 알약 → 저장 수량) · 불러오기로 만든 일위대가도 사용자가 다 고른 뒤 엑셀 수량을 저장 값으로 · 기존 null 줄 = 빈 칸 → 사용자 입력 · 단가산출에 일위대가를 넣으면 그 저장 수량으로 금액(「수량 없음」 없어짐) +- [x] 742-1 서버 · 화면 — 안 줄 수량 저장 되살림 · 기준단위 단가 / 수량 반영 금액 두 칸 · 알약 · 프로젝트 값 차례 · 불러오기 수량 · 단가산출 안 일위대가 금액 — 확인 = 저장 → 다시 열기 · 구조물 조합 알약 있음 · 없음 · B09 금액 · 시험 · 사진 + - 검증완료 — 시험 267 통과(새 test_742_1 8 · 낡은 723 · 175 · 204-2 · 702-3 · 703 · 717-4 · 719 · hopyo build · flow · labor ref 고침) · typecheck 0 · B09 공사 금액 전후 같음(검증_랩탑메인 일위대가 18 줄 · 검증_sub8) · M02 구조물 조합 금액 26 전후 같음(마스터 「테스트 사전조합」 옛 손 1 · 1 이 저장 수량으로 읽혀 272,935 · item3 기준합만 바뀜 · 금액 0 그대로) · ORCA 5183 개인 층 임시 일위대가 = 열 「기준단위 단가 · 수량 반영 금액」 · 79,279.7 × 2 = 158,559.3 · 빈 줄 「수량 입력」 → 0.5 넣고 저장 → 다시 열기 2 · 0.5 그대로 · 합 203,421.6 · 글자 = 「수가 아님」 · 빈 칸 = 빈 줄 1 · 저장 안 하고 옮김 = 물음 · 마스터 읽기 칸 0 · 밝음 · 어두움 · 임시 지움 +- 이음(742-1 → 740-1 sub3): 일위대가(SC) 문서 안 줄 · 자재 `수량` = `{손: 수}`(저장 · 사용자 입력 · 새 줄 1) | null(빈 칸 · 금액 빠짐 · 「수량 입력」) · 비율 줄 = 율 그대로 · 층 셈 차례 = 알약 값(given) → 저장 손 · 공사 굳힌 사본(`refs.with_pills`) = `{저장: 수}` 로 옮겨 굳힘 → 공사 차례 = 굳힌 손(옛 사본 · 707-7) → 알약 → 저장 · 740-1 SC 펼침 수량 = 그 줄 `수량.손`(null = 펼칠 수량 없음) · `M02_Labor_Ref` 고침 = `clean_doc` 끝 · `no_qty` 지움 · `with_pills` 만(다른 함수 안 고침) + +## 743 불러오기 엑셀 보관 안 함 (요구 582-3 · 584-2 · sub1 · 739 뒤) + +- 사용자 확정(584-2): 불러온 엑셀 자료 자체를 보관하지 않음 · 필요하면 다시 불러와 씀 +- 정함(브레인): 서버 임시 자리 = 그 불러오기 동안만(끝내기 · 화면 떠남 · 하루 지나면 지움) · 같은 엑셀 다시 올림 막힘(409) 걷음 · 만든 단가산출 · 일위대가 안 엑셀 사본(실무 수량 · 엑셀 품 · 메모) 걷고 출처(파일 이름 · 때)만 · 옛 실무자료 파일 정리 · 별칭 제안(739)은 남김 +- [x] 743-1 임시 자리 · 지움 때 · 다시 올림 · 사본 걷음 · 옛 파일 정리 — 확인 = 끝내기 뒤 남는 파일 0 · 같은 파일 다시 올림 됨 · 만든 문서 금액 그대로 + - 검증완료 — 시험 108 통과(743 새 5 · 718-1 · 705-2 · hopyo excel · fit · screen · 729 · 739 등) · typecheck 0 · 끝내기 뒤 HP · 격자 · 맞춤 캐시 0 · 만든 SC · SK 1 단위 계 전후 같음 · 본뜬 = 파일 · 때 · 누가만 · 같은 엑셀 다시 올림 HP-000002(409 없음) · 하루 지난 HP 숨김 → 올림 때 지움 · ORCA 5174(일반 계정 개인 층): 올림 → 다른 화면 = 파일 0 · 같은 파일 다시 올림 새 키 · 페이지 새로고침 = 파일 0 · 뒤 셈 캐시 안 남음 · 옛 파일 정리 미리 보기 5 폴더 9 건(쓰기 0 · --apply 는 브레인) +- 이음(743-1 ↔ 742-1 sub8): [끝내기] · 화면 떠남 · 하루 지남 = `M02_Hopyo_Forget.finish` 한 길 · 만든 SC · SK `본뜬` → `{층 「실무자료」, 파일, 때, 누가, 전국자재?, 복사원본?}`(키 · 원본 · 확인 · 갈래 · 엑셀품 · 못옮김 · 놓음 · 품셈 · 또(그 HP 몫) 걷음) · SC 줄 `메모` 「실무 …」 걷음 · `본뜬.실무수량` = 모든 값이 그 줄 `수량.손` 과 같을 때만 걷음(742 저장 수량이 정본 · 다르면 남김) · `Build._save` 안 고침 + +## 744 단가산출 기초 품 그대로 보임 · 되돌리기 빈틈 (요구 582-1 · sub3 → sub2) + +- 사용자 확정(582-1): M02 단가산출에 기초를 가져오면 M01 기초를 복사 · 품 유지 · 사용자가 바꿀 수 있음 +- 정함(브레인): 둘째 기초 세부 품 = M01 원문 그대로 보이고 「몇 개(배)」는 기초 머리 수량 칸 하나로(지금 품에 곱해 0.871 → 0.00871 로 보임 걷음) · 단위 바꾼 단가산출은 728 환산 그대로(사용자 요구) · 되돌리기 빈틈 셋([기초 빼기] · 더한 항목 빼기 · 더한 항목 수량 되돌림 → 처음 값) 고침 +- [x] 744-1 둘째 기초 품 원문 + 머리 수량 칸 · 되돌리기 셋 — 확인 = GF000316 0.871 그대로 · 수량 칸 0.01 · 금액 같음 · 되돌림 → 처음 값 + - 검증완료 — 화면 몫 = 748-0(94e330a5f) · 서버 몫 끝(sub3 · 6c0211af1) — 둘째 기초 펼침 줄 원문 품 = GF000316 0.871 · 상자 몫 0.00871 · 머리 수량 0.01 · 금액 같음 · 처음수량 · 뺀더한줄 저장 · 셈 · 지문 안 듦 · 시험 4 · ORCA 8005 API 확인 · 화면(sub2) 남음 +- 이음(744-1 서버 → sub2 화면): 둘째 기초 `펼침` 줄 덧칸 `원문 {수량, 금액, 비목}` = 그 기초 1 기준단위 몫(M01 원문 품 · 머리 수량 안 곱함 · 화면 품 칸은 이것) · 줄 `수량 · 금액 · 비목` = 머리 수량 곱한 몫(상자 합 · 그대로) · 머리 줄 수량 = 더한줄 `수량` · 더한줄 항목 `처음수량`(넣을 때 화면이 적음 · 없으면 서버가 저장 때 지금 수량) · [되돌림] = 수량 ← 처음수량 · SK · SC 줄 패치 `뺀더한줄 [{차례, 줄}]` = [기초 빼기] · 더한 항목 빼기 때 화면이 옮겨 둠 · [되살림] = 그 차례에 다시 끼움(열쇠 당김 = 화면) · 셈 · 지문 안 듦 · 못 읽는 기억 줄은 서버가 버림 + +## 745 불러오기 미리 고름 (요구 582-2 · 4 · 584-3 · 근거 _분석_582-24 · sub5 · 741 뒤) + +- 사용자 확정(584-3): 미리 고름 = 「다른 점」 줄만 · 누르기는 사용자 +- [x] 745-1 고르기 창 「다른 점」 줄에 블록 인용 조항 = 1위 기초 조항이면 미리 고름 점(자동 확정 아님) — 확인 = 북부청 표본 점 찍힘 · 맞음 비율 · 사용자가 눌러야 만듦 + - 검증완료 — 북부청 표본(임도 · 사방) 「다른 점」 28 줄 전부 점(1위 맞음 28/28 · 100 %) · 「후보」 21 줄 점 0 · 점 줄도 체크 안 됨(눌러야 만듦) · test_m02_hopyo_match 28 · test_m02_precombo_scope 7 통과 · typecheck 0 · ORCA 사진(고르기 창 점 · 후보 줄 점 0) + +## 746 불러오기 후보 블록 줄 비교 · 요소 후보 · 단위 같은 성격 (요구 585 · 근거 _분석_585 · sub4) + +- 정함(브레인): 지금도 일위대가는 줄마다 맞춤 · 요소로 기초를 거꾸로 찾기는 정답률이 떨어져(163 → 107) 넣지 않음 · 대신 「고름 대기」 후보 블록에도 1위 기초와 줄 단위 비교(같은 항목 · 다른 품 · 더한 항목 · 뺀 항목)를 붙여 보여 줌(판정 · 미리 고름은 그대로 = 584-3 「다른 점」만) · 못옮김 · 기계 줄에 M01 요소 후보 · 단위 모듈 hr ↔ 시간 · 개 ↔ 개소 같은 성격 +- [x] 746-1 (sub4) 서버 — 후보 블록 줄 비교(응답 칸) · 못옮김 · 기계 줄 요소 후보 · 단위 별칭 둘 — 확인 = 고름 대기 89 중 비교 붙는 블록 수(72 안팎) · 골든 1위 그대로 · 단위 고름 대기 1~3 블록 풀림 · 시험 + - 검증완료 — 고름 대기 89 중 1위 줄비교 72(셋 중 하나 82 · 골든 고름 대기 60 중 붙음 48 · 그중 1위 맞음 42) · 골든 1위 163 그대로 · 판정 수 그대로 · 단위 별칭 = 개 ↔ 개소 1 블록 견줌 풀림(hr ↔ 시간 걸린 블록 0) · 못옮김 후보 8/9 · 기계 줄 후보 32/32 · M01 로직 3,992 값 같음 · 고름 대기 블록당 +0.13 초 · 시험 215 통과 · c0319a4f5 +- [x] 746-2 (sub5 · 745 와 같이) 고르기 창에 그 비교 표 · 요소 후보 — 확인 = 사진 + - 검증완료 — 북부청 임도 표본 고름 대기 12 블록 전부 비교 표(후보 단추 앞 셋 · 표 37 줄 · 같음/품 다름/엑셀만 배지 · 후보 고름 칸과 표 동기) · 못옮김 창(단산60) 자재 요소 후보 셋 → 누름 = 고른 것 MT007130 · 북부청 사방 안 이은 줄 창 기계 줄(제11호표 콘크리트 펌프차) 요소 후보 셋 · 기초 후보 칸에 요소 후보 안 섞임 · 밝음 · 어두움 · 1366×768 · 1920×1080 · 1100 폭 사진 · 시험 11 통과(새 4 · 729-3 · 746-1) · typecheck 0 · 올린 파일 · 만든 SK 35 지움(남은 SK-000001 = sub7) +- 이음(746-1 → 746-2 · 응답 칸): [단가산출 만들기] `고름대기[].후보[0~2].줄비교` = null(견줌 못함 · 단위 성격 다름) | `{줄 [{갈래 인력|장비|자재, 이름, 규격, 단위, 엑셀, 기초, 꼴 같음|품다름|엑셀만|기초만}], 수 {같음, 품다름, 엑셀만, 기초만}, 못옮김 [...], 환산?}` (엑셀 · 기초 = 1 단위 품 · 없으면 null) · 못옮김 · 안 이은 기계 줄(`남은줄[]` 갈래 기계) `후보` = `[{키, 갈래 노임|기계|자재, 이름, 규격, 단위, 단가}]` 셋까지(고르면 지금 잇기 · 이음 길 그대로) + +## 747 M02 단가산출 재료 수량 · 재료비 빠짐 (요구 586 · sub7) + +- 사용자 확정: M01 기초에는 재료 수량이 있는데 M02 단가산출로 가져오면 재료비 항목 수량이 모두 없음(개인 「테스트1」 · GC000211 6-3-1 합판거푸집) · 수량 유지 · 사용자가 바꿀 수 있게(582-1) +- 브레인 실측: 서버 셈 답 `재료` 목록에는 수량 있음(합판 1.03 · 각재 0.038 · 폼타이 0) · 그러나 `줄`에 재료 줄 없음 · `기준` 재료비 = 소모자재 1,404.81 뿐(합판 · 각재 금액 빠짐) · 문서 덮개 · 더한줄 · 뺀줄 없음 · `자재표시` · `고름` 쪽 짐작 +- [x] 747-1 까닭(서버 셈 · 자재표시 · 고름 · 화면) → 고침: 기초 재료 줄 = M01 수량 그대로 보임 · 재료비에 듦(값 칸 = M01 기본) · 사용자가 수량 · 값 칸 바꿀 수 있음 — 확인 = 테스트1 재료 줄 수량 · 재료비 = M01 단가산출 기초 같은 입력 값 · 기존 SK 금액 전후 표 + - 검증완료 — 까닭 = SK 셈이 바탕 로직 재료에 일위대가 기본(별도계상 · 162-1)을 써 줄 · 재료비에서 뺌 → SK 만 빈 칸 = 직접계산(일위대가 덮개 줄 그대로) · 테스트1 재료비 1,404.81 → 36,525.06 · 계 89,299.60 → 124,419.85 = M01 GC000211 같음 · 합판 1.03 · 각재 0.038 · 폼타이 0 · 화면 합판 2 덮개 → 재료 소계 47,979(폐기로 되돌림 · 파일 같음) · 다른 기초 6(GF000002 · 011 · 012 · GC000001 · 002 · 014) M01 같음 · 기존 SK 17 중 바뀜 3(GC000211 바탕 · 개인 1/10 SK-000003 36,069.61 → 47,553.94 · SK-000008 59,255.22 → 74,813.50 · 1/3 SK-000002) 14 같음 · 시험 295 + 새 3 통과 · typecheck 0 + +## 748 단가산출 상세 화면 정리 (요구 588-5~9 · 요구서_588 · sub2) + +- 사용자 확정: 588-5~9 원문 +- 정함(브레인): 공용 상세라 M01 단가산출 기초 화면도 같이 · 744 화면 몫(둘째 기초 품 원문 + 머리 수량 칸 · 되돌림 셋) 먼저 +- 이음(748 sub2 → 남): 공용 상세 줄 모달 = `Modal.openMaterialModal` edit 인자 (close, head) · 연결 키 = 제목 오른쪽 · `HoEdit.more` = {head, body} · 텍스트 수식 줄 누름 = `Ho.lineOpener(ctx)` → `FormulaContext.open` · 바닥 줄 = 새 `LogicLab_Foot.buildFoot` · 둘째 기초 머리 = 새 `Sangeun_BaseHead` · 되돌리기 기억 = 새 `logic/M02_Sangeun_Undo`(문서 `뺀더한줄` · `처음수량` · 일위대가 줄 패치 `뺀더한줄`) · 세부 줄 품 = 서버 `원문.수량`(`hoView` 줄 수량 교체 · 금액 · 비목 그대로) +- [x] 748-0 (744-1 화면 몫) 둘째 기초 세부 품 = 원문 · 기초 머리 수량 칸 하나 · 되돌림 셋([기초 빼기] · 더한 항목 빼기 · 더한 항목 수량) 화면 — 확인 = GF000316 0.871 · 수량 칸 · 되돌림 → 처음 값 + - 검증완료 — 검척(GF000316) 둘째 기초 추가: 세부 품 0.871 그대로 · 머리 수량 칸 0.01 · 금액 2,051(= 0.00871 × 235,459 · 계 24,673 → 26,724 합 +2,051) · 수량 0.05 → 되돌림 → 계 24,673.21 같음 · [기초 빼기] → [되살리기] 계 24,673.21 같음 · 빈 칸 = 「0 이상 수만」 값 안 바뀜 · 시험 test_748_0 + 727 · 728 · 717 · 740 통과 · typecheck 0 · 사진 tmp/shots_748/m_* +- [x] 748-1 줄 모달 = 머리 제목 오른쪽 (연결 키) · [적용] [삭제](← 빼기) [닫기] 차례 · 옛 빼기 아래 정보 걷음 — 확인 = 사진 · 적용 · 삭제 동작 그대로 + - 검증완료 — 제목 「보통인부 (LB000002)」 · 머리 [적용] [삭제] [닫기] 차례(ORCA · Playwright 밝음 · 어두움 · 1366 · 768) · 적용 0.05 → 계 24,673 → 26,566 · 삭제 → 되살리기 줄 · M01 단가산출 기초(GF000002) 모달 [적용] [닫기] +- [x] 748-2 텍스트 수식 = 행 고르기 걷음 · 이름식 변수명 · 값 든 식 값마다 누름 → 748-1 모달 — 확인 = 변수 · 값 누름 → 그 항목 모달 + - 검증완료 — 행 고르기 없음 · 이름식 변수 · 값 든 식 수 하나하나 단추(첫 3 줄 8 · 5 · 9 개) 누름 → 같은 줄 모달 · M01 GF000002 단추 44 개 누름 → 벌목부 (LB000037) [적용] [닫기] · 시험 test_748_2 +- [x] 748-3 상세 보기 항목 옵션 값 꼬리표 걷음 · 텍스트 수식 바닥 노무비 · 재료비 · 경비 고정(계 없음) · 상자 사이 간격 = 바닥 줄 위 여유 — 확인 = 사진(밝음 · 어두움 · 1366 · 768) · 금액 그대로 + - 검증완료 — 줄 옵션 값 칩 걷음 · 텍스트 수식 바닥 노무비 12,428 · 재료비 1,958 · 경비 1,515 세 칸(계 없음 · 상자 소계 그대로) · 상자 사이 간격 12px = 바닥 줄 위 여유 12px(상세 · 텍스트 수식 같음) · 금액 전후 같음(24,673.21) · 시험 test_737 + +## 750 단가산출 보기 넷 셈 하나로 (요구 588-1 · 근거 _분석_588-134 · sub3 → sub2) + +- 사용자 확정(588-1): 네 보기 셈은 같고 보이기만 다름 · 상세 보기가 맞음 · 옵션 바꿈 셈 빨라짐 +- 정함(브레인): SK 셈 답 하나에 텍스트 수식 · 시험 계산 줄까지 실어 네 보기를 그 답 하나로 그림(바탕 M01 로직 따로 셈 걷음 · 별도계상 · 단위 환산 마무리) · 옵션 바꿈 = 서버 셈 한 번 · 일위대가 줄 모달 안 상세도 같은 답(747 한쪽만 든 흠 · 124,419.85 대 89,299.60) · M01 화면은 그대로 동작 +- [x] 750-1 (sub3) 서버 — SK 셈 답에 텍스트 수식 · 시험 계산 줄 · 일위대가 줄 모달 상세 같은 답 — 확인 = 변형 일곱(줄 빼기 · 더하기 · 덧줄 · 별도계상 · 단위 환산 · 재료 고름 · 기본) 네 보기 줄 · 계 같음 · 옵션 바꿈 호출 수 · 시간 전후 + - 검증완료 — 테스트1 변형 일곱(기본 12 줄 124,419.85 · 줄 빼기 11 · 99,589.41 · 더하기 13 · 133,023.25 · 덧줄 124,771.06 · 별도계상 11 · 112,725.23 · 단위 환산 1,244,198.53 · 재료 고름 124,563.39) 상세 · 인력재료경비 · 시험 계산 · 텍스트 수식 줄 · 계 같음(8005 HTTP · cid 대조) · 무작위 로직 120 · storage SK 17 · 참조 바탕 같음 · 일위대가 줄 모달 = 그 줄(storage · 마스터 일위대가 150 줄 다름 23 → 0 · ORCA 개인 층 GC000211 모달 89,300 = 줄 89,299.6 · 임시 사전조합 지움) · 옵션 바꿈 부름 5 → 2 · HTTP 약 3.3 초 → 0.5 초 · M01 /m01/calc · /text 124,419.85 12 줄 그대로 · 시험 57 통과 · typecheck 0 +- [x] 750-2 (sub2 · 748 뒤) 화면 — 네 보기가 750-1 답 하나로 그림 · 옵션 바꿈 한 번 셈 — 확인 = 테스트1 · 변형 넷 보기 같음 · 시간 + - 검증완료 — 테스트 변형 일곱(기본 · 줄 빼기 · 더하기 · 덧줄 · 별도계상 · 단위 환산 · 재료 고름) 네 보기 줄 수 · 이름 · 계 같음(상세 · 인력재료경비 · 시험 계산 · 텍스트 수식 = 124,420 · 99,589 · 133,023 · 124,771 · 112,725 · 1,244,199 · 124,563 · 별도계상 합판은 상세 · 계산서에 기준 밖 줄로 남고 시험 계산 · 텍스트 수식엔 없음) · 열 때 /logic-lib/calc · text 부름 0 · 옵션 한 번 바꿈 = SK 셈 2 부름(답 + 바탕 원값 · 옛 5 부름) · 서버 셈 0.05 초 대 옛 2.9 초 · 브라우저 0.5 초 · 재료 고름 = 문서 고름(cid) 1 회 셈 · 관련 시험 묶음 통과(41 · 36 · 35 · 새 3) · typecheck 0 · 사진 tmp/shots_750/750_* (밝음 · 어두움 · 1366 · 768) · 일위대가 줄 모달 = 같은 상세(서버 줄모드 시험)이라 화면 별도 확인 못 함(개인 층에 일위대가 없음) · 커밋 db590e6fa +- 이음(750-1 → 750-2): `POST /layers/{층}/sangeun/calc` 답 덧칸 — `글` = M01 `/text` 답 꼴(ok · groups[비목 · 줄 · 소계 · 끝수] · 계 · 별칭 · 기준단위 · 줄 칸 = 이름 · 글 · 이름글 · 조각 · 요소조각 · 금액 · 까닭 · 짝 · annotate 덧칸 + `cid`) · 글 금액 · 소계 · 계 = SK `줄` · `기준` 그대로(별도계상 뺌 · 단위 환산 ÷배 · 못 셈 줄 까닭) · 줄마다 `cid` = SK 줄 cid · `글.나눔` = 배(≠1 · 글 식은 바탕 단위) · `중간` = 시험 계산 중간값(수 · 글만) · 시험 계산 = {ok: true, lines: `줄`, sums: `기준`, middle: `중간`} · 재료 고름 = 문서 `고름`(cid → MT) · 일위대가 줄 모달 = 문서 `줄모드: true`(`lineDoc` · 750-1 이 넣음) · 몸 `보기: false` = 덧칸 안 지음(바탕 원값 셈 `plain` 에 · 서버 품 절반) · 옛 `/logic-lib/calc` · `/text` 부름 걷음은 750-2 + +## 752 M02 단가산출 우측 원문 보기 (요구 588-3 · sub2 · 750-2 뒤) + +- 정함(브레인): 일위대가 상세가 쓰는 원문 서버 길 · 원문 보기 부품 재사용 · 바탕 · 둘째 기초가 여럿이면 기초마다 탭 · 일위대가 줄 모달 안에서는 끔 +- [x] 752-1 단가산출 상세 우측 원문 패널 — 확인 = 테스트1 6-3-1 원문 · 기초 둘 탭 · 좁은 폭 접힘 · 사진 + - 검증완료 — 테스트 6-3-1 바탕 원문 패널(건설품셈 공통 6-3-1 · 6-03_거푸집.md) · 기초 둘(검척 7-13 더함) = 탭 둘 · 둘째 탭 = 산림품셈 7-13 원문 · 1600 처음 열림(밝음 · 어두움) · 1366 · 768 처음 접힘(세로 띠 · 누르면 열림) · 일위대가 줄 모달(RowSk)은 source 안 켬(시험 대조) · 시험 30 통과 · typecheck 0 · 사진 tmp/shots_750/752_* · 커밋 da45997eb + +## 753 옵션으로 0 이 되는 줄 숨김 (요구 588-4 · sub4 → sub2) + +- 사용자 확정(588-4): 옵션에 따라 하나만 보일 재료가 옵션 전체 · 0 으로 보임(폼타이) · M01 도 같음 · 조심히 확인 후 수정 +- 정함(브레인): (가) 서버가 셈 답 줄에 「옵션으로 빠진 줄」 표시 → M01 · M02 화면 모두 숨김(M01 정본 자료는 안 고침 · 금액 그대로) · 153 로직 356 줄 · M01 정본 줄 합치기(15 로직 39 줄)는 사용자 답 뒤(아침 물음) · 폼타이 자재 단가 빈 자료는 아침 물음 +- [x] 753-1 (sub4) 서버 표시(M01 셈 · 계산서 · SK 셈 같은 꼴) — 확인 = 테스트1 폼타이 고른 한 줄만 · 153 로직 표시 수 · 금액 전후 같음 · 시험 + - 검증완료 — 테스트1(SK 셈 · M01 /calc · /text 셋 같음) 폼타이 아님 = 셋 표시 · 사용 9.5 = 12.7 · 7.9 표시 · 9.5 만 표시 없음 · 구멍땜 0 줄 표시 없음 · 계 124,419.85 그대로 · 153 로직 고르기 값 하나씩 돌림 = 스캔 줄 0 관측 4,581 전부 표시 · 표시 붙은 로직 141 · 시험입력 한 벌 = 표시 191 줄 · 129 로직 · 0 인데 표시 없음 2(GC000480 표 칸 0 = 진짜 0) · 0 아닌데 표시 0 · 돈 로직 1,440 금액 전후 같음 · 셈 시간 그대로 · 시험 287 + 새 9 통과 · 정함: 수 입력 문턱(GC000187 타설길이 ≤ 20 장비 택일) · 범위 조건(GC000769 규격 15~50 보통인부 0)도 표시 · a36092fad +- [x] 753-2 (sub2 · 750-2 와 같이) 화면 숨김(M01 · M02 공용 상세) — 확인 = 사진 · 옵션 바꿈 → 보이는 줄 바뀜 + - 검증완료 — 테스트 GC000211(폼타이 셋) — 폼타이 아님 = 상세 · 인력재료경비 · 시험 계산 · 텍스트 수식 넷 모두 폼타이 줄 0 · 사용 + 12.7 = 12.7 한 줄 · 9.5 = 9.5 한 줄(옵션 칸 그대로 · 바꿈 = SK 셈 2 부름 · 보이는 줄 바뀜) · 계 124,420 그대로 · M01 단가산출 기초 GC000211 같음([계산] 뒤 9 줄 · 사용 → 폼타이 12.7 한 줄 · 계 같음) · 관련 시험 86 통과(새 test_750_2 · 753-1 · 750-1 포함) · typecheck 0 · 사진 tmp/shots_750/753_* · 밝음 · 어두움 · 1366 · 768 · 커밋 a773a9d2c · 참고: test_719_4 가 B09 sheet 의 formatNumber 때문에 실패(sub6 751-1 몫 · 내 변경 밖) +- 이음(753-1 → 753-2 · 750-1 · 응답 칸): 엔진 줄 덧칸 `옵션꺼짐: true`(아니면 칸 없음 · `master_gate`) — M01 `/calc` `lines[]` · `/text` · M02 `/logic-lib/sheet` 줄(`master_sheet_line.KEYS`) · SK 셈 답 `줄[]` · 둘째 기초 `펼침[]` 같은 칸(엔진 `run` 한 곳에서 붙음 · SK 는 저절로) · 가름 = 수량 0 이 `만약` 간 갈래 상수 0 탓(고르기 · 수 입력 문턱 · 중간값 거침) · 입력 0 · 표 칸 0 · 원문 0 = 칸 없음 · 금액 · 차례 · cid 그대로 · 화면은 그 줄을 안 그림 + +## 755 B05 예정노선 수정 흐름 (요구 589-1 · 3 · 5 · 6 · 7 · 8 · 근거 _분석_589-A · sub1) + +- 정함(브레인): 켠 동안 선 끊김 허용(가운데 묶음 지움 됨) · 끌 때 검사 = 끊겨 있으면 알림하고 안 끔(이어 긋기 · [잇기]) · 계획 평면노선은 켠 동안 안 바꿈 → 끌 때 한 번 추적(폴리라인 = 직선 + 곡선 · 둘째 단계 = 곡선 가운데 20 m 측점 맞춤) · 켠 동안 자기 되돌리기 · 길이재기 = 켠 동안도 먹음(예정노선 재기 · 두 점) · 받은 노선 색 더 잘 보이게 · 설명 걷음 · B05 위치 상자 걷음 · 「둘째 단계 안 됨」 뜻은 아침 물음(받은 노선이 화면 예상보다 280 m 긴 흠 · 측점 밀림은 같이 가름) +- [x] 755-1 켠 동안 끊김 허용 · 끌 때 검사 · 끌 때 한 번 추적 · 자기 되돌리기 — 확인 = 가운데 묶음 지움 · 끌 때 계획선 갱신 · 측점 31 곳 맞춤 + - 검증완료 — 검증_sub1 가운데 토막 6개(100 m) 지움 → 두 토막 [31, 28] · 켠 동안 follow 0 · 끌기 막힘 알림 · Ctrl+Z · 도구줄 되돌림 됨 · 이은 뒤 끌 때 follow 한 번(63 점 → 노드 27 · 곡선 25 · MC 20 m 측점 위 25/25) · 안 고치고 끔 follow 0 · ORCA 같은 길 · 시험 68 · typecheck 0 · dfba0e7b5 +- [x] 755-2 길이재기 · 받은 노선 색 · 설명 걷음 · 위치 상자 걷음 — 확인 = 사진 · 재기 수치 + - 검증완료 — 켠 동안 재기 선 따라 360.0 m · −1.09 % · 두 점 256.5 m(지형 밖) · Esc 지움 · 받은 노선 분홍 점선 + 검은 테(밝음 · 어두움 · 768) · 켬 설명 · 위치 칸 없음 · D03 칸 위치 · 지점 · 그리기 · 구역 그대로 · dfba0e7b5 +- [x] 755-3 받은 노선 280 m 긴 흠(source 를 csv 양끝에서 자름) · 켠 채 [저장] · 떠남 알림 — 측점 밀림 19+10 → 17+18 은 안 고침(아침 물음) + - 검증완료 — source = csv 1,097.4 m(검증_sub1 전후 같음 · 검증_sub2 1,377.4 → 1,097.4 · follow 1,356.5 → 1,080.2) · 켠 채 판 고침 [저장] POST 0 + 알림 · 떠남 물음 · 취소 = 머묾 · 탭 새로고침 = 브라우저 물음 · 되돌리면 안 물음 · 밝음 1080 · 어두움 768 · ORCA 같은 길 · 시험 127 · typecheck 0 · 8b3560c4c · 9d5778bf5 +- 이음(755 ↔ 756): `B05_Profile_UI_RouteEdit.ts` 755 몫 = `createExpectedDraw` 인자(measuring · onHistory · frame 걷음) · `bindHistoryControls` 의 local 한 줄 · 받은 노선 색 = 기존 `--draw-map-route-3`(theme.css 안 고침) · Render · Chrome · SnapStep 안 고침 · 635_3 RouteEdit 줄 한도 683 → 690 + +## 756 B05 · D03 도구줄 · 등고선 (요구 589-2 · 4 · 10 · 12 · sub5) + +- 정함(브레인): 켠 동안 추천 선 진한색 그대로 · 스냅 + 스냅 간격 한 아이콘 · 추천노선 = 여럿 고르기(색 구분 · 「끔」 걷음 · 하나도 안 고르면 꺼짐 · 다른 아이콘이 쓰는 여럿 고르기 부품 재사용) · 켠 동안 등고선 = 켜기 전 지도 영역 또는 보는 영역 따라 불러옴(D03 와 같은 길 · LAS 면 전체) +- [x] 756-1 추천 선 색 · 스냅 하나 · 추천노선 여럿 고르기 · 등고선 영역 — 확인 = 사진 · 등고선 띠 폭 전후(215 → 144 px 걷힘) + - 검증완료 — 시험 756_1(새) · 757_1(새) · 624_2 · 634_2 · 626_1 · 636_2 · 390_4 · 553_4 · 626_3 통과 · typecheck 0 · 켠 동안 추천 선 색 픽셀 수 끔과 같음(진한 그대로) · 스냅 한 아이콘(측점 No. · 10 m · 끔) · 추천노선 둘 고름(색 점 · 점선 꼴 다름) · LAS 켠 동안 띠 없음 · 도엽은 계획선 + 예정노선 띠(시험 3 건) · ORCA 5180 길 확인 + +## 757 D03 · B05 그리기 칸 긋기 단추 걷음 (요구 589-9 · sub5 · 756 뒤) + +- 정함(브레인): 긋기 단추 걷고 옵션 넷 켬끔으로 시작 · 끝(하나라도 켜면 긋기 시작 · 모두 끄면 끝) · D03 · B05 같은 키트 +- [x] 757-1 옵션 넷 켬끔 = 시작 · 끝 — 확인 = D03 · B05 긋기 시작 · 끝 · 사진 + - 검증완료 — D03 · B05 [긋기] 단추 없음 · 방식 켬 = draw · 다른 방식 = draw 유지(방식만 바뀜) · 고른 방식 다시 누름 · Enter · Esc = idle + 칸 모두 풀림 · 노드 판 그대로 · 시험 757_1 · 553_4 · 626_3 통과 · typecheck 0 · ORCA 5180 + +## 754 B04 등고선 = 지표면 외곽 · 횡단배수 첫 열림 빈 폼 (요구 589-11 · 13 · 근거 _분석_589-B) + +- 정함(브레인): 11 = 어느 방식을 지어도 그 필터 dtm 이 낡았으면 같이 다시 지음(한 곳) · 등고선 캐시 판단에 dtm · 격자 파일 때 넣음 · ptd 확정 시 dtm_ptd 없음 흠도 같이 · 사용자 프로젝트(가이드 · 테스터) 다시 짓기는 아침 물음 · 13 = 재현 안 됨 · 첫 열림 한 번 빈 폼 → 경쟁 조건 가름(새 탭 · 캐시 빈 · 구조물 고른 채 B06 첫 열림 반복) +- [x] 754-1 (sub7) 등고선 · 격자 외곽 = 지금 지표면 외곽 · 캐시 판단 — 확인 = 검증 프로젝트 하나 다시 지어 등고선 외곽 = 3D 외곽 · ptd 확정 길 · 시험 + - 검증완료 — 검증_sub7 낡은 외곽 흉내(dtm 28.2 만 · 지문 다름) 뒤 [서피스 생성] classification·implicit = dtm · 격자 34.46 만 m² · glb 34.16 만 · 등고선 밖 0.16 % · dtm 새것이면 등고선 다시 셈 · ptd·implicit dtm_ptd 생김(36.19 만) → 확정 체인 B05 · B06 완료 · 노선 181 점 표본 181 · 시험 50 통과 · typecheck 0 · 3f4fd188e +- [x] 754-2 (sub8) 횡단배수 좌측 폼 첫 열림 빈 폼 재현 · 까닭 · 고침 — 확인 = 새 탭 반복 20 번 빈 폼 0 · 사진 + - 검증완료 — 재현 B06 고름 → 첫 B05 5/5 · B05 이른 고름 3/5 빈 폼(공용 폼 setTypes 가 군 · 종류만 비움) → 고친 뒤 새 탭 24+20+10 번 빈 폼 0 · 옵션 값(관경 1000 · 집수정 1.5 · 기슭막이 10) 예정노선 수정 켬끔 · 새로고침 · 1080/768 · 밝음/어두움 그대로 · 저장 → 다시 열기 같은 값 · 시험 38 · typecheck 0 · 검증 값 되돌림 diff --git a/docs/raw/요구_1103/사용자요구_1103.md b/docs/raw/요구_1103/사용자요구_1103.md new file mode 100644 index 000000000..852711fdc --- /dev/null +++ b/docs/raw/요구_1103/사용자요구_1103.md @@ -0,0 +1,21 @@ +# 사용자 요구 장부 (11-03) + +## 요구 590 (11-03 · 사용자 · 감시 점검) + +감시스크립트 이상함 점검. 서브3 질문 맞음 + +## 요구 591 (11-03 · 사용자 · 590 이어서) + +서브3의 bash 승인은 내가 넣었음 + +## 요구 592 (11-03 · 사용자) + +bash 승인으로 종종 걸림 대책 확인 + +## 요구 593 (11-03 · 사용자 · 592 이어서) + +그럼 정리는 어떻게 함? 삭제는 대부분 tmp 폴다 같은데 tmp폴다는 마음대로 써도 됨. + +## 요구 594 (11-03 · 사용자 · 593 이어서) + +지금까지 대부분 지우기가 tmp폴다가 맞지? 최근 일주일만 보면됨 diff --git a/resources/tester/test_626_3_expected_edit.py b/resources/tester/test_626_3_expected_edit.py index 225d281b9..6404daaae 100644 --- a/resources/tester/test_626_3_expected_edit.py +++ b/resources/tester/test_626_3_expected_edit.py @@ -76,9 +76,7 @@ def test_D03_긋기_고르기_메뉴_속성판_그대로_한_선(): for fake in ("NO_POINTS", "epsg: () => 0", "addPoint: () => undefined", "oneLine"): assert fake not in ui, fake assert "if (!kit.key(event)) return;" in ui # 같은 단축키(키트) - assert ( - 'keyIs(event, "plan.undo") || keyIs(event, "plan.redo")' in ui - ) # 되돌리기 = 페이지 사진첩 + assert '? (step("undo"), true)' in ui # 켠 동안 Ctrl+Z = 이 판 사진첩(755-1) # 지도 입력 = 키트 한 함수 · 잡기 단계(capture) — 재기 · 계획선 고르기보다 먼저 assert "capture: { active: () => open, busy: () => busy }," in ui assert "canvas.addEventListener(type, run, !!capture)" in kit @@ -99,7 +97,7 @@ def test_D03_긋기_고르기_메뉴_속성판_그대로_한_선(): for src in (draw, menu, props): assert "oneLine" not in src table = _src(D03 / "D03_PlanAnalysis_UI_Features.ts") - # 638-3 — 세트 칸 · 세트 줄 = B05 보기만 · [잇기] = B05 숨김 · 세트 고침 = assignSet 한 길 + # 638-3 — 세트 칸 · 세트 줄 = B05 보기만 · 세트 고침 = assignSet 한 길 · 755-1 [잇기] = B05 사용(끊긴 곳 이음) assert 'disabled: !params.editable() || !featureOn(page, "set")' in props assert 'featureOn(page, "join") && edit.isEnd(' in props + menu assert "setPiecesYear" not in props and "setLineYear" not in _src( @@ -109,7 +107,7 @@ def test_D03_긋기_고르기_메뉴_속성판_그대로_한_선(): ("branch", "hide"), ("years", "view"), ("set", "view"), - ("join", "hide"), + ("join", "use"), ("points", "hide"), ("draw", "use"), ): diff --git a/resources/tester/test_635_2_plan_context.py b/resources/tester/test_635_2_plan_context.py index dde01b129..048e79f55 100644 --- a/resources/tester/test_635_2_plan_context.py +++ b/resources/tester/test_635_2_plan_context.py @@ -133,6 +133,31 @@ def test_plan_source_skips_densify_keeps_trim(tmp_path: Path, monkeypatch) -> No assert asyncio.run(source.read_plan_source(uuid4())).status_code == 404 +def test_plan_source_clips_to_expected_csv_ends(tmp_path: Path, monkeypatch) -> None: + """755-3 — 지표면이 넓어져 트림이 덜 잘라도 체인 예정노선 파일 양끝에서 자름(같은 길이).""" + monkeypatch.setattr( + source, + "load_design_route", + lambda *a: SimpleNamespace(vertices=[SimpleNamespace(x=x, y=y) for x, y in SPARSE]), + ) + monkeypatch.setattr(source, "build_surface_sampler", lambda *a: object()) + monkeypatch.setattr(source, "trim_route_to_surface", lambda points, sampler: points) + surface = {"source_filter": "classification", "method": "dtm", "smooth": True} + dense = densify_route(SPARSE[3:30], source.SPACING_M) + write_route_csv(expected_route_csv_path(tmp_path), [{"x": x, "y": y} for x, y in dense]) + got = source.source_vertices(tmp_path, surface, None) + + def length(points): + return sum(math.dist(a, b) for a, b in zip(points, points[1:])) + + assert abs(length(got) - length(dense)) < 1e-3 # 파일 좌표 0.1 mm 반올림 + assert math.dist(got[0], SPARSE[3]) < 1e-3 and math.dist(got[-1], SPARSE[29]) < 1e-3 + assert len(got) == 27 # 끝 = 꼭짓점 바로 앞 · 겹친 점 없음 + # 파일 없음 = 그대로 + expected_route_csv_path(tmp_path).unlink() + assert len(source.source_vertices(tmp_path, surface, None)) == len(SPARSE) + + MAIN = { "id": "main", "vertices": [[200000.0, 450000.0], [200100.0, 450000.0], [200200.0, 450000.0]], diff --git a/resources/tester/test_635_3_b05_plan_panel.py b/resources/tester/test_635_3_b05_plan_panel.py index bc072670e..a66141f89 100644 --- a/resources/tester/test_635_3_b05_plan_panel.py +++ b/resources/tester/test_635_3_b05_plan_panel.py @@ -1,10 +1,10 @@ -"""635-3 B05 왼쪽 「위치」 · 「그리기」 칸 · 「예정노선 수정」 · 받은 노선 비교 · [받은 노선으로] · 어둡게(요구 546). +"""635-3 B05 왼쪽 「그리기」 칸(755-2 위치 칸 걷음) · 「예정노선 수정」 · 받은 노선 비교 · [받은 노선으로] · 어둡게(요구 546). -- 칸 = 예정노선 키트(`createPlanKit` 「b05」) 칸 · 자리 = `registerPanelSection` order 1 · 2(종단 보기) · 편집기가 채움. -- 위치 = 찾기 → 지도 이동만(지형 받기 · 기준점 없음) · 고칠 선 = 받은 원본 꼭짓점(`GET /route/plan/source` · 635-2). +- 칸 = 예정노선 키트(`createPlanKit` 「b05」) 칸 · 자리 = `registerPanelSection` order 1(종단 보기) · 편집기가 채움. +- 위치 칸 = 기능 표 「site」 b05 숨김(589-8) · 고칠 선 = 받은 원본 꼭짓점(`GET /route/plan/source` · 635-2). - [받은 노선으로] = 받은 꼭짓점 다시 추적 + 초안 `expected` [](고친 예정노선 지움) — 한 걸음 · 페이지 [초기화] 그대로. - 켬 = 아래 계획노선 그림 불투명 `--draw-map-dim`(0.35) · 등고 높이값 · 예정노선 그림은 그대로. -- 큰 파일 안 늘림(RouteEdit 683 · Render 648 + 10 안). +- 큰 파일 안 늘림(RouteEdit 690 · Render 648 + 10 안). ./venv/Scripts/python.exe -m pytest resources/tester/test_635_3_b05_plan_panel.py -q """ @@ -22,10 +22,10 @@ def _src(path: Path) -> str: return path.read_text(encoding="utf-8") -def test_왼쪽_칸_둘_종단_보기_위(): +def test_왼쪽_칸_하나_종단_보기_위(): panel = _src(B05 / "B05_Profile_UI_Panel_Plan.ts") - assert '["site", 1, "위치"]' in panel and '["draw", 2, "그리기"]' in panel - assert 'views: ["profile"]' in panel and "id: `plan-${slot}`" in panel + assert 'id: "plan-draw",' in panel and 'title: "그리기",' in panel and "order: 1," in panel + assert 'views: ["profile"]' in panel and '"site"' not in panel and '위치"' not in panel assert "export function fillPlanPanel(" in panel assert "holder.hidden = !card;" in panel # 비면 자리 숨김 # 다른 칸(구조물 배치 10 · 페이지 설정 20)보다 위 @@ -33,18 +33,18 @@ def test_왼쪽_칸_둘_종단_보기_위(): assert "order: 20" in _src(B05 / "B05_Profile_UI_Panel_Sections.ts") -def test_키트_칸_위치_찾기_이동만_원본_꼭짓점(): +def test_키트_칸_위치_걷음_원본_꼭짓점(): ui = _src(B05 / "B05_Profile_UI_ExpectedDraw.ts") body = ui.split("*/", 1)[1] - assert 'page: "b05",' in body and "cards: { site: { parts: [find.root] } }," in body - assert "fillPlanPanel({" in body and "createPlaceFind(" in body - assert "coords.toPlan(hit.lon, hit.lat)" in body # 지도 이동만(서버 변환 · 635-5) + assert 'page: "b05",' in body and "cards: {}," in body + assert "fillPlanPanel(drawCard);" in body and "createPlaceFind" not in body + assert "toPlan" not in body and "frame" not in body # 찾기 이동 걷음(589-8) for banned in ("createPlanTerrain", "ensureCovered", "createPlanSite", "createFloatPanel"): assert banned not in body, banned assert "/route/plan/source" in body and "expected_source" in body assert "shown === original ? received() : shown" in body # 안 고친 선 = 원본 꼭짓점 features = _src(ROOT / "D03_PlanAnalysis" / "D03_PlanAnalysis_UI_Features.ts") - assert re.search(r'key: "site",[^}]*b05: "use"', features) + assert re.search(r'key: "site",[^}]*b05: "hide"', features, re.S) assert re.search(r'key: "terrain",[^}]*b05: "hide"', features, re.S) @@ -56,10 +56,10 @@ def test_수정_켬끔_받은_노선_비교_되돌림(): assert "받은 노선 ${meters(received())} m → 고친 노선 ${meters(line())} m" in body assert 'label: "받은 노선으로"' in body and "follow(received(), [])" in body assert "resetButton.disabled = !edited || busy;" in body - assert "if (!sameLine(points, base)) await follow(points, points);" in body + assert "return sameLine(points, base) || (await follow(points, points));" in body # 끌 때 한 번 main = _src(B05 / "B05_Profile_UI_RouteEdit.ts") - assert "frame: () => ({ meta, setView: (next: ViewState) => (view = next) })," in main - assert len(main.splitlines()) <= 683 + assert "frame:" not in main + assert len(main.splitlines()) <= 690 def test_켬_때_아래_노선_어둡게(): diff --git a/resources/tester/test_635_5_b05_coords.py b/resources/tester/test_635_5_b05_coords.py index b850ccc8b..d79060eb8 100644 --- a/resources/tester/test_635_5_b05_coords.py +++ b/resources/tester/test_635_5_b05_coords.py @@ -1,7 +1,7 @@ -"""635-5 B05 경위도 ↔ 사업지 좌표 서버 변환(요구 546) — 찾기 이동 · 속성 판 경위도. +"""635-5 B05 경위도 ↔ 사업지 좌표 서버 변환(요구 546) — 속성 판 경위도(찾기 이동 = 755-2 걷음). - 서버 `POST /route/coords` = 작업 좌표계(`resolve_project_crs` · WKT 그대로) pyproj 묶음 변환. -- 화면 = 찾기 이동 · 노드 고름 때만 부름 · 같은 점 캐시 · 바탕 `tm_grid` 늘려 쓰기 걷음. +- 화면 = 노드 고름 때만 부름 · 같은 점 캐시 · 바탕 `tm_grid` 늘려 쓰기 걷음. ./venv/Scripts/python.exe -m pytest resources/tester/test_635_5_b05_coords.py -q """ @@ -50,12 +50,12 @@ def test_라우터_등록(): assert "resolve_project_crs(root, route_crs_input=" in router -def test_화면_서버_변환_찾기_고름만(): +def test_화면_서버_변환_고름만(): panel = (B05 / "B05_Profile_UI_Panel_Plan.ts").read_text(encoding="utf-8") assert "`/projects/${projectId}/route/coords`" in panel # requestJson 이 /api 를 붙임 assert "known.has(key)" in panel # 같은 점 캐시 ui = (B05 / "B05_Profile_UI_ExpectedDraw.ts").read_text(encoding="utf-8") - assert "coords.toPlan(hit.lon, hit.lat)" in ui and "lonLat: coords.lonLat," in ui + assert "lonLat: coords.lonLat," in ui and "toPlan" not in ui + panel assert "normOfLonLat" not in ui and "tm_grid` 늘려" not in ui # 끌기 중엔 판을 안 그림 — 속성 판은 고름 · 놓음(commit) 때만 kit = (ROOT / "D03_PlanAnalysis" / "D03_PlanAnalysis_UI_Kit.ts").read_text(encoding="utf-8") diff --git a/resources/tester/test_719_4_sangeun_sheet.py b/resources/tester/test_719_4_sangeun_sheet.py index 6308c46fd..540aefc77 100644 --- a/resources/tester/test_719_4_sangeun_sheet.py +++ b/resources/tester/test_719_4_sangeun_sheet.py @@ -36,6 +36,7 @@ def made(): def _ts(sg, machines): files = [ + "common_util/common_util_money.ts", "B09_Estimation/B09_Estimation_Calc_Base.ts", "A00_Common/calc_sheet.ts", "B09_Estimation/B09_Estimation_UI_Tabs_Sangeun_Sheet.ts", diff --git a/resources/tester/test_721_find_pick.py b/resources/tester/test_721_find_pick.py index b81649914..d13084ff0 100644 --- a/resources/tester/test_721_find_pick.py +++ b/resources/tester/test_721_find_pick.py @@ -26,7 +26,7 @@ def _text(path: Path) -> str: def test_721_1_단추_이름과_자리(): detail = _text(M02 / "M02_MasterTemplete_Sangeun_Detail.ts") - assert "children: [views.root, addButton, baseHint]" in detail + assert "children: [views.root, addButton]" in detail # 750-2 「시험 계산 = 바탕」 안내 걷음 assert "M02_SangeunPickBase" not in detail # 727-2 단추 걷음 locale = _text(ROOT / "ui_template" / "ui_template_locale_m2.ts") assert "M02_SangeunPickBase" not in locale diff --git a/resources/tester/test_750_2_views_one_answer.py b/resources/tester/test_750_2_views_one_answer.py index ca3836567..6f8bf6815 100644 --- a/resources/tester/test_750_2_views_one_answer.py +++ b/resources/tester/test_750_2_views_one_answer.py @@ -30,3 +30,66 @@ def test_calc_view_answer_hook_skips_logic_lib(): ts = _ts("M01_MasterData/M01_MasterData_UI_Logic_Calc.ts") assert "ctx.onText && !ctx.answer" in ts # 미리보기 안 부름 assert "if (ctx.answer)" in ts and "runCalc" in ts # M01 옛 길 그대로 + + +def test_752_1_source_panel_only_on_sangeun_screen(): + """752-1 원문 패널 — 단가산출 화면만 켬 · 일위대가 줄 모달 안 상세는 끔 · 기초마다 탭 · 좁은 폭 접힘.""" + side = _ts("M02_MasterTemplete/M02_MasterTemplete_Sangeun_Side.ts") + assert "/m02/logic-lib/source" in side and "createSidePanels" in side + assert "OPEN_FROM" in side and "createSegment" in side + assert "source: true" in _ts("M02_MasterTemplete/M02_MasterTemplete_Sangeun_View.ts") + assert "source" not in _ts("M02_MasterTemplete/M02_MasterTemplete_Labor_RowSk.ts") + detail = _ts("M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts") + assert "opt.source ? createSangeunSide()" in detail and "side?.set(" in detail + + +def test_753_2_option_off_flag_reaches_sheet_and_views(): + """753-2 옵션으로 빠진 줄 — SK 줄 `옵션꺼짐` → 표 줄 `off` → 계산서 줄 `옵션꺼짐`(공용 계산서 · 상세 · + 텍스트 수식 · 시험 계산이 안 그림) · 소계 · 금액은 그대로.""" + import json + import re + import shutil + import subprocess + + import pytest + + if shutil.which("node") is None: + pytest.skip("node 가 없음") + m02 = ROOT / "M02_MasterTemplete" + strip = lambda p: re.sub(r"^import .*$", "", p.read_text("utf-8"), flags=re.M) # noqa: E731 + src = "\n".join( + [ + strip(m02 / "logic" / "M02_Born_Tag.ts"), + strip(m02 / "logic" / "M02_Sangeun_Undo.ts"), + strip(m02 / "logic" / "M02_Sangeun_Cover_Model.ts"), + """ +const line = (cid, 이름, 수량, 금액, 꺼짐) => ({ cid, 이름, 단위: "개", 수량, 단가: 100, 금액, + 비목: { 재료비: 금액 }, 묶음: "재료", ...(꺼짐 ? { 옵션꺼짐: true } : {}) }); +const ans = { 기준: null, 짝잃음: [], 바탕판: "x", 정체: ["material:A#0", "material:B#0"], + 줄: [line("material:A#0", "폼타이 12.7", 0, 0, true), line("material:B#0", "합판", 1, 100, false)] }; +const rows = rowsOf(ans, null, {}); +const groups = sheetGroups(rows); +console.log(JSON.stringify({ off: rows.map((r) => r.off), + sheet: groups.map((g) => [g.비목, g.소계, g.줄.map((x) => [x.이름, x.옵션꺼짐 ?? false, x.금액])]) })); +""", + ] + ) + out = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", src], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert out.returncode == 0, out.stderr + got = json.loads(out.stdout) + assert got["off"] == [True, False] + assert got["sheet"] == [["재료비", 100, [["폼타이 12.7", True, 0], ["합판", False, 100]]]] + sheet = (ROOT / "A00_Common" / "calc_sheet.ts").read_text("utf-8") + assert "b.줄.filter(shown)" in sheet and "g.줄.filter(shown)" in sheet + ho = (ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_Ho.ts").read_text("utf-8") + assert "옵션꺼짐" in ho + assert "옵션꺼짐" in ( + ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_Formula.ts" + ).read_text("utf-8") + assert "옵션꺼짐" in (ROOT / "M01_MasterData" / "M01_MasterData_UI_Logic_Calc.ts").read_text( + "utf-8" + ) diff --git a/resources/tester/test_754_1_contour_outline.py b/resources/tester/test_754_1_contour_outline.py new file mode 100644 index 000000000..1e40bd179 --- /dev/null +++ b/resources/tester/test_754_1_contour_outline.py @@ -0,0 +1,102 @@ +"""754-1 등고선 · 격자 외곽 = 지금 지표면 외곽 (요구 589-11). + +implicit 하나만 지어도 같은 필터 dtm 이 낡았으면 같이 다시 지음 · 등고선 캐시는 dtm · 굳힌 격자가 +바뀌면 다시 셈 · ptd 처럼 dtm 없던 필터도 굳힌 격자가 생김. 서버 함수만 — 합성 점군 · 임시 폴더. +""" + +import json +import os +from pathlib import Path + +import numpy as np + +from B04_PreProcess.B04_PreProcess_Engine_Contour import contour_cache_is_stale +from B04_PreProcess.B04_PreProcess_Engine_Pipeline import build_all_terrain_models +from common_util.common_util_surface_sampler import implicit_grid_npz +from config import config_system as cfg + + +def _banded_plane() -> tuple[dict, np.ndarray]: + axis = np.arange(0.0, 60.0, 0.5) + x, y = np.meshgrid(axis, axis) + points = np.column_stack([x.ravel(), y.ravel(), (100.0 + 0.2 * x).ravel()]) + points = points[~((points[:, 0] > 28) & (points[:, 0] < 33))] # 5 m 빈 띠(z 106 = x 30) + data = {"xyz": points, "bounds": np.array([points.min(axis=0), points.max(axis=0)]).T} + return data, np.ones(len(points), bool) + + +def _config(filter_key: str, precompute: list[str], close_m: float) -> dict: + config = cfg.build_surface_model_config() + config.update( + { + "source_filters": [filter_key], + "precompute": precompute, + "footprint_gap_close_meters": close_m, + "implicit_min_points": 500, + "implicit_max_points": 2_000, + } + ) + return config + + +def _valid_area(path: Path) -> float: + with np.load(path) as grid: + resolution = float(np.asarray(grid["resolution"]).ravel()[0]) + return float(np.count_nonzero(grid["valid_mask"])) * resolution**2 + + +def _band_contour(path: Path) -> bool: + lines = json.loads(path.read_text(encoding="utf-8"))["contours"] + return any(abs(line["level"] - 106.0) < 1e-6 for line in lines) + + +def test_implicit_만_지어도_낡은_dtm_같이_다시(tmp_path: Path) -> None: + data, mask = _banded_plane() + masks = {"csf": mask} + build_all_terrain_models(data, masks, tmp_path, _config("csf", ["dtm", "implicit"], 1.0)) + narrow = _valid_area(tmp_path / "dtm_csf.npz") + contour = tmp_path / "contour_csf_implicit_1.0m.json" + assert not _band_contour(contour) # 1 m 닫기 = 띠 빈칸 · 106 m 줄 잘림 + + # 화면 [서피스 생성] = 고른 표현 하나만 — 외곽 설정(지문)이 바뀌었으니 dtm 도 다시 + manifest = build_all_terrain_models(data, masks, tmp_path, _config("csf", ["implicit"], 3.0)) + methods = manifest["source_filters"]["csf"]["methods"] + assert methods["dtm"]["status"] == "completed" + wide = _valid_area(tmp_path / "dtm_csf.npz") + grid_area = _valid_area(implicit_grid_npz(tmp_path, "csf")) + glb_area = methods["implicit"]["footprint_area_m2"] + assert wide > 1.05 * narrow + assert grid_area == wide # 격자 외곽 = dtm 외곽 + assert abs(glb_area - wide) / wide < 0.05 # 3D 외곽 = 격자 외곽 + assert _band_contour(contour) # 등고선이 넓힌 띠까지 + assert not contour_cache_is_stale(contour, tmp_path / "implicit_csf.npz") + + +def test_dtm_이_바뀌면_등고선_캐시_낡음(tmp_path: Path) -> None: + model = tmp_path / "implicit_csf.npz" + dtm = tmp_path / "dtm_csf.npz" + grid = tmp_path / "implicit_csf_grid.npz" + contour = tmp_path / "contour_csf_implicit_1.0m.json" + assert contour_cache_is_stale(contour, model) # 없음 + for index, path in enumerate((model, dtm, grid, contour)): + path.write_bytes(b"x") + os.utime(path, ns=(10**18 + index, 10**18 + index)) + assert not contour_cache_is_stale(contour, model) + for newer in (dtm, grid): + os.utime(newer, ns=(2 * 10**18, 2 * 10**18)) + assert contour_cache_is_stale(contour, model) + os.utime(newer, ns=(10**18, 10**18)) + tin_smooth = tmp_path / "tin_csf_smooth.npz" + tin_smooth.write_bytes(b"x") + os.utime(tin_smooth, ns=(10**18, 10**18)) + os.utime(dtm, ns=(2 * 10**18, 2 * 10**18)) + assert contour_cache_is_stale(contour, tin_smooth) # 스무딩 면도 같은 필터 dtm 을 봄 + + +def test_ptd_implicit_만_지어도_굳힌_격자(tmp_path: Path) -> None: + data, mask = _banded_plane() + build_all_terrain_models(data, {"ptd": mask}, tmp_path, _config("ptd", ["implicit"], 3.0)) + assert (tmp_path / "dtm_ptd.npz").is_file() + grid = implicit_grid_npz(tmp_path, "ptd") # B05 · B06 표본이 읽는 파일 + with np.load(grid) as loaded: + assert np.isfinite(loaded["z"][loaded["valid_mask"]]).all() diff --git a/resources/tester/test_754_2_types_late_form.py b/resources/tester/test_754_2_types_late_form.py new file mode 100644 index 000000000..0f45c9c65 --- /dev/null +++ b/resources/tester/test_754_2_types_late_form.py @@ -0,0 +1,37 @@ +"""PLAN 754-2 — 구조물 종류 목록이 늦게 와도(`setTypes`) 고른 관 폼이 비지 않음. + +B05 · B06 한 폼(`B05_Profile_UI_Structures_Panel.ts`)을 두 보기가 따로 불러 `setTypes` 를 부름 — +종류 목록을 다시 세우며 군 · 종류를 빈칸으로 돌리므로, 고름이 남아 있으면 그 폼을 다시 열어야 함. +""" + +from __future__ import annotations + +import os +import re + +ROOT = os.path.join(os.path.dirname(__file__), "..", "..") + + +def _set_types_body() -> str: + path = os.path.join(ROOT, "B05_Profile", "B05_Profile_UI_Structures_Panel.ts") + with open(path, encoding="utf-8") as f: + src = f.read() + match = re.search( + r"\n setTypes\(next, pick = \(\) => true\) \{\n(.*?)\n \},\n", src, re.S + ) + assert match, "setTypes 본문을 못 찾음" + return match.group(1) + + +def test_종류를_다시_세운_뒤_고른_관_폼을_다시_엶() -> None: + body = _set_types_body() + reset = body.index("syncTypeOptions();") + assert body.index("loadPipeForm(pipe)", reset) > reset + assert "selectedPipeChainage" in body[reset:] + + +def test_임시_배치_중이면_폼을_다시_열지_않음() -> None: + """임시 배치(A군 우클릭 추가) 중 입력을 지우지 않게 — 옛 동작 그대로.""" + body = _set_types_body() + reset = body.index("syncTypeOptions();") + assert body.index("tempPipeChainage !== null", reset) < body.index("loadPipeForm(pipe)") diff --git a/resources/tester/test_755_expected_flow.py b/resources/tester/test_755_expected_flow.py new file mode 100644 index 000000000..26311c0a3 --- /dev/null +++ b/resources/tester/test_755_expected_flow.py @@ -0,0 +1,98 @@ +"""755 B05 평면 예정노선 수정 흐름(요구 589-1 · 3 · 5 · 6 · 7 · 8). + +- 755-1 켠 동안 = 판만 고침 · 끊김 허용(가운데 묶음 지움) · 이 판 사진첩(Ctrl+Z · 도구줄 단추) · + 끌 때 한 번 = 끊겼으면 알림하고 안 끔 · 바뀌었으면 `/route/follow` 한 걸음 · [잇기] = B05 사용. +- 755-2 켠 동안 재기 = D03 재기 겹 · 받은 노선 = 분홍 점선 + 검은 테 · 켬 설명 걷음 · 위치 칸 걷음. +- 755-3 켠 채 판 고침 = [저장] · [확정] 막고 알림 · 떠남 = 공용 떠남 물음(탭 닫기 = 브라우저 물음). + +./venv/Scripts/python.exe -m pytest resources/tester/test_755_expected_flow.py -q +""" + +from __future__ import annotations + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +B05 = ROOT / "B05_Profile" +D03 = ROOT / "D03_PlanAnalysis" + + +def _body() -> str: + return (B05 / "B05_Profile_UI_ExpectedDraw.ts").read_text("utf-8").split("*/", 1)[1] + + +def _fn(body: str, head: str) -> str: + start = body.index(head) + end = body.index("\n }\n", start) + return body[start:end] + + +def test_켠_동안_판만_한_걸음_사진첩(): + body = _body().replace("\r\n", "\n") + commit = _fn(body, "function commit(") + assert "undoStack.push(network);" in commit and "redoStack = [];" in commit + assert "follow(" not in commit and "apply(" not in commit # 켠 동안 평면선 안 바꿈 + assert "function apply(" not in body and "strokeBase" not in body + assert "onMode: () => params.redraw()," in body # 긋기 끝 = 다시 추적 아님 + assert 'keyIs(event, "plan.undo")\n ? (step("undo"), true)' in body + assert "history: () =>\n open" in body # 끔 = 페이지 사진첩 + history = (B05 / "B05_Profile_UI_RouteEdit_History.ts").read_text("utf-8") + assert "const local = params.local?.();" in history + assert "params.local?.() ?? getHistory()" in history + main = (B05 / "B05_Profile_UI_RouteEdit.ts").read_text("utf-8") + assert "local: () => expectedDraw.history()," in main + assert "onHistory: () => historyControls.sync()," in main + + +def test_끌_때_끊김_알림_안_끔_한_번_추적(): + body = _body().replace("\r\n", "\n") + open_ = _fn(body, "async function setOpen(") + assert "if (!next && open && !(await finish())) return toggle.set(true);" in open_ + finish = _fn(body, "async function finish(") + assert "drawn.length > 1" in finish and "return false;" in finish and "showToast(" in finish + assert "return sameLine(points, base) || (await follow(points, points));" in finish + follow = _fn(body, "async function follow(") + assert ( + "/route/follow" in follow + and "return true;" in follow + and "if (!ok) return false;" in follow + ) + assert "revert" not in body # 실패 · 취소 = 판 그대로(켠 채) + table = (D03 / "D03_PlanAnalysis_UI_Features.ts").read_text("utf-8") + assert re.search(r'key: "join",[^}]*b05: "use"', table, re.S) + assert re.search(r'key: "branch",[^}]*b05: "hide"', table, re.S) # 갈래 · 둘째 선 그대로 막음 + + +def test_재기_D03_겹_켠_동안(): + body = _body() + assert "createPlanMeasure(" in body and "params.measuring()" in body + assert "kit.layers([], [measure.layer])" in body and "layers: liveLayers," in body + assert "idle: () => open && !measuring()," in body # 재는 동안 고르기 · 끌기 쉼 + main = (B05 / "B05_Profile_UI_RouteEdit.ts").read_text("utf-8") + assert "measuring: () => measureControls.mode()," in main + + +def test_받은_노선_색_설명_위치_걷음(): + body = _body() + assert 'strokeCased(context, themeColor("--draw-map-route-3", "#ff8cbc"), 2.5, [8, 5]);' in body + assert "--draw-text-secondary" not in body + assert "HINT =" not in body.replace("OFF_HINT =", "") and "hint.hidden = open;" in body + table = (D03 / "D03_PlanAnalysis_UI_Features.ts").read_text("utf-8") + assert re.search(r'key: "site",[^}]*b05: "hide"', table, re.S) + assert re.search(r'key: "site",[^}]*d03: "use"', table, re.S) # D03 그대로 + theme = (ROOT / "ui_template" / "ui_template_theme.css").read_text("utf-8") + assert "--draw-map-route-3:" in theme + + +def test_켠_채_저장_떠남_알림(): + body = _body().replace("\r\n", "\n") + assert "const pending = (): boolean => open && undoStack.length > 0;" in body + assert "holdDesignSave(canvas, () => (pending() ? HELD : null));" in body + assert "guardProjectLeave(canvas, {\n dirty: pending," in body + save = (ROOT / "A00_Common" / "design" / "design_save.ts").read_text("utf-8") + save = save.replace("\r\n", "\n") + head = save[save.index("export async function saveDesign(") :] + held = head.index("const held = heldSave();") + assert held < head.index("saving.add(ctx.projectId);") # 막음 = 저장 줄 앞 · 가림막 없음 + assert held < head.index("showLoadingOverlay") diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index bbc6ebf11..7c9678d3c 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -207,10 +207,7 @@ export const ui_locales_m2 = { "Lines not priced — total uses the rest · drop the line or fill its input", ], M02_SangeunScaleOnly: ["열린 입력 — 품은 배율만", "Open inputs — quantity as multiplier only"], - M02_SangeunBaseViews: [ - "시험 계산 · 텍스트 수식 = 바탕 단가산출 기초 + 고친 품 · 단가(뺀 줄 · 더한 줄 빼고)", - "Test calc · formula = base M01 logic + edited qty · price (no dropped · added lines)", - ], + M02_SangeunSourceNone: ["원문 연결 없음 — 바탕 로직 없음", "No source — no base logic"], M02_SangeunWait: ["셈 기다리는 중", "Calculating…"], M02_SangeunNoInputs: [ "바탕 단가산출 기초 없음 — 시험 입력 없음",