Merge remote-tracking branch 'origin/dev' into sub_laptop_3

This commit is contained in:
2026-11-03 06:16:18 +09:00
35 changed files with 979 additions and 198 deletions
+11 -3
View File
@@ -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)));
+18
View File
@@ -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();
@@ -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 = (
@@ -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,
@@ -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:
+26 -2
View File
@@ -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)
+139 -80
View File
@@ -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<RoutePlanResponse, "planned" | "nodes" | "curves" | "node_edits">;
@@ -58,11 +62,13 @@ export interface ExpectedDrawParams {
rotation: () => Pick<MapRotation, "unrotate" | "rerotate" | "uprightRad">;
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<Vertex>, b: ReadonlyArray<Vertex>): 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<void> {
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<void> {
if (busy || !open) return;
/** 끌 때 한 번 — 끊겼으면 알림 · 안 끔 · 바뀌었으면 다시 추적(실패 · 물음 취소 = 안 끔). 참 = 꺼도 됨. */
async function finish(): Promise<boolean> {
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<void> {
if (busy || points.length < 2) return;
/** 확인창(켠 뒤 처음만) → `/route/follow`(저장 0) → 편집기 한 걸음(초안 `expected` = `expected`). 참 = 됨. */
async function follow(points: Vertex[], expected: Vertex[]): Promise<boolean> {
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,
};
}
+24 -37
View File
@@ -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<Record<Slot, HTMLElement>> = {};
const cards: Partial<Record<Slot, HTMLElement | null>> = {};
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<Slot, HTMLElement | null>): 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<Vertex | null>;
/** 사업지 → 경위도 — 받은 점이면 바로 · 처음이면 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<Vertex | null> =>
const ask = (to: "lonlat", point: Vertex): Promise<Vertex | null> =>
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<string, Vertex | null>();
return {
toPlan: (lon, lat) => ask("plan", [lon, lat]),
lonLat(x, y) {
const key = `${x},${y}`;
if (known.has(key)) return known.get(key) ?? null;
+3 -1
View File
@@ -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);
@@ -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<HTMLButtonElement>('[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]();
@@ -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가 다시 저장을 걸어
@@ -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",
@@ -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", {
@@ -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, [
@@ -105,6 +105,8 @@ export interface CalcLine {
까닭?: string;
/** (M02 744-1) 둘째 기초 세부 줄 — `수량`은 M01 원문 · 이 칸은 1 기준단위 몫 비목(금액 · 비목은 머리 수량 곱한 상자 몫) */
원문?: { 비목?: Record<string, number | null> };
/** (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 =
@@ -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", {
@@ -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 } });
@@ -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<CompareLook, { tone: BadgeTone; key: LocaleKey }> = {
기초만: { 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,
@@ -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<string, LogicInput[]>();
const infoOf = new Map<string, SourceTab>(); // 더한줄 로직 → 원문 탭(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<string>((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) — 둘째 기초 세부 줄은 보기만 */
@@ -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();
},
};
}
@@ -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
}
@@ -91,6 +91,8 @@ export interface SkLine {
원문?: { 수량: number | null; 금액: number | null; 비목?: Record<string, number | null> };
/** 재료 줄이 잡은 품목(시험 계산 재료 칸 · 줄 모달) */
품목?: 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<string, number>,
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 } : {}),
});
}
}
+172
View File
@@ -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 · 검증 값 되돌림
@@ -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폴다가 맞지? 최근 일주일만 보면됨
+3 -5
View File
@@ -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"),
):
@@ -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]],
+15 -15
View File
@@ -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_켬_때_아래_노선_어둡게():
+4 -4
View File
@@ -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")
@@ -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",
+1 -1
View File
@@ -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
@@ -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"
)
@@ -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()
@@ -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)")
@@ -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")
+1 -4
View File
@@ -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: [
"바탕 단가산출 기초 없음 — 시험 입력 없음",