From 702f3e68d954b9ce4b1a7f6c27678e84c5c50db9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 14:47:39 +0900 Subject: [PATCH 1/5] =?UTF-8?q?fix(B06):=20=ED=8F=AC=EC=9E=A5=20=EB=8B=A8?= =?UTF-8?q?=EC=B6=94=20=EC=A7=80=EC=9B=80=20=C2=B7=20=ED=8F=AC=EC=9E=A5=20?= =?UTF-8?q?=3D=20=EB=85=B8=EB=A9=B4=EA=B3=B5=20=EB=B2=94=EC=9C=84=EB=A7=8C?= =?UTF-8?q?=20=C2=B7=20=ED=8F=AC=EC=9E=A5=20=EA=B6=8C=EC=9E=A5=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EC=A4=84(138-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버 paved_at 저장값 인자 지움 · enforce_pavement_ranges 켬↔끔 양쪽 · 단건 계산도 범위 규칙 - 브라우저 재계산 pavedBySpecs(같은 규칙) · CARRY_INPUT_KEYS 에서 paved 뺌 - 카드 「⚠ 포장 권장」 배지 → 알림 줄 「⚠ 포장 권장 · n측점」 · 로케일 정리 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU --- B06_Section/B06_Section_Cross_Refresh.ts | 10 ++++- B06_Section/B06_Section_Router.py | 4 +- B06_Section/B06_Section_Router_Design.py | 34 +++++++++------- B06_Section/B06_Section_UI_Cross_Design.ts | 22 +++-------- .../B06_Section_UI_Cross_FillSlope_Notice.ts | 16 +++++++- .../B06_Section_UI_Style_Cross_Areas.css | 7 ---- .../tester/test_74_route_confirm_initial.py | 3 +- .../tester/test_b06_pavement_and_ford.py | 39 +++++++++++++++---- ui_template/ui_template_locale_b2.ts | 4 +- 9 files changed, 85 insertions(+), 54 deletions(-) diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index 16f1a143b..aafbd6091 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -108,6 +108,14 @@ const PRESERVED_KEYS = [ ...SPOIL_KEYS, ] as const; +/** + * 포장 = 노면공 · 물넘이 범위 안만(138-1) — 측점에 얹힌 제원(`pavement` · `ford_pavement`)이 곧 범위다. + * 서버 짝 = `B06_Section_Router_Design.paved_at` · 저장값 · 카드 선택은 쓰지 않는다. + */ +export function pavedBySpecs(section: Pick): boolean { + return Boolean(section.pavement?.length) || Boolean(section.ford_pavement); +} + function preserveUserFields( next: NonNullable, previous: CrossSection["design"], @@ -293,7 +301,7 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { ditchType: choice?.ditch_type ?? (typeof design.ditch_type === "string" ? design.ditch_type : "standard"), - paved: choice?.paved ?? Boolean(design.paved), + paved: pavedBySpecs(section), standard, berm: bermAt(section.chainage_m), rockBoundaryOffsetM, diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index a1963de5a..074fd5e88 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -44,6 +44,8 @@ from B06_Section.B06_Section_Router_Design import ( USER_TOUCHED_KEYS, ford_drop_at, ford_surface_drops, + paved_at, + pavement_ranges, stored_berm, stored_cut_slope, ) @@ -640,7 +642,7 @@ async def compute_cross_section_design( section_mode=request.section_mode, ditch_side=request.ditch_side, ditch_type=request.ditch_type, - paved=request.paved, + paved=paved_at(request.chainage_m, pavement_ranges(project_root)), standard=request.standard_cross_section, rock_boundary_offset_m=request.rock_boundary_offset_m, two_stage_slope=request.two_stage_slope, diff --git a/B06_Section/B06_Section_Router_Design.py b/B06_Section/B06_Section_Router_Design.py index 7722522ce..b12160741 100644 --- a/B06_Section/B06_Section_Router_Design.py +++ b/B06_Section/B06_Section_Router_Design.py @@ -110,12 +110,17 @@ def ford_drop_at(chainage_m: float, drops: list[tuple[float, float]]) -> float: return 0.0 -def paved_at(chainage_m: float, ranges: list[tuple[float, float]], stored: Any = None) -> bool: - """이 측점을 포장으로 볼 것인가 — 구간 안이면 강제, 밖이면 사용자 저장값(기본 비포장).""" - for start, end in ranges: - if start - 1e-9 <= chainage_m <= end + 1e-9: - return True - return bool(stored) if isinstance(stored, bool) else False +# 범위 끝 허용오차(m) — 브라우저 짝(`common_util_structure_pavement` · `_culvert_sets`)과 같은 값. +_PAVED_EDGE_TOLERANCE_M = 0.02 + + +def paved_at(chainage_m: float, ranges: list[tuple[float, float]]) -> bool: + """이 측점을 포장으로 볼 것인가 — 노면공 · 물넘이 범위 안만 포장, 밖은 비포장(저장값 무시). + + 브라우저 짝 = `B06_Section_Cross_Refresh.ts` `pavedBySpecs`(같은 규칙 · 138-1). + """ + tol = _PAVED_EDGE_TOLERANCE_M + return any(start - tol <= chainage_m <= end + tol for start, end in ranges) # 사용자 조작값 — 다시 계산해도 그대로 승계한다. **이 목록이 서버의 유일한 한 벌이다.** @@ -152,7 +157,6 @@ CARRY_INPUT_KEYS = ( "ground_type", "ditch_type", "ditch_choice", - "paved", "two_stage_slope", "rock_boundary_offset_m", "cut_slope_ratio_user", @@ -178,23 +182,23 @@ def enforce_pavement_ranges( project_root: Path, standard: dict[str, Any] | None = None, ) -> int: - """포장 구간·물넘이 범위 안 측점을 포장으로 맞춘다 — 저장분이 비포장이어도 그렇다. + """포장 = 노면공 · 물넘이 범위 안만 — 저장분과 다르면 켬 · 끔 양쪽으로 맞춘다(138-1). 사용자가 구간을 지정하면 그 안 횡단도에 **자동 반영**된다(2026-08-28 사용자 확정). + 범위 밖 옛 `paved=true`(지운 노면공 · 옛 카드 단추)는 비포장으로 되돌린다. 포장 여부는 횡단경사·포장층을 바꾸므로 플래그만 갈아 끼우지 않고 다시 계산한다. 사용자 조작값(반폭·구조물 조정)은 그대로 승계한다. """ ranges = pavement_ranges(project_root) - if not ranges: - return 0 ford_drops = ford_surface_drops(project_root) changed = 0 for section in cross_sections: design = section.get("design") - if not isinstance(design, dict) or design.get("paved"): + if not isinstance(design, dict): continue chainage = float(section.get("chainage_m", 0.0)) - if not paved_at(chainage, ranges): + wanted = paved_at(chainage, ranges) + if bool(design.get("paved")) == wanted: continue try: recomputed = compute_cross_design( @@ -204,7 +208,7 @@ def enforce_pavement_ranges( section_mode=str(design.get("section_mode") or "left_cut"), ditch_side=design.get("ditch_side"), ditch_type=str(design.get("ditch_type") or "standard"), - paved=True, + paved=wanted, standard=standard, rock_boundary_offset_m=design.get( "rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M @@ -449,7 +453,7 @@ def recompute_designs_for_alignment( section_mode=str(stored.get("section_mode") or modes.get(key, "left_cut")), ditch_side=stored.get("ditch_side"), ditch_type=str(stored.get("ditch_type") or "standard"), - paved=paved_at(chainage, paved_ranges, stored.get("paved")), + paved=paved_at(chainage, paved_ranges), standard=standard, rock_boundary_offset_m=session_offsets.get( key, @@ -527,7 +531,7 @@ def compute_default_designs( section_mode=default_modes.get(round(chainage_m, 3), "left_cut"), ditch_type=str(user.get("ditch_type") or "standard"), # 포장은 사용자 구간 지정만 켠다 — 경사 제안은 경고로만 남는다(2026-08-28). - paved=paved_at(chainage_m, paved_ranges, user.get("paved")), + paved=paved_at(chainage_m, paved_ranges), standard=standard, rock_boundary_offset_m=( STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M if rock_offset is None else rock_offset diff --git a/B06_Section/B06_Section_UI_Cross_Design.ts b/B06_Section/B06_Section_UI_Cross_Design.ts index 19e1e8dbc..813759369 100644 --- a/B06_Section/B06_Section_UI_Cross_Design.ts +++ b/B06_Section/B06_Section_UI_Cross_Design.ts @@ -1,6 +1,6 @@ /* ============================================================================= * B06_Section_UI_Cross_Design.ts - * 측점 표준횡단 설계 지정 컨트롤(지반유형·단면유형·측구위치·측구형식·포장)과 + * 측점 표준횡단 설계 지정 컨트롤(지반유형·단면유형·측구위치·측구형식)과 * 설계선·암 경계선·포장층 오버레이. * * 카드 헤더 아래에 세그먼트 버튼을 배치하고, 선택이 바뀌면 onChange로 계산을 @@ -439,25 +439,13 @@ export function buildDesignControls( !rockCut, L("B06_Design_Disabled_RockCut"), ); - // 포장 토글: 라벨 삭제, 버튼명 활성="포장"/비활성="비포장"(컬러 유지) — E-7. - const paved = toggle("", state.paved, L("B06_Design_Paved_On"), L("B06_Design_Paved_Off"), () => { - state.paved = !state.paved; - emit(); - }); - // B05 법정 경사 분석이 포장을 제안한 측점은 근거 문구를 배지·툴팁으로 표기한다. - if (design?.pavement_suggested) { - paved.button.title = L("B06_Design_Paved_Suggested"); - const badge = document.createElement("span"); - badge.className = "b06-design__paved-badge"; - badge.textContent = "⚠"; - badge.title = L("B06_Design_Paved_Suggested"); - paved.wrap.append(badge); - } + // 포장 단추는 없다(138-1) — 포장 = 노면공 · 물넘이 범위만(`pavedBySpecs`) · 「포장 권장」은 알림 줄. + // `state.paved` 는 저장값 그대로 실어 보낸다(재계산이 범위로 다시 정함). // 오버플로 드롭다운(N-2-2): 컨트롤이 카드 폭에서 한 행을 넘치면 뒤쪽부터 우측 "⋯" // 드롭다운(세로 배치)으로 옮겨 다음 행으로 밀리는 것을 막는다. 경사 방향은 항상 인라인, - // 이동 우선순위(뒤에서부터): 포장 → 복합경사 → 측구형식 → 측구 토글. - const moveable = [ditchToggle.wrap, ditchTypeSeg, twoStage.wrap, paved.wrap]; + // 이동 우선순위(뒤에서부터): 복합경사 → 측구형식 → 측구 토글. + const moveable = [ditchToggle.wrap, ditchTypeSeg, twoStage.wrap]; const more = document.createElement("details"); more.className = "b06-design__more"; const moreSummary = document.createElement("summary"); diff --git a/B06_Section/B06_Section_UI_Cross_FillSlope_Notice.ts b/B06_Section/B06_Section_UI_Cross_FillSlope_Notice.ts index 6db3cdb63..82f665e0f 100644 --- a/B06_Section/B06_Section_UI_Cross_FillSlope_Notice.ts +++ b/B06_Section/B06_Section_UI_Cross_FillSlope_Notice.ts @@ -4,6 +4,7 @@ * (2026-09-14 브레인 승인 (나)). 판정은 `_Cross_FillSlope_Warn`, 여기는 그리기만. * 측점을 누르면 그 카드로 간다. 경고만 — 구조물을 세우거나 값을 바꾸지 않는다. * ⭐ 2026-09-15 — 그 밑에 사면길이 3m 초과 **소단 검토 정보 한 줄**(경고 모양 아님 · 브레인 ③). + * ⭐ 138-1 — 「⚠ 포장 권장 · n측점」 줄(B05 법정 경사 제안 · 포장 범위 밖 측점 · 옛 카드 배지 자리). * ========================================================================== */ import type { CrossSection } from "./B06_Section_Api_Fetch"; @@ -15,6 +16,7 @@ import { type FillSlopeWarning, } from "./B06_Section_UI_Cross_FillSlope_Warn"; import { L, stationLabel } from "./B06_Section_UI_Section_Common"; +import { pavedBySpecs } from "./B06_Section_Cross_Refresh"; export interface FillSlopeNotice { root: HTMLElement; @@ -46,7 +48,8 @@ function noticeBlock(className: string): { const label = L(side === "left" ? "B06_Design_Ditch_Left" : "B06_Design_Ditch_Right"); return `${label} ${open ? "≥" : ""}${lengthM.toFixed(2)}m`; }); - button.textContent = `${stationLabel(section.chainage_m, stationInterval)} ${parts.join(" · ")}`; + button.textContent = + `${stationLabel(section.chainage_m, stationInterval)} ${parts.join(" · ")}`.trim(); button.dataset.stationId = section.station_id; return button; }), @@ -59,7 +62,9 @@ export function createFillSlopeNotice(onPick: (stationId: string) => void): Fill const root = document.createElement("div"); const warn = noticeBlock("b06-section__notice"); const info = noticeBlock("b06-section__notice b06-section__notice--info"); - root.append(warn.root, info.root); + const pave = noticeBlock("b06-section__notice"); + pave.root.title = L("B06_Design_Paved_Suggested"); + root.append(warn.root, pave.root, info.root); root.addEventListener("click", (event) => { const id = (event.target as HTMLElement).closest("button")?.dataset.stationId; if (id) onPick(id); @@ -73,6 +78,13 @@ export function createFillSlopeNotice(onPick: (stationId: string) => void): Fill fillSlopeWarnings(sections, fillSlopeLengths), stationInterval, ); + pave.fill( + `⚠ ${L("B06_Notice_PaveSuggested")}`, + sections + .filter((section) => section.design?.pavement_suggested && !pavedBySpecs(section)) + .map((section) => ({ section, sides: [] })), + stationInterval, + ); info.fill( `ⓘ ${BERM_INFO_TEXT}`, bermReviewStations(sections, slopeRunLengths), diff --git a/B06_Section/B06_Section_UI_Style_Cross_Areas.css b/B06_Section/B06_Section_UI_Style_Cross_Areas.css index 6f08716c6..f526528d2 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Areas.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Areas.css @@ -404,13 +404,6 @@ align-self: center; } -/* 포장 제안 배지: B05 법정 경사 분석이 포장을 권장한 측점 표시 */ -.b06-design__paved-badge { - font-size: 0.72rem; - color: var(--color-warning); - cursor: help; -} - /* 세월교 구체(2026-08-25) — 바닥판은 콘크리트. 측벽은 ㄴ형 집수정 형상을 그대로 쓰므로 `.b06-chart__culvert-basin` 색을 공유한다. */ .b06-chart__ford-slab { diff --git a/resources/tester/test_74_route_confirm_initial.py b/resources/tester/test_74_route_confirm_initial.py index 0211134d8..1c79e715b 100644 --- a/resources/tester/test_74_route_confirm_initial.py +++ b/resources/tester/test_74_route_confirm_initial.py @@ -25,7 +25,8 @@ def test_이월은_사용자_입력만() -> None: "ditch_side", ): assert result_key not in CARRY_INPUT_KEYS - assert {"ground_type", "paved", "ditch_choice", "cut_slope_ratio_user"} <= set(CARRY_INPUT_KEYS) + assert {"ground_type", "ditch_choice", "cut_slope_ratio_user"} <= set(CARRY_INPUT_KEYS) + assert "paved" not in CARRY_INPUT_KEYS # 포장 = 노면공 범위로 새로 정함(138-1) def test_체인이_옛_설계를_통째로_옮기지_않는다() -> None: diff --git a/resources/tester/test_b06_pavement_and_ford.py b/resources/tester/test_b06_pavement_and_ford.py index 975cc3fc3..584c0c472 100644 --- a/resources/tester/test_b06_pavement_and_ford.py +++ b/resources/tester/test_b06_pavement_and_ford.py @@ -126,17 +126,42 @@ def test_missing_truth_files_are_not_an_error(tmp_path): # ── 포장 판정 규칙 ───────────────────────────────────────────────────────── -def test_outside_range_keeps_user_value_and_defaults_unpaved(): - """경사 제안은 더 이상 포장을 켜지 않는다 — 구간 밖은 사용자 값(없으면 비포장).""" +def test_outside_range_is_unpaved(): + """포장 = 노면공 · 물넘이 범위만(138-1) — 밖은 비포장(옛 저장값 인자 없음).""" ranges = [(10.0, 20.0)] assert paved_at(30.0, ranges) is False - assert paved_at(30.0, ranges, True) is True - assert paved_at(30.0, ranges, False) is False + assert paved_at(15.0, ranges) is True + assert paved_at(20.02, ranges) is True # 끝 허용오차 = 브라우저 짝 0.02 m + assert paved_at(20.03, ranges) is False + assert paved_at(15.0, []) is False -def test_inside_range_overrides_user_off_switch(): - """구간 안은 강제 포장 — 물넘이는 콘크리트 노면이고 지정 구간은 자동 반영이다.""" - assert paved_at(15.0, [(10.0, 20.0)], False) is True +def test_enforce_turns_paved_on_and_off(monkeypatch): + """재계산은 켬↔끔 양쪽 — 범위 밖 옛 `paved=true` 는 비포장으로 되돌림(138-1).""" + from pathlib import Path + + from B06_Section import B06_Section_Router_Design as design_mod + + monkeypatch.setattr(design_mod, "pavement_ranges", lambda root: [(0.0, 50.0)]) + monkeypatch.setattr(design_mod, "ford_surface_drops", lambda root: []) + monkeypatch.setattr(design_mod, "design_elevation_from_longitudinal", lambda lon, ch: 100.0) + monkeypatch.setattr(design_mod, "curve_widening_args", lambda section: {}) + monkeypatch.setattr( + design_mod, "compute_cross_design", lambda samples, elev, **kw: {"paved": kw["paved"]} + ) + sections = [ + {"chainage_m": 20.0, "design": {"paved": False}}, # 범위 안 · 끔 → 켬 + {"chainage_m": 80.0, "design": {"paved": True}}, # 범위 밖 · 켬 → 끔 + {"chainage_m": 30.0, "design": {"paved": True}}, # 그대로 + {"chainage_m": 90.0, "design": {"paved": False}}, # 그대로 + ] + assert design_mod.enforce_pavement_ranges({}, sections, Path("."), None) == 2 + assert [s["design"]["paved"] for s in sections] == [True, False, True, False] + + # 노면공을 다 지워도(범위 없음) 옛 켬은 꺼진다 + monkeypatch.setattr(design_mod, "pavement_ranges", lambda root: []) + assert design_mod.enforce_pavement_ranges({}, sections, Path("."), None) == 2 + assert not any(s["design"]["paved"] for s in sections) # ── 독립 기슭막이 ───────────────────────────────────────────────────────── diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index bc98b3039..b23da94cb 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -447,9 +447,6 @@ export const ui_locales_b2 = { B06_Design_DitchType_Legend: ["측구형식", "Ditch type"], B06_Design_DitchType_Standard: ["일반", "Standard"], B06_Design_DitchType_LType: ["L형", "L-type"], - B06_Design_Paved_Legend: ["포장", "Pavement"], - B06_Design_Paved_On: ["포장", "Paved"], - B06_Design_Paved_Off: ["비포장", "Unpaved"], B06_Design_TwoStage_Legend: ["2단계 경사", "Two-stage slope"], B06_Design_TwoStage_On: ["복합경사", "Compound slope"], B06_Design_TwoStage_Off: ["단경사", "Single slope"], @@ -475,6 +472,7 @@ export const ui_locales_b2 = { "측구를 생성한 경우에만 형식을 선택할 수 있습니다.", "Available only when a ditch is created.", ], + B06_Notice_PaveSuggested: ["포장 권장", "Pavement recommended"], B06_Design_Paved_Suggested: [ "종단경사 법정 상한 초과 — 포장 권장 (임도설치 및 관리 등에 관한 규정 별표 1-2)", "Grade exceeds legal limit — pavement recommended (Forest Road Regulation, Annex 1-2)", From 0c22b7d4a165a9cc6d8f53625928756d721d0d09 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 14:49:06 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat(B05):=20=EB=85=B8=EB=A9=B4=EA=B3=B5=20?= =?UTF-8?q?=ED=99=95=ED=8F=AD=20=EA=B5=AC=EA=B0=84=20=EB=8B=A8=EC=B6=94?= =?UTF-8?q?=EC=97=90=20=ED=99=95=ED=8F=AD=20=EB=A9=B4=EC=A0=81=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C(138-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - wideningArea — 측점 사이 사다리꼴 합 · 구간 앞뒤 확폭 0 측점까지 · 노면공 범위로 자름 - 단추 오른쪽 끝 「n.nn ㎡」 · 끈 구간 회색 · 수량엔 안 넣음 - test_87 면적 경우 셋 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU --- .../design/design_structure_widening.ts | 29 +++++++++++++++++++ .../B05_Profile_UI_Structures_Widening.ts | 19 ++++++++++-- .../B05_Profile_UI_Style_Structures.css | 17 +++++++++++ resources/tester/helper_87_widening.cjs | 13 +++++++++ resources/tester/test_87_widening_runs.py | 16 ++++++++++ 5 files changed, 91 insertions(+), 3 deletions(-) diff --git a/A00_Common/design/design_structure_widening.ts b/A00_Common/design/design_structure_widening.ts index 6b841ea38..5e73e062b 100644 --- a/A00_Common/design/design_structure_widening.ts +++ b/A00_Common/design/design_structure_widening.ts @@ -63,6 +63,35 @@ export function wideningRuns( return runs; } +/** + * 확폭 면적(㎡ · 138-2 · 표시만) — 확폭(좌 + 우) 측점 사이 사다리꼴 합 Σ(wᵢ + wᵢ₊₁)/2 × Δ. + * 구간 앞뒤 이웃 측점(확폭 0)까지 이어 붙이고 · 노면공 범위 `[start, end]` 밖은 잘라 냄(자른 끝은 직선 보간). + */ +export function wideningArea( + stations: readonly WideningStation[], + run: WideningRun, + range: { start: number; end: number }, +): number { + const sorted = [...stations].sort((a, b) => a.chainage_m - b.chainage_m); + const first = sorted.findIndex((s) => Math.abs(s.chainage_m - run.start_m) <= WIDENING_TOL_M); + const last = sorted.findIndex((s) => Math.abs(s.chainage_m - run.end_m) <= WIDENING_TOL_M); + if (first < 0 || last < 0) return 0; + const points = sorted + .slice(Math.max(first - 1, 0), last + 2) + .map((s) => [s.chainage_m, wideningOf(s)] as const); + let area = 0; + for (let i = 0; i + 1 < points.length; i += 1) { + const [x0, w0] = points[i]; + const [x1, w1] = points[i + 1]; + const a = Math.max(x0, range.start); + const b = Math.min(x1, range.end); + if (b <= a || x1 <= x0) continue; + const at = (x: number): number => w0 + ((w1 - w0) * (x - x0)) / (x1 - x0); + area += ((at(a) + at(b)) / 2) * (b - a); + } + return area; +} + const round = (value: number): number => Number(value.toFixed(2)); /** 저장 글 → 끈 구간 쌍. 틀린 조각은 버림. */ diff --git a/B05_Profile/B05_Profile_UI_Structures_Widening.ts b/B05_Profile/B05_Profile_UI_Structures_Widening.ts index b649eb18f..3ef019b40 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Widening.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Widening.ts @@ -10,6 +10,7 @@ import { isRunOff, offForRuns, toggleRun, + wideningArea, wideningRuns, type WideningRun, } from "../A00_Common/design/design_structure_widening"; @@ -37,18 +38,24 @@ export function createWideningBox(hooks: { let off = ""; /** 지금 범위의 구간들 — 횡단 자료 · 범위가 없으면 null(+ 안내 글). */ - const runs = (): { list: WideningRun[] | null; note: string } => { + const runs = (): { + list: WideningRun[] | null; + note: string; + area?: (run: WideningRun) => number; + } => { const sections = pipeSections(); if (!sections?.length) return { list: null, note: "횡단 자료 없음" }; const { start, end } = hooks.range(); if (start === null || end === null || end <= start) return { list: null, note: "시작 · 종료 측점을 넣으면 보임" }; const list = wideningRuns(sections, start, end); - return { list, note: list.length ? "" : "범위 안 확폭 없음" }; + // 확폭 면적(138-2) — 표시만 · 수량엔 안 넣음 + const area = (run: WideningRun) => wideningArea(sections, run, { start, end }); + return { list, note: list.length ? "" : "범위 안 확폭 없음", area }; }; const paintBody = (): void => { - const { list, note } = runs(); + const { list, note, area } = runs(); if (!list?.length) { const text = document.createElement("span"); text.className = "b05-structure__widening-note"; @@ -68,6 +75,12 @@ export function createWideningBox(hooks: { const from = stationLabel(run.start_m, step); const to = stationLabel(run.end_m, step); button.textContent = `${from === to ? from : `${from} ~ ${to}`} · 확폭 ${Number(run.widening_m.toFixed(2))} m`; + const size = area?.(run); + const areaText = document.createElement("span"); + areaText.className = "b05-structure__widening-area"; + areaText.textContent = + size !== undefined && Number.isFinite(size) ? `${size.toFixed(2)} ㎡` : ""; + button.append(areaText); button.title = on ? "확폭 포함 — 누르면 규격 폭만" : "규격 폭만 — 누르면 확폭 포함"; button.addEventListener("click", () => { off = toggleRun(list, off, run); diff --git a/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css index bb0919662..a322b7cc9 100644 --- a/B05_Profile/B05_Profile_UI_Style_Structures.css +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -606,3 +606,20 @@ .b05-structure__widening [data-widening-run][aria-pressed="true"]::before { content: "✓ "; } + +/* 확폭 면적(138-2) — 단추 안 오른쪽 끝 · 끈 구간 회색 */ +.b05-structure__widening [data-widening-run] { + display: flex; + align-items: center; + column-gap: 4px; +} + +.b05-structure__widening-area { + margin-left: auto; + padding-left: var(--spacing-8, 8px); + white-space: nowrap; +} + +.b05-structure__widening [aria-pressed="false"] .b05-structure__widening-area { + color: var(--color-text-secondary); +} diff --git a/resources/tester/helper_87_widening.cjs b/resources/tester/helper_87_widening.cjs index 4aea3aeda..5f7a53262 100644 --- a/resources/tester/helper_87_widening.cjs +++ b/resources/tester/helper_87_widening.cjs @@ -50,5 +50,18 @@ const out = { toggledOn: w.toggleRun(runs, off, runs[0]), stale: w.offForRuns(runs, "20-60,0-5,100-130"), empty: w.offForRuns([], "20-60"), + // 확폭 면적(138-2) — 앞뒤 확폭 0 측점까지 사다리꼴 · 범위로 자름 + area: runs.map((r) => w.wideningArea(stations, r, { start: 0, end: 140 })), + areaClipped: w.wideningArea(stations, runs[0], { start: 30, end: 140 }), + areaSingle: w.wideningArea( + [at(0), at(20, 1, 0), at(40)], + { start_m: 20, end_m: 20, widening_m: 1 }, + { start: 0, end: 40 }, + ), + areaMissing: w.wideningArea( + stations, + { start_m: 999, end_m: 999, widening_m: 1 }, + { start: 0, end: 140 }, + ), }; process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_87_widening_runs.py b/resources/tester/test_87_widening_runs.py index 05a91b85d..e0bdf026f 100644 --- a/resources/tester/test_87_widening_runs.py +++ b/resources/tester/test_87_widening_runs.py @@ -58,3 +58,19 @@ def test_끈_구간_글_왕복(out): def test_안_맞는_끈_값은_무시_저장_때_빠짐(out): assert out["stale"] == "20-60" assert out["empty"] == "" + + +def test_확폭_면적_사다리꼴_앞뒤_0_측점까지(out): + # 0~20: (0+0.3)/2×20=3 · 20~40: 10.5 · 40~60: 11.5 · 60~80: 4 → 29 + # 80~100: 5 · 100~120: 11 · 120~140: 6 → 22 + assert [round(a, 6) for a in out["area"]] == [29.0, 22.0] + + +def test_확폭_면적_노면공_범위로_자름(out): + # 30 에서 자름: w(30)=0.525 → (0.525+0.75)/2×10=6.375 + 11.5 + 4 = 21.875 + assert round(out["areaClipped"], 6) == 21.875 + + +def test_한_측점_구간도_면적_자료_없으면_0(out): + assert round(out["areaSingle"], 6) == 20.0 # (0+1)/2×20 × 2 + assert out["areaMissing"] == 0 From cb81e13b29a48afa21ce13f97159501227d6c384 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 14:53:14 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat(B06):=20=EC=95=8C=EB=A6=BC=20=EC=A4=84?= =?UTF-8?q?=20=EC=83=81=EB=8B=A8=20=EA=B3=A0=EC=A0=95=20=C2=B7=20=EC=97=B4?= =?UTF-8?q?=20=EC=88=98=20=EC=B9=B8=20=C2=B7=20=EC=B9=B4=EB=93=9C=20?= =?UTF-8?q?=EA=B7=B8=EB=9E=98=ED=94=84=203=EC=97=B4=20=EB=B9=84=EC=9C=A8?= =?UTF-8?q?=20=EC=9C=A0=EC=A7=80(138-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 알림 줄 sticky · 알림 없어도 줄 유지 · 오른쪽 끝 열 수 칸(1~6 · 기본 3 · pref cross-columns) - 카드 폭 = (칸 폭 − 간격) ÷ 열 수 · 높이 = 폭 × 3열 비율 · pickCrossColumns 지움 - 카드 scroll-margin-top = 고정 줄 높이 · test_138_cross_columns Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU --- A00_Common/b_page_state.ts | 2 + .../B06_Section_UI_Cross_FillSlope_Notice.ts | 7 +- .../B06_Section_UI_Cross_View_Metrics.ts | 14 +++ B06_Section/B06_Section_UI_Section_Columns.ts | 68 +++++------ B06_Section/B06_Section_UI_Section_Common.ts | 5 - B06_Section/B06_Section_UI_Section_View.ts | 110 +++++++++++------- B06_Section/B06_Section_UI_Style_Cross.css | 38 +++++- resources/tester/helper_138_cross_columns.cjs | 25 ++++ resources/tester/test_138_cross_columns.py | 46 ++++++++ ui_template/ui_template_locale_b2.ts | 1 + 10 files changed, 229 insertions(+), 87 deletions(-) create mode 100644 resources/tester/helper_138_cross_columns.cjs create mode 100644 resources/tester/test_138_cross_columns.py diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index f2aa42d78..07dd518dc 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -102,6 +102,8 @@ export const STATE_REGISTRY = { scope: "global", legacy: () => "b06:profile-panel-collapsed", }, + /** B06 횡단 카드 한 줄 카드 수(138-3 · 1~6 · 기본 3). */ + "cross-columns": { bucket: "pref", scope: "global" }, "section-panel-height": { bucket: "pref", scope: "global", diff --git a/B06_Section/B06_Section_UI_Cross_FillSlope_Notice.ts b/B06_Section/B06_Section_UI_Cross_FillSlope_Notice.ts index 82f665e0f..ce8a899b3 100644 --- a/B06_Section/B06_Section_UI_Cross_FillSlope_Notice.ts +++ b/B06_Section/B06_Section_UI_Cross_FillSlope_Notice.ts @@ -5,6 +5,7 @@ * 측점을 누르면 그 카드로 간다. 경고만 — 구조물을 세우거나 값을 바꾸지 않는다. * ⭐ 2026-09-15 — 그 밑에 사면길이 3m 초과 **소단 검토 정보 한 줄**(경고 모양 아님 · 브레인 ③). * ⭐ 138-1 — 「⚠ 포장 권장 · n측점」 줄(B05 법정 경사 제안 · 포장 범위 밖 측점 · 옛 카드 배지 자리). + * ⭐ 138-3 — 줄 전체가 카드 칸 위에 고정(sticky) · 알림 없어도 줄 유지(오른쪽 끝 열 수 칸은 부르는 쪽이 붙임). * ========================================================================== */ import type { CrossSection } from "./B06_Section_Api_Fetch"; @@ -60,11 +61,15 @@ function noticeBlock(className: string): { export function createFillSlopeNotice(onPick: (stationId: string) => void): FillSlopeNotice { const root = document.createElement("div"); + root.className = "b06-section__notice-bar"; + const blocks = document.createElement("div"); + blocks.className = "b06-section__notice-blocks"; + root.append(blocks); const warn = noticeBlock("b06-section__notice"); const info = noticeBlock("b06-section__notice b06-section__notice--info"); const pave = noticeBlock("b06-section__notice"); pave.root.title = L("B06_Design_Paved_Suggested"); - root.append(warn.root, pave.root, info.root); + blocks.append(warn.root, pave.root, info.root); root.addEventListener("click", (event) => { const id = (event.target as HTMLElement).closest("button")?.dataset.stationId; if (id) onPick(id); diff --git a/B06_Section/B06_Section_UI_Cross_View_Metrics.ts b/B06_Section/B06_Section_UI_Cross_View_Metrics.ts index 43d315ecf..d965aace4 100644 --- a/B06_Section/B06_Section_UI_Cross_View_Metrics.ts +++ b/B06_Section/B06_Section_UI_Cross_View_Metrics.ts @@ -63,6 +63,20 @@ export function crossPlotBase( return { sourceSamples, minOffset, maxOffset, elevationMid, pixelsPerMeter, naturalHeight }; } +/** + * 같은 기하를 다른 카드 폭으로 그렸을 때 자연 높이(바닥 적용 전 · 다시 계산 안 함 · 138-3). + * 그림 몫은 폭(좌우 여백 뺀)에 비례 · 위아래 여백은 그대로. + */ +export function naturalHeightAtWidth( + base: CrossPlotBase, + fromWidthPx: number, + toWidthPx: number, +): number { + const fixed = CROSS_PAD.top + CROSS_PAD.bottom + AREA_OVERLAY_HEADROOM_PX; + const inner = (width: number): number => Math.max(width - CROSS_PAD.left - CROSS_PAD.right, 1e-6); + return (base.naturalHeight - fixed) * (inner(toWidthPx) / inner(fromWidthPx)) + fixed; +} + /** 재 둔 기하에 행 높이만 얹는다 — 다시 계산하지 않는다. */ export function crossPlotFromBase(base: CrossPlotBase, forcedHeightPx?: number): CrossPlotMetrics { const heightPx = forcedHeightPx ?? Math.max(base.naturalHeight, CROSS_HEIGHT); diff --git a/B06_Section/B06_Section_UI_Section_Columns.ts b/B06_Section/B06_Section_UI_Section_Columns.ts index 36653df8f..b1481fff0 100644 --- a/B06_Section/B06_Section_UI_Section_Columns.ts +++ b/B06_Section/B06_Section_UI_Section_Columns.ts @@ -1,47 +1,43 @@ /* ============================================================================= * B06_Section_UI_Section_Columns.ts - * 횡단 카드 한 줄 카드 수 고르기 — 보기 칸 높이에 첫 줄 + 둘째 줄 절반이 들어가는 - * 가장 적은 카드 수. 카드 폭은 최소~최대 폭 안(그림은 축척 그대로 · 폭만 바뀜). + * 횡단 카드 열 수 · 폭 · 높이(138-3) — 열 수 = 사용자 값(알림 줄 오른쪽 끝 · 1~6 · 기본 3). + * 카드 폭 = (칸 폭 − 간격) ÷ 열 수 · 그래프 높이 = 폭 × (3열 기준 높이 ÷ 3열 폭) — 열 수가 + * 바뀌어도 그림 비율 그대로(글자 크기는 그대로). + * import 없음 — 시험이 이 파일만 옮겨 돌림(`test_138_cross_columns.py`). * ========================================================================== */ -import { - CROSS_CARD_CHROME, - CROSS_GRID_GAP, - CROSS_GRID_MAX_WIDTH, - CROSS_GRID_MIN_WIDTH, -} from "./B06_Section_UI_Section_Common"; +export const CROSS_COLUMNS_MIN = 1; +export const CROSS_COLUMNS_MAX = 6; +export const CROSS_COLUMNS_DEFAULT = 3; +/** 높이 비율의 기준 열 수. */ +export const CROSS_COLUMNS_BASE = 3; -export interface CrossColumns { - columns: number; - cardWidth: number; +/** 저장값 → 열 수(정수 · 1~6 · 못 읽으면 기본 3). */ +export function clampCrossColumns(value: unknown): number { + const number = typeof value === "string" && value.trim() === "" ? NaN : Number(value); + if (!Number.isFinite(number)) return CROSS_COLUMNS_DEFAULT; + return Math.min(CROSS_COLUMNS_MAX, Math.max(CROSS_COLUMNS_MIN, Math.round(number))); } -const widthFor = (columns: number, viewWidth: number): number => - Math.min(CROSS_GRID_MAX_WIDTH, (viewWidth - (columns - 1) * CROSS_GRID_GAP) / columns); +/** 카드 폭(px) = (칸 폭 − 간격 × (열 수 − 1)) ÷ 열 수. */ +export function crossCardWidth(viewWidth: number, columns: number, gap: number): number { + return Math.max(0, (viewWidth - gap * (columns - 1)) / columns); +} /** - * `heightsFor(cardWidth, count)` — 그 폭일 때 앞 `count` 장의 카드 자연 높이. - * `viewHeight` 가 0 이하(숨김 · 아직 안 잰 칸)면 가장 큰 카드 쪽을 고른다. + * 카드마다 강제 높이(px) — `baseHeights[i]` = 그 카드를 3열 폭(`baseWidth`)으로 그렸을 때 높이. + * 지금 폭으로 비례해 늘리고 · 같은 줄(열 수 단위) 최댓값으로 맞춤. */ -export function pickCrossColumns( - viewWidth: number, - viewHeight: number, - heightsFor: (cardWidth: number, count: number) => number[], -): CrossColumns { - const unit = CROSS_GRID_MIN_WIDTH + CROSS_GRID_GAP; - const most = Math.max(1, Math.floor((viewWidth + CROSS_GRID_GAP) / unit)); - const fewest = Math.min( - most, - Math.max(1, Math.ceil((viewWidth + CROSS_GRID_GAP) / (CROSS_GRID_MAX_WIDTH + CROSS_GRID_GAP))), - ); - for (let columns = fewest; columns < most; columns += 1) { - const cardWidth = widthFor(columns, viewWidth); - if (viewHeight <= 0) return { columns, cardWidth }; - const heights = heightsFor(cardWidth, columns * 2); - const first = Math.max(0, ...heights.slice(0, columns)) + CROSS_CARD_CHROME; - const second = Math.max(0, ...heights.slice(columns)) + CROSS_CARD_CHROME; - const needed = heights.length > columns ? first + CROSS_GRID_GAP + second / 2 : first; - if (needed <= viewHeight) return { columns, cardWidth }; - } - return { columns: most, cardWidth: widthFor(most, viewWidth) }; +export function crossRowHeights( + baseHeights: readonly number[], + baseWidth: number, + cardWidth: number, + columns: number, +): number[] { + const scale = baseWidth > 0 ? cardWidth / baseWidth : 1; + const heights = baseHeights.map((height) => height * scale); + return heights.map((_, index) => { + const start = index - (index % columns); + return Math.max(...heights.slice(start, start + columns)); + }); } diff --git a/B06_Section/B06_Section_UI_Section_Common.ts b/B06_Section/B06_Section_UI_Section_Common.ts index 79f63d0c1..bfda291de 100644 --- a/B06_Section/B06_Section_UI_Section_Common.ts +++ b/B06_Section/B06_Section_UI_Section_Common.ts @@ -35,11 +35,6 @@ export const LONG_WIDTH = 1200; export const LONG_HEIGHT = 220; export const CROSS_WIDTH = 560; export const CROSS_HEIGHT = 250; -// 카드 폭 범위 — 한 줄 카드 수는 보기 칸 높이로 정한다(`_Section_Columns`). -export const CROSS_GRID_MIN_WIDTH = 400; -export const CROSS_GRID_MAX_WIDTH = 800; -// 카드에서 그림 높이 밖 몫 — 머리 · 설계 줄 · 꼬리(실측 135). -export const CROSS_CARD_CHROME = 135; export const CROSS_GRID_GAP = 16; // bottom은 측점 라벨 한 줄 몫이다. X축 제목을 그래프 상단으로 올린 뒤 남던 여백을 걷어내, // 종단면도 아래 라벨이 그 밑 유토곡선과의 틈 **가운데**에 오도록 좁혔다(2026-08-02 사용자 지시). diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index 301272a01..ded85862f 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -38,12 +38,22 @@ import { type CrossPlotBase, crossPlotBase, effectiveCardHalfWidth, + naturalHeightAtWidth, } from "./B06_Section_UI_Cross_View_Metrics"; -import { CROSS_HEIGHT } from "./B06_Section_UI_Section_Common"; +import { CROSS_GRID_GAP, CROSS_HEIGHT } from "./B06_Section_UI_Section_Common"; import type { SectionStructureEdit } from "./B06_Section_UI_Section_View_Menu"; import { culvertLinkFor as culvertLink } from "./B06_Section_UI_Cross_Culvert_Wire"; import type { CulvertLink } from "./B06_Section_UI_Cross_Culvert_Wire"; -import { pickCrossColumns } from "./B06_Section_UI_Section_Columns"; +import { + CROSS_COLUMNS_BASE, + CROSS_COLUMNS_MAX, + CROSS_COLUMNS_MIN, + clampCrossColumns, + crossCardWidth, + crossRowHeights, +} from "./B06_Section_UI_Section_Columns"; +import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state"; +import { createInputField } from "@ui/ui_template_elements"; import { CROSS_WIDTH, type CrossDesignChange, @@ -93,9 +103,9 @@ export function createSectionView( let currentStationInterval: number | undefined; let structureEdit: SectionStructureEdit | null = null; let renderWidth = 0; - let renderHeight = 0; - let drawnColumns = 0; let resizeTimer = 0; + /** 한 줄 카드 수 — 사용자 값(보기 설정 pref · 138-3). */ + let columnCount = clampCrossColumns(readStateRaw("cross-columns")); // 카드 단위 재빌드에 재사용하는 렌더 컨텍스트 (draw에서 갱신) let cachedStationInterval = 1; let cachedCardWidth = CROSS_WIDTH; @@ -113,6 +123,29 @@ export function createSectionView( const fillSlopeNotice = createFillSlopeNotice((id) => selectedStationId === id ? revealCard(id, "smooth") : selectStation(id, true), ); + // 열 수 칸(138-3) — 고정 알림 줄 오른쪽 끝 · 1~6 · 바꾸면 카드 전부 다시 그림 + const columnsField = createInputField({ + type: "number", + compact: true, + min: CROSS_COLUMNS_MIN, + max: CROSS_COLUMNS_MAX, + value: String(columnCount), + }); + columnsField.root.classList.add("b06-section__columns"); + columnsField.input.title = L("B06_Cross_Columns"); + columnsField.input.addEventListener("change", () => { + const next = clampCrossColumns(columnsField.input.value); + columnsField.input.value = String(next); + if (next === columnCount) return; + columnCount = next; + writeStateRaw("cross-columns", String(next)); + draw(); + }); + fillSlopeNotice.root.append(columnsField.root); + // 카드 scroll-margin-top = 고정 줄 높이 — 고른 카드가 줄 밑에 숨지 않게 + new ResizeObserver(() => + cards.style.setProperty("--b06-notice-h", `${fillSlopeNotice.root.offsetHeight}px`), + ).observe(fillSlopeNotice.root); /** 종단도 한 벌 — 첫 그리기에서 횡단 보기 알림을 건다(`_Section_View_Profile`). */ const profile = createSectionProfile( @@ -138,14 +171,7 @@ export function createSectionView( return Number.isFinite(width) ? Math.max(0, width) : 0; }; - const contentHeight = (): number => { - const style = getComputedStyle(cards); - const pad = (value: string): number => Number.parseFloat(value) || 0; - const height = cards.clientHeight - pad(style.paddingTop) - pad(style.paddingBottom); - return Number.isFinite(height) ? Math.max(0, height) : 0; - }; - - /** 카드 폭에서 앞 `count` 장의 기하 — 한 줄 카드 수 고르기 · 그리기가 함께 쓴다. */ + /** 카드 폭에서 앞 `count` 장의 기하. */ const plotBasesFor = (detail: SectionDetailResponse, cardWidth: number, count: number) => detail.cross_sections .slice(0, count) @@ -158,11 +184,17 @@ export function createSectionView( designElevationAt(detail.longitudinal.design_profiles, section.chainage_m), ), ); - const naturalHeightsOf = (bases: ReadonlyArray): number[] => - bases.map((base) => Math.max(base?.naturalHeight ?? CROSS_HEIGHT, CROSS_HEIGHT)); - const pickColumns = (detail: SectionDetailResponse) => - pickCrossColumns(renderWidth, renderHeight, (cardWidth, count) => - naturalHeightsOf(plotBasesFor(detail, cardWidth, count)), + /** 3열 폭으로 그렸을 때 높이(바닥 250) — 다른 열 수도 이 비율을 지킴(138-3). */ + const baseHeightsOf = ( + bases: ReadonlyArray, + cardWidth: number, + baseWidth: number, + ): number[] => + bases.map((base) => + Math.max( + base ? naturalHeightAtWidth(base, cardWidth, baseWidth) : CROSS_HEIGHT, + CROSS_HEIGHT, + ), ); /** @@ -368,14 +400,11 @@ export function createSectionView( grid.addEventListener("click", (event) => { if (!(event.target as HTMLElement).closest(".b06-cross-card")) clearSelection(); }); - // 열 수 = 보기 칸 높이에 1.5줄이 드는 가장 적은 수 · 카드 폭 400~800(`_Section_Columns`). - const picked = pickColumns(detail); - const columnCount = picked.columns; - drawnColumns = columnCount; + // 열 수 = 사용자 값 · 폭 = (칸 폭 − 간격) ÷ 열 수(`_Section_Columns` · 138-3). + cachedCardWidth = crossCardWidth(renderWidth, columnCount, CROSS_GRID_GAP); // CSS auto-fill의 자체 열 수와 JS columnCount가 어긋나면 같은 행 높이 그룹핑이 실제 // 렌더 행과 달라져 카드 내부가 뒤죽박죽된다(N-2-6). 열 수 · 폭을 JS가 명시해 일치시킨다. - grid.style.gridTemplateColumns = `repeat(${columnCount}, ${picked.cardWidth}px)`; - cachedCardWidth = picked.cardWidth; + grid.style.gridTemplateColumns = `repeat(${columnCount}, ${cachedCardWidth}px)`; cachedRowHeight.clear(); if (detail.cross_sections.length) { // 1차: 각 카드의 자연 높이(250px 바닥 적용) 측정 → 같은 행(columnCount 단위) 최댓값을 행 높이로. @@ -385,19 +414,18 @@ export function createSectionView( // 반폭도 카드가 실제로 쓰는 값(`effectiveCardHalfWidth`, 자동 줌아웃 포함)으로 맞춰 // 재 둔다 — 카드·행 높이가 어긋나지 않는다. const plotBases = plotBasesFor(detail, cachedCardWidth, sections.length); - const naturalHeights = naturalHeightsOf(plotBases); - // 2차: 행별 최댓값으로 강제 높이를 정해 같은 행 카드를 동일 높이로 렌더한다. - for (let start = 0; start < sections.length; start += columnCount) { - const rowHeight = Math.max(...naturalHeights.slice(start, start + columnCount)); - for ( - let index = start; - index < Math.min(start + columnCount, sections.length); - index += 1 - ) { - cachedRowHeight.set(sections[index].station_id, rowHeight); - grid.append(buildCrossCard(sections[index], rowHeight, plotBases[index])); - } - } + // 2차: 높이 = 폭 × (3열 높이 ÷ 3열 폭) · 같은 행 최댓값으로 맞춰 동일 높이로 렌더한다. + const baseWidth = crossCardWidth(renderWidth, CROSS_COLUMNS_BASE, CROSS_GRID_GAP); + const rowHeights = crossRowHeights( + baseHeightsOf(plotBases, cachedCardWidth, baseWidth), + baseWidth, + cachedCardWidth, + columnCount, + ); + sections.forEach((section, index) => { + cachedRowHeight.set(section.station_id, rowHeights[index]); + grid.append(buildCrossCard(section, rowHeights[index], plotBases[index])); + }); } else { grid.append(emptyView(L("B06_Profile_View_NoCross"))); } @@ -441,19 +469,14 @@ export function createSectionView( else fillSlopeNotice.update(currentDetail.cross_sections, cachedStationInterval); }; + // 높이만 바뀌면(아래 패널 끌기 · 접기) 다시 그리지 않는다 — 열 수는 사용자 값(138-3). const resizeObserver = new ResizeObserver(() => { const nextWidth = contentWidth(); - const nextHeight = contentHeight(); if (!Number.isFinite(nextWidth) || nextWidth <= 0) return; - const widthMoved = Math.abs(nextWidth - renderWidth) >= 1; - if (!widthMoved && Math.abs(nextHeight - renderHeight) < 1) return; + if (Math.abs(nextWidth - renderWidth) < 1) return; window.clearTimeout(resizeTimer); resizeTimer = window.setTimeout(() => { renderWidth = nextWidth; - renderHeight = nextHeight; - // 높이만 바뀌면(아래 패널 끌기 · 접기) 한 줄 카드 수가 바뀔 때만 다시 그린다. - if (!widthMoved && currentDetail && pickColumns(currentDetail).columns === drawnColumns) - return; draw(); }, 150); }); @@ -490,7 +513,6 @@ export function createSectionView( // 진입 시 측점을 자동으로 고르지 않는다(2026-08-02 사용자 지시) — 0측점이 선택된 채로 // 시작하면 사용자가 고르지도 않은 카드가 강조돼 있고 유토곡선 말풍선도 떠 있다. renderWidth = contentWidth(); - renderHeight = contentHeight(); draw(); if (renderWidth <= 0) requestAnimationFrame(() => resizeObserver.observe(cards)); }, diff --git a/B06_Section/B06_Section_UI_Style_Cross.css b/B06_Section/B06_Section_UI_Style_Cross.css index 0c9b34125..dcace78e4 100644 --- a/B06_Section/B06_Section_UI_Style_Cross.css +++ b/B06_Section/B06_Section_UI_Style_Cross.css @@ -44,7 +44,8 @@ border: 1px solid var(--color-border); border-radius: var(--radius-cards); background: var(--color-surface-raised); - scroll-margin-top: var(--spacing-8); + /* 고정 알림 줄 높이만큼 띄움(138-3) — 고른 카드가 줄 밑에 숨지 않게 */ + scroll-margin-top: calc(var(--b06-notice-h, 0px) + var(--spacing-8)); /* 카드를 **자체 쌓임 맥락**으로 만든다. 이게 없으면 카드 안 오버레이(줌 버튼·면적 표)의 z-index가 최상위 축에서 겨루게 되어 이웃 판 위로 삐져나온다(2026-08-02 사용자 지적). */ isolation: isolate; @@ -122,6 +123,41 @@ gap: var(--spacing-16); } +/* 알림 줄(138-3) — 카드 칸 맨 위 고정 · 알림 없어도 줄 유지 · 오른쪽 끝 열 수 칸. + 위 여백(카드 칸 padding)만큼 끌어올려 그 틈으로 카드가 비치지 않게 */ +.b06-section__notice-bar { + position: sticky; + top: calc(-1 * var(--spacing-16)); + z-index: 3; + display: flex; + align-items: flex-start; + gap: var(--spacing-8); + margin-top: calc(-1 * var(--spacing-16)); + padding-block: var(--spacing-16) var(--spacing-4); + background: var(--color-bg); +} + +.b06-section__notice-blocks { + flex: 1 1 auto; + min-width: 0; +} + +.b06-section__notice-blocks > .b06-section__notice:last-of-type { + margin-bottom: 0; +} + +.b06-section__columns { + flex: none; + width: 4.5em; + margin-left: auto; +} + +/* 펼친 목록이 길면 줄 안에서 스크롤 — 고정 줄이 카드 칸을 다 덮지 않게 */ +.b06-section__notice-list { + max-height: 30vh; + overflow: auto; +} + /* 성토사면 5m 초과 경고 줄(2026-09-14) — 요약 한 줄, 펼치면 측점 단추 목록. */ .b06-section__notice { margin-bottom: var(--spacing-8); diff --git a/resources/tester/helper_138_cross_columns.cjs b/resources/tester/helper_138_cross_columns.cjs new file mode 100644 index 000000000..7835ffd85 --- /dev/null +++ b/resources/tester/helper_138_cross_columns.cjs @@ -0,0 +1,25 @@ +/* 138-3 횡단 카드 열 수 · 폭 · 높이 — `B06_Section_UI_Section_Columns.ts` 를 그 자리에서 트랜스파일해 Node 로 돌림. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const file = path.join(__dirname, "..", "..", "B06_Section", "B06_Section_UI_Section_Columns.ts"); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +new Function("exports", "module", js)(box.exports, box); +const c = box.exports; + +// 칸 폭 1232 · 간격 16 → 3열 폭 400 +const baseWidth = c.crossCardWidth(1232, 3, 16); +const base = [250, 300, 280, 260, 400]; +const out = { + clamp: [undefined, "", "abc", 0, 1, 2.6, 6, 9, "4"].map(c.clampCrossColumns), + widths: [1, 2, 3, 6].map((n) => c.crossCardWidth(1232, n, 16)), + baseWidth, + rows3: c.crossRowHeights(base, baseWidth, baseWidth, 3), + rows2: c.crossRowHeights(base, baseWidth, c.crossCardWidth(1232, 2, 16), 2), + rows1: c.crossRowHeights(base, baseWidth, c.crossCardWidth(1232, 1, 16), 1), +}; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_138_cross_columns.py b/resources/tester/test_138_cross_columns.py new file mode 100644 index 000000000..179f87371 --- /dev/null +++ b/resources/tester/test_138_cross_columns.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +"""B06 횡단 카드 열 수 · 폭 · 높이(PLAN 138-3) — 열 수 1~6 · 기본 3 · 폭 = (칸 − 간격) ÷ n · 높이 = 폭 × 3열 비율. + +규칙은 Node 헬퍼(`helper_138_cross_columns.cjs`)가 `B06_Section_UI_Section_Columns.ts` 를 그대로 돌림. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_138_cross_columns.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_열_수_1_6_기본_3(out): + assert out["clamp"] == [3, 3, 3, 1, 1, 3, 6, 6, 4] + + +def test_카드_폭은_칸_폭_나누기_열_수(out): + assert out["widths"] == [1232, 608, 400, (1232 - 80) / 6] + assert out["baseWidth"] == 400 + + +def test_3열은_자연_높이_줄_최댓값(out): + assert out["rows3"] == [300, 300, 300, 400, 400] + + +def test_다른_열_수는_3열_비율_그대로(out): + # 2열 폭 608 = 3열 400 × 1.52 → 높이도 1.52 배 · 줄(2장) 최댓값 + assert [round(h, 6) for h in out["rows2"]] == [456, 456, 425.6, 425.6, 608] + # 1열 폭 1232 = 3.08 배 + assert [round(h, 6) for h in out["rows1"]] == [770, 924, 862.4, 800.8, 1232] diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index b23da94cb..a96d3e121 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -473,6 +473,7 @@ export const ui_locales_b2 = { "Available only when a ditch is created.", ], B06_Notice_PaveSuggested: ["포장 권장", "Pavement recommended"], + B06_Cross_Columns: ["한 줄 카드 수(1~6)", "Cards per row (1-6)"], B06_Design_Paved_Suggested: [ "종단경사 법정 상한 초과 — 포장 권장 (임도설치 및 관리 등에 관한 규정 별표 1-2)", "Grade exceeds legal limit — pavement recommended (Forest Road Regulation, Annex 1-2)", From 48c4f7d7d496d9c89a623e04e30b951e8582ba70 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 15:01:01 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat(B06):=20=ED=9A=A1=EB=8B=A8=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20[=EC=83=81=EC=84=B8]=20=EB=AA=A8=EB=8B=AC=20?= =?UTF-8?q?=E2=80=94=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EA=B8=80=20=C2=B7=20?= =?UTF-8?q?CAD=20=EA=BC=B4=20=EA=B7=B8=EB=A6=BC=20=C2=B7=20B07=20=ED=9A=A1?= =?UTF-8?q?=EB=8B=A8=20=EC=88=98=EB=9F=89=ED=91=9C(138-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 측구 줄 맨 오른쪽 [상세] · 투명 막 · 카드 안 가리는 넓은 쪽 · 카드 다시 그리면 갱신 - 구조물 글 structure_note — USER_TOUCHED_KEYS 두 벌 · CrossSectionPatch · 세션 초안 → [저장] - 표 값 = 서버 cross-quantity-table 한 길(B07 _quantity_table 을 캐시 설계로) · 칸 배치 B07 그대로 - test_138_cross_detail Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU --- A00_Common/b_page_state.ts | 2 + B06_Section/B06_Section_Api_Fetch.ts | 26 ++ B06_Section/B06_Section_Api_Types.ts | 4 + B06_Section/B06_Section_Cross_Refresh.ts | 2 + B06_Section/B06_Section_Router_CrossTable.py | 94 +++++++ B06_Section/B06_Section_Router_Design.py | 2 + B06_Section/B06_Section_Schema.py | 2 + B06_Section/B06_Section_UI_Cross_Design.ts | 10 +- B06_Section/B06_Section_UI_Cross_Detail.ts | 260 ++++++++++++++++++ .../B06_Section_UI_Page_Design_Sync.ts | 3 + B06_Section/B06_Section_UI_Page_Persist.ts | 7 + .../B06_Section_UI_Style_Cross_Detail.css | 89 ++++++ main.py | 2 + resources/tester/test_138_cross_detail.py | 145 ++++++++++ ui_template/ui_template_locale_b2.ts | 1 + 15 files changed, 648 insertions(+), 1 deletion(-) create mode 100644 B06_Section/B06_Section_Router_CrossTable.py create mode 100644 B06_Section/B06_Section_UI_Cross_Detail.ts create mode 100644 B06_Section/B06_Section_UI_Style_Cross_Detail.css create mode 100644 resources/tester/test_138_cross_detail.py diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 07dd518dc..881977f8d 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -111,6 +111,8 @@ export const STATE_REGISTRY = { }, /* ── ② 설계 초안 ─────────────────────────────────────────────────────── */ + /** B06 횡단 [상세] 모달 측점 구조물 글(138-4) — 측점키(0.01 m) → 글. [저장]이 내보냄. */ + "structure-notes": { bucket: "draft", scope: "route" }, /** 아직 정본에 안 넣은 구조물 목록. B05 에서 만들고 B06 [저장]이 내보낸다. */ structures: { bucket: "draft", scope: "project", legacy: (p) => `b05:structures:${p}` }, /** 3D 램프로 바꾼 측점 상단측(측구 방향). */ diff --git a/B06_Section/B06_Section_Api_Fetch.ts b/B06_Section/B06_Section_Api_Fetch.ts index fe0519ad6..67266c563 100644 --- a/B06_Section/B06_Section_Api_Fetch.ts +++ b/B06_Section/B06_Section_Api_Fetch.ts @@ -285,3 +285,29 @@ export async function previewCrossDesigns( }, ); } + +/** B07 횡단 수량표 한 장(138-4 · 서버 `cross_quantity_sheet`) — 값 · 글 · 칸 배치. */ +export interface CrossQuantitySheet { + quantity_table: Record; + /** B07 표에 찍히는 글 그대로(값 없음 = 「-」). */ + text: Record; + layout: { + header: Array<[string, string]>; + left: Array<[string | null, string | null, string | null]>; + middle: Array<[string | null, string, string]>; + right: Array<[string | null, string | null]>; + }; +} + +/** 캐시 설계로 B07 횡단 수량표를 받는다 — 표시 전용(정본을 안 씀). */ +export async function fetchCrossQuantityTable( + projectId: string, + routeId: number, + chainageM: number, + design: CrossDesign | null | undefined, +): Promise { + return requestJson( + `/projects/${projectId}/sections/${routeId}/cross-quantity-table`, + { method: "POST", body: JSON.stringify({ chainage_m: chainageM, design: design ?? null }) }, + ); +} diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index 64b1be3f5..ed1888143 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -464,6 +464,8 @@ export interface CrossDesign { pavement_thickness_m?: number; /** B05 법정 경사 분석의 포장 제안 여부(사용자 토글과 무관하게 유지). */ pavement_suggested?: boolean; + /** 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸). */ + structure_note?: string; /** 노면(노견 포함) 양 끝점 — 노면 렌더링 기준. */ road_edges: { left: { offset_m: number; elevation_m: number }; @@ -605,4 +607,6 @@ export interface CrossSectionPatch { fill_area_m2?: number; cut_soil_area_m2?: number; cut_rock_area_m2?: number; + /** 측점 구조물 글(138-4) — 빈 글 = 지움. */ + structure_note?: string; } diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index aafbd6091..3328b3526 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -87,6 +87,8 @@ export const USER_TOUCHED_KEYS = [ "berm", // 측점별 암 절토 경사(2026-09-07) — 표준을 바꿔도 개별로 고친 측점은 그대로 둔다. "cut_slope_ratio_user", + // 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸) — 계산 입력이 아님. + "structure_note", ] as const; /** 다시 계산해도 살려 두는 값 — 위 사용자 값에 **상태를 나르는 둘**을 더한 것. */ diff --git a/B06_Section/B06_Section_Router_CrossTable.py b/B06_Section/B06_Section_Router_CrossTable.py new file mode 100644 index 000000000..a8b47e5e8 --- /dev/null +++ b/B06_Section/B06_Section_Router_CrossTable.py @@ -0,0 +1,94 @@ +"""B06 횡단 [상세] 모달의 **B07 횡단 수량표 값** 창구(138-4). + +값은 B07 횡단도가 쓰는 `_quantity_table` 한 벌을 **브라우저 캐시 설계**로 돌린 것이다 — +화면이 TS 로 다시 짜지 않는다(CLAUDE.md 5장 ③ 서버 단독). 칸 배치도 B07 표 정의를 그대로 +실어 보내 화면은 그리기만 한다. **표시 전용** — 정본을 쓰지 않는다. 모달을 열 때 부른다. +""" + +from __future__ import annotations + +import asyncio +import logging +from pathlib import Path +from typing import Any +from uuid import UUID + +from fastapi import APIRouter +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field + +from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from B06_Section.B06_Section_Repository import get_longitudinal_section +from B06_Section.B06_Section_Router_Design import read_cross_design_inputs +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import _format +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import ( + _CROSS_HEADER_KEYS, + _CROSS_LEFT_ROWS, + _CROSS_MIDDLE_ROWS, + _CROSS_RIGHT_ROWS, +) +from B07_DesignDetail.B07_DesignDetail_Router_Support import _quantity_table +from common_util.common_util_storage import resolve_stored_project_path +from config.config_db import get_db_pool + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/projects", tags=["B06 Profile Cross"]) + + +class CrossQuantityTableRequest(BaseModel): + chainage_m: float = Field(..., ge=0) + # 브라우저 캐시의 그 측점 설계(조작 반영본) — 없으면 원본 칸만. + design: dict[str, Any] | None = None + + +def cross_quantity_sheet(source: dict[str, Any], design: dict[str, Any] | None) -> dict[str, Any]: + """B07 횡단 수량표 한 장 — 값(숫자) · 글(B07 표와 같은 꼴) · 칸 배치.""" + table = _quantity_table(source, design) + text = { + key: _format(value) if isinstance(value, (int, float)) else "-" + for key, value in table.items() + } + return { + "quantity_table": table, + "text": text, + "layout": { + "header": [list(row) for row in _CROSS_HEADER_KEYS], + "left": [list(row) for row in _CROSS_LEFT_ROWS], + "middle": [list(row) for row in _CROSS_MIDDLE_ROWS], + "right": [list(row) for row in _CROSS_RIGHT_ROWS], + }, + } + + +@router.post("/{project_id}/sections/{route_id}/cross-quantity-table", response_model=None) +async def cross_quantity_table( + project_id: UUID, route_id: int, request: CrossQuantityTableRequest +) -> JSONResponse: + pool = get_db_pool() + async with pool.acquire() as connection: + longitudinal = await get_longitudinal_section(connection, project_id, route_id) + stored_path = await get_project_storage_relative_path(connection, project_id) + if not longitudinal or not stored_path: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "종횡단 상세 결과가 없습니다."}, + ) + try: + *_, source = await asyncio.to_thread( + read_cross_design_inputs, + Path(resolve_stored_project_path(stored_path)), + str(longitudinal["longitudinal_file_path"]), + request.chainage_m, + ) + sheet = cross_quantity_sheet(source, request.design) + except (FileNotFoundError, ValueError) as error: + return JSONResponse(status_code=404, content={"status": "error", "message": str(error)}) + except Exception: + logger.exception( + "횡단 수량표 계산 실패: project_id=%s chainage=%s", project_id, request.chainage_m + ) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "횡단 수량표를 만들지 못했습니다."}, + ) + return JSONResponse(content={"status": "success", **sheet}) diff --git a/B06_Section/B06_Section_Router_Design.py b/B06_Section/B06_Section_Router_Design.py index b12160741..67a8484fb 100644 --- a/B06_Section/B06_Section_Router_Design.py +++ b/B06_Section/B06_Section_Router_Design.py @@ -148,6 +148,8 @@ USER_TOUCHED_KEYS = ( # 측점별 암 절토 경사 — 표준 횡단면 설정을 바꿔도 개별로 고친 측점은 그대로 둔다 # (2026-09-07 사용자 확정: 「사용자가 기본값을 사용하지 않는 값들은 변경되면 안됨」). "cut_slope_ratio_user", + # 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸) — 계산 입력이 아님. + "structure_note", ) # 노선 [확인] 체인이 옛 노선에서 누가거리로 넘기는 **사용자 입력**(PLAN 74-3) — 계획고 · 면적 · diff --git a/B06_Section/B06_Section_Schema.py b/B06_Section/B06_Section_Schema.py index dc8d2bf2d..1084a2fd8 100644 --- a/B06_Section/B06_Section_Schema.py +++ b/B06_Section/B06_Section_Schema.py @@ -192,6 +192,8 @@ class CrossSectionPatch(BaseModel): box_length_m: float | None = Field(default=None, ge=0) # B08 자재 집계표 세월교 관 한 련 길이(131) — 그린 관 길이 1 m 올림(m) · 같은 길. ford_pipe_length_m: float | None = Field(default=None, ge=0) + # 측점 구조물 글(138-4 · 횡단 [상세] 모달) — 빈 글 = 지움. + structure_note: str | None = Field(default=None, max_length=2000) class SectionConfirmRequest(BaseModel): diff --git a/B06_Section/B06_Section_UI_Cross_Design.ts b/B06_Section/B06_Section_UI_Cross_Design.ts index 813759369..878121f43 100644 --- a/B06_Section/B06_Section_UI_Cross_Design.ts +++ b/B06_Section/B06_Section_UI_Cross_Design.ts @@ -27,6 +27,7 @@ export { appendRockBoundaryOverlay, } from "./B06_Section_UI_Cross_Design_Surface"; export { appendCrossDesignOverlay } from "./B06_Section_UI_Cross_Design_Line"; +import { openCrossDetail, refreshCrossDetail } from "./B06_Section_UI_Cross_Detail"; /** 넘침 정리 한 장 몫 — 쓰기·읽기·쓰기 세 토막으로 갈라 두어 프레임 단위로 묶는다. */ interface ReflowCard { @@ -455,7 +456,14 @@ export function buildDesignControls( const morePanel = document.createElement("div"); morePanel.className = "b06-design__more-panel"; more.append(moreSummary, morePanel); - bar.append(slopeDirSeg, ...moveable, more); + // [상세](138-4) — 맨 오른쪽 · 넘침 정리(moveable) 밖이라 늘 보임 + const detail = document.createElement("button"); + detail.type = "button"; + detail.className = "b06-design__btn b06-design__detail"; + detail.textContent = L("B06_Design_Detail"); + detail.addEventListener("click", () => openCrossDetail(section)); + bar.append(slopeDirSeg, ...moveable, more, detail); + refreshCrossDetail(section); // 넘침 정리는 **세 토막**으로 나눈다 — 쓰기(reset) → 읽기(measure) → 쓰기(apply). // 카드 안에서 한 장씩 하면 쓰기 뒤 읽기가 카드 수만큼 반복돼 브라우저가 레이아웃을 diff --git a/B06_Section/B06_Section_UI_Cross_Detail.ts b/B06_Section/B06_Section_UI_Cross_Detail.ts new file mode 100644 index 000000000..49de677c9 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Detail.ts @@ -0,0 +1,260 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Detail.ts + * 횡단 카드 측구 줄 맨 오른쪽 [상세] → 모달(138-4). + * + * 맨 위 = 측점 구조물 글(`design.structure_note` · 세션 초안 → [저장]이 patch 로 내보냄) · + * 가운데 = 카드 그림 복제(어두운 바탕 · CAD 꼴) · 아래 = B07 횡단 수량표(값 · 칸 배치 모두 + * 서버 `cross-quantity-table` 한 길 · 캐시 설계로 · 열 때 부름). + * 막은 투명(누름 통과) · 자리는 그 카드를 안 가리는 넓은 쪽(좌/우 → 위/아래). + * 모달이 열린 채 카드를 다시 그리면 그림 · 표를 새로 맞춤. + * ========================================================================== */ + +import "./B06_Section_UI_Style_Cross_Detail.css"; +import { openModal } from "@ui/ui_template_modal"; +import { readState, writeState } from "../A00_Common/b_page_state"; +import { fetchCrossQuantityTable } from "./B06_Section_Api_Fetch"; +import type { + CrossQuantitySheet, + CrossSection, + SectionDetailResponse, +} from "./B06_Section_Api_Fetch"; +import { inferStationInterval, stationLabel } from "./B06_Section_UI_Section_Common"; + +/** 페이지가 한 번 걸어 줌(`_UI_Page_Design_Sync`) — 카드 줄은 프로젝트 · 노선을 모름. */ +export interface CrossDetailHost { + projectId: string; + routeId: () => number | null; + detail: () => SectionDetailResponse | null; +} + +let host: CrossDetailHost | null = null; +export function setCrossDetailHost(next: CrossDetailHost): void { + host = next; +} + +/* ── 구조물 글 세션 초안(측점키 0.01 m → 글) ─────────────────────────────── */ + +const noteKey = (chainageM: number): string => chainageM.toFixed(2); + +/** [저장]이 patch 로 싣는 글 — 측점(m) → 글(빈 글 = 지움). */ +export function readStructureNotes(projectId: string, routeId: number): Map { + const raw = readState>("structure-notes", projectId, routeId) ?? {}; + return new Map(Object.entries(raw).map(([key, text]) => [Number(key), String(text)])); +} + +function noteOf(section: CrossSection): string { + const routeId = host?.routeId(); + if (host && routeId !== null && routeId !== undefined) { + const draft = readState>("structure-notes", host.projectId, routeId); + const text = draft?.[noteKey(section.chainage_m)]; + if (text !== undefined) return text; + } + return section.design?.structure_note ?? ""; +} + +function writeNote(section: CrossSection, text: string): void { + // 캐시 설계에도 얹음 — B05 [저장](`crossPatchesFromCache`)이 같은 값을 실음 + if (section.design) section.design.structure_note = text; + const routeId = host?.routeId(); + if (!host || routeId === null || routeId === undefined) return; + const all = readState>("structure-notes", host.projectId, routeId) ?? {}; + all[noteKey(section.chainage_m)] = text; + writeState("structure-notes", all, host.projectId, routeId); +} + +/* ── 자리 ───────────────────────────────────────────────────────────── */ + +const GAP_PX = 12; +const MIN_WIDTH_PX = 360; +const MAX_WIDTH_PX = 720; + +/** 카드를 안 가리는 넓은 쪽에 붙임 — 좌우가 좁으면 위아래. */ +function place(dialog: HTMLElement, card: HTMLElement | null): void { + const vw = window.innerWidth; + const vh = window.innerHeight; + const rect = card?.getBoundingClientRect(); + const style = dialog.style; + style.position = "fixed"; + style.margin = "0"; + if (!rect) { + style.width = `${Math.min(MAX_WIDTH_PX, vw - GAP_PX * 2)}px`; + style.left = `${(vw - Math.min(MAX_WIDTH_PX, vw - GAP_PX * 2)) / 2}px`; + style.top = `${GAP_PX}px`; + style.maxHeight = `${vh - GAP_PX * 2}px`; + return; + } + const left = rect.left; + const right = vw - rect.right; + if (Math.max(left, right) >= MIN_WIDTH_PX + GAP_PX * 2) { + const width = Math.min(MAX_WIDTH_PX, Math.max(left, right) - GAP_PX * 2); + const top = Math.min(Math.max(rect.top, GAP_PX), vh - GAP_PX - 240); + style.width = `${width}px`; + style.left = `${right >= left ? rect.right + GAP_PX : rect.left - GAP_PX - width}px`; + style.top = `${Math.max(top, GAP_PX)}px`; + style.maxHeight = `${vh - Math.max(top, GAP_PX) - GAP_PX}px`; + return; + } + const width = Math.min(MAX_WIDTH_PX, vw - GAP_PX * 2); + style.width = `${width}px`; + style.left = `${Math.min(Math.max(rect.left, GAP_PX), vw - GAP_PX - width)}px`; + const below = vh - rect.bottom; + if (below >= rect.top) { + style.top = `${rect.bottom + GAP_PX}px`; + style.maxHeight = `${Math.max(below - GAP_PX * 2, 160)}px`; + } else { + style.top = `${GAP_PX}px`; + style.maxHeight = `${Math.max(rect.top - GAP_PX * 2, 160)}px`; + } +} + +/* ── 표(B07 칸 배치 그대로) ─────────────────────────────────────────────── */ + +function cell(tag: "th" | "td", text: string, span?: { col?: number; row?: number }): HTMLElement { + const element = document.createElement(tag); + element.textContent = text; + if (span?.col) element.setAttribute("colspan", String(span.col)); + if (span?.row) element.setAttribute("rowspan", String(span.row)); + return element; +} + +/** B07 `_cross_table_entities` 와 같은 꼴 — 제목 · 머리 넷 쌍 · 본문 6행 × (좌 · 중 · 우). */ +export function buildQuantityTable(sheet: CrossQuantitySheet, title: string): HTMLTableElement { + const { layout, text } = sheet; + const value = (key: string | null): string => (key ? (text[key] ?? "-") : ""); + const table = document.createElement("table"); + table.className = "b06-detail__qtable"; + const titleRow = table.insertRow(); + titleRow.append(cell("th", title, { col: 8 })); + const headRow = table.insertRow(); + for (const [label, key] of layout.header) + headRow.append(cell("th", label), cell("td", value(key))); + layout.left.forEach(([groupL, subL, keyL], index) => { + const row = table.insertRow(); + if (groupL !== null && subL !== null) row.append(cell("th", groupL, { row: 2 })); + else if (groupL !== null) row.append(cell("th", groupL, { col: 2 })); + if (subL !== null) row.append(cell("th", subL)); + row.append(cell("td", value(keyL))); + const [groupM, subM, keyM] = layout.middle[index]; + if (groupM !== null) row.append(cell("th", groupM, { row: 2 })); + row.append(cell("th", subM), cell("td", value(keyM))); + const [labelR, keyR] = layout.right[index]; + row.append(cell("th", labelR ?? ""), cell("td", value(keyR))); + }); + return table; +} + +/* ── 모달 ───────────────────────────────────────────────────────────── */ + +interface OpenDetail { + stationId: string; + section: CrossSection; + paint: () => void; + loadTable: () => void; + close: () => void; +} + +let current: OpenDetail | null = null; +let tableTimer = 0; + +const cardOf = (section: CrossSection): HTMLElement | null => + document.getElementById(`cross-${section.station_id}`); + +function titleOf(section: CrossSection): string { + const stations = host?.detail()?.longitudinal.stations ?? []; + return stationLabel(section.chainage_m, inferStationInterval(stations)); +} + +/** [상세] — 이미 열린 것은 닫고 이 측점으로 엶. */ +export function openCrossDetail(section: CrossSection): void { + current?.close(); + const title = titleOf(section); + let tableSeq = 0; + openModal({ + title: `${title} 상세`, + closeLabel: "닫기", + dialogClass: "b06-detail", + backdropClass: "b06-detail__backdrop", + mount(body, close) { + const dialog = body.parentElement as HTMLElement; + const note = document.createElement("textarea"); + note.className = "ui-input b06-detail__note"; + note.rows = 3; + note.placeholder = "구조물 메모 — 이 측점에 남길 글"; + note.value = noteOf(section); + note.addEventListener("input", () => writeNote(state.section, note.value)); + const cad = document.createElement("div"); + cad.className = "b06-detail__cad"; + const tableBox = document.createElement("div"); + tableBox.className = "b06-detail__table"; + body.append(note, cad, tableBox); + + const state: OpenDetail = { + stationId: section.station_id, + section, + paint() { + const card = cardOf(state.section); + const svg = card?.querySelector("svg.b06-section__chart"); + cad.replaceChildren(svg ? svg.cloneNode(true) : document.createTextNode("그림 없음")); + place(dialog, card); + }, + loadTable() { + const projectId = host?.projectId; + const routeId = host?.routeId(); + if (!projectId || routeId === null || routeId === undefined) { + tableBox.textContent = "노선 정보 없음"; + return; + } + const seq = ++tableSeq; + tableBox.classList.add("is-loading"); + fetchCrossQuantityTable( + projectId, + routeId, + state.section.chainage_m, + state.section.design, + ) + .then((sheet) => { + if (seq !== tableSeq || current !== state) return; + tableBox.replaceChildren(buildQuantityTable(sheet, title)); + }) + .catch((error: unknown) => { + if (seq !== tableSeq || current !== state) return; + tableBox.textContent = `수량표를 못 받음 — ${error instanceof Error ? error.message : error}`; + }) + .finally(() => seq === tableSeq && tableBox.classList.remove("is-loading")); + }, + close() { + forget(); + close(); + }, + }; + current = state; + tableBox.textContent = "수량표 불러오는 중…"; + state.paint(); + state.loadTable(); + }, + // 닫기 단추 · Esc 도 같은 정리를 거침 + beforeClose: () => { + forget(); + return true; + }, + }); +} + +/** 열린 모달 기억을 지움(닫힐 때). */ +function forget(): void { + window.clearTimeout(tableTimer); + current = null; +} + +/** + * 카드를 다시 그릴 때마다 부름(`buildDesignControls`) — 그 측점 모달이 열려 있으면 새 카드로 + * 그림을 다시 뜨고 표는 조작이 멈춘 뒤 한 번 다시 받음(최신 응답만). + */ +export function refreshCrossDetail(section: CrossSection): void { + const open = current; + if (!open || open.stationId !== section.station_id) return; + open.section = section; + requestAnimationFrame(() => current === open && open.paint()); + window.clearTimeout(tableTimer); + tableTimer = window.setTimeout(() => current === open && open.loadTable(), 400); +} diff --git a/B06_Section/B06_Section_UI_Page_Design_Sync.ts b/B06_Section/B06_Section_UI_Page_Design_Sync.ts index d63255462..27b27117a 100644 --- a/B06_Section/B06_Section_UI_Page_Design_Sync.ts +++ b/B06_Section/B06_Section_UI_Page_Design_Sync.ts @@ -23,6 +23,7 @@ import { L } from "./B06_Section_UI_Page_Common"; import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; import type { CrossDesignChange, SectionViewController } from "./B06_Section_UI_Section_View"; import type { StructureInstance } from "../B05_Profile/B05_Profile_Api_Structures"; +import { setCrossDetailHost } from "./B06_Section_UI_Cross_Detail"; export interface DesignSyncContext { projectId: string | null; @@ -42,6 +43,8 @@ export interface DesignSyncController { export function createDesignSync(ctx: DesignSyncContext): DesignSyncController { const { projectId } = ctx; + // 횡단 [상세] 모달(138-4)이 쓰는 프로젝트 · 노선 · 상세 + if (projectId) setCrossDetailHost({ projectId, routeId: ctx.routeId, detail: ctx.detail }); /** * 측점 설계 버튼 변경 처리: (1) 선택을 즉시 로컬 반영해 해당 카드만 리프레시(버튼 즉시 반응), diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index 9a0b0cfdf..e5473ae7e 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -29,6 +29,7 @@ import { structureAreaRows, } from "./B06_Section_Structure_Layouts"; import { crossDesignChoices } from "./B06_Section_Cross_Design_Session"; +import { readStructureNotes } from "./B06_Section_UI_Cross_Detail"; import type { StandardCrossSection } from "./B06_Section_Api_Fetch"; import type { RockBoundaryControl } from "./B06_Section_UI_Section_View"; import type { CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope"; @@ -386,6 +387,12 @@ export function collectSectionEdits(ctx: SectionPersistContext): { // 자동으로 되돌아가지 않으므로(켬/끔 둘 뿐) 지금은 손해가 없다(2026-09-09). if (typeof choice.ditch_choice === "boolean") patch.ditch_choice = choice.ditch_choice; }); + // 측점 구조물 글(138-4 · [상세] 모달) — 세션 초안 그대로(빈 글 = 지움) + const routeId = ctx.routeId(); + if (ctx.projectId && routeId !== null) + readStructureNotes(ctx.projectId, routeId).forEach((note, chainage) => { + patchFor(chainage).structure_note = note; + }); if (detail) { // 구조물 폐회로 면적 — **카드를 그리지 않은 측점까지** 여기서 다 계산한다 // (2026-09-06 사용자 확정: 「일단은 브라우저 계산으로」). diff --git a/B06_Section/B06_Section_UI_Style_Cross_Detail.css b/B06_Section/B06_Section_UI_Style_Cross_Detail.css new file mode 100644 index 000000000..556ec025c --- /dev/null +++ b/B06_Section/B06_Section_UI_Style_Cross_Detail.css @@ -0,0 +1,89 @@ +/* ============================================================================= + * B06_Section_UI_Style_Cross_Detail.css + * 횡단 카드 [상세] 단추 · 모달(138-4) — 투명 막(누름 통과) · 글 칸 · CAD 꼴 그림 · B07 수량표. + * ========================================================================== */ + +/* 측구 줄 맨 오른쪽 — ⋯ 가 보이면 그 옆, 숨으면 혼자 오른쪽 끝 */ +.b06-design__detail { + flex: 0 0 auto; + margin-left: auto; +} + +.b06-design__more:not([hidden]) ~ .b06-design__detail { + margin-left: 0; +} + +/* 막은 투명 · 누름은 아래 화면으로 — 모달 몸만 받음 */ +.ui-modal__backdrop.b06-detail__backdrop { + background-color: transparent; + pointer-events: none; +} + +.b06-detail__backdrop > .ui-modal.b06-detail { + pointer-events: auto; + overflow: auto; + box-shadow: var(--shadow-lg); +} + +.b06-detail__note { + width: 100%; + box-sizing: border-box; + resize: vertical; + font: inherit; +} + +/* 가벼운 CAD 꼴 — 어두운 바탕 · 카드 그림 복제에 다크 색 토큰을 덮어씀 */ +.b06-detail__cad { + --color-bg: #111111; + --color-surface: #111111; + --color-surface-raised: #1a1a1a; + --color-border: #3a3a3a; + --color-text: #f2f2f2; + --color-text-body: #e6e6e6; + --color-text-secondary: #b8b8b8; + --color-text-muted: #8b8b8b; + --color-primary: #c9a8ff; + --color-warning: #ffd34d; + --color-danger: #ff5f57; + --color-success: #4cd37b; + border-radius: var(--radius-cards); + background: #111111; + color: #f2f2f2; + overflow: hidden; +} + +.b06-detail__cad > svg { + display: block; + width: 100%; + height: auto; +} + +/* B07 횡단 수량 산출표 — 칸 배치 그대로 */ +.b06-detail__table.is-loading { + opacity: 0.6; +} + +.b06-detail__qtable { + width: 100%; + border-collapse: collapse; + font-size: var(--text-caption); + font-variant-numeric: tabular-nums; +} + +.b06-detail__qtable th, +.b06-detail__qtable td { + padding: 2px 4px; + border: 1px solid var(--color-border); + text-align: center; + white-space: nowrap; +} + +.b06-detail__qtable th { + font-weight: 500; + color: var(--color-text-secondary); +} + +.b06-detail__qtable tr:first-child > th { + text-align: left; + color: var(--color-text); +} diff --git a/main.py b/main.py index 7f56d5acd..36adcd159 100644 --- a/main.py +++ b/main.py @@ -60,6 +60,7 @@ from B06_Section.B06_Section_Router_Confirm import ( from B06_Section.B06_Section_Router_HaulPlan import ( router as b06_section_haul_plan_router, ) +from B06_Section.B06_Section_Router_CrossTable import router as b06_section_cross_table_router from B07_DesignDetail.B07_DesignDetail_Router import router as b07_design_router from B07_DesignDetail.B07_DesignDetail_Router_Frame import router as b07_frame_router from B07_DesignDetail.B07_DesignDetail_Router_PlanKinds import router as b07_plan_kinds_router @@ -645,6 +646,7 @@ app.include_router(b06_section_router, dependencies=protected_with_company) app.include_router(b06_section_stations_router, dependencies=protected_with_company) app.include_router(b06_section_confirm_router, dependencies=protected_with_company) app.include_router(b06_section_haul_plan_router, dependencies=protected_with_company) +app.include_router(b06_section_cross_table_router, dependencies=protected_with_company) app.include_router(b07_design_router, dependencies=protected_with_company) app.include_router(b07_frame_router, dependencies=protected_with_company) app.include_router(b07_plan_kinds_router, dependencies=protected_with_company) diff --git a/resources/tester/test_138_cross_detail.py b/resources/tester/test_138_cross_detail.py new file mode 100644 index 000000000..2944db779 --- /dev/null +++ b/resources/tester/test_138_cross_detail.py @@ -0,0 +1,145 @@ +# -*- coding: utf-8 -*- +"""B06 횡단 [상세] 모달(PLAN 138-4) — 구조물 글 새 키 저장 왕복 · B07 수량표 값 POST 한 길. + +- 글(`structure_note`): 브라우저 세션 초안 → [저장] patch → 서버 스키마 → 정본 병합 → + 재계산(포장 · 세월교 · 사토장)에서도 남음. +- 표: `cross-quantity-table` 이 B07 `_quantity_table` 을 캐시 설계로 돌린 값과 칸 배치를 줌. + +./venv/Scripts/python.exe -m pytest resources/tester/test_138_cross_detail.py -q +""" + +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from fastapi import FastAPI # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 + +from B06_Section import B06_Section_Router_CrossTable as table_mod # noqa: E402 +from B06_Section import B06_Section_Router_Design as design_mod # noqa: E402 +from B06_Section.B06_Section_Schema import CrossSectionPatch # noqa: E402 +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import QUANTITY_VALUE_KEYS # noqa: E402 + +# ── 글 저장 왕복 ───────────────────────────────────────────────────────── + + +def test_글은_patch_스키마를_지나_정본_병합_값으로_남는다(): + patch = CrossSectionPatch(chainage_m=40.0, structure_note="L형 측구 · 집수정 보강") + dumped = patch.model_dump() + # 확정 · 저장 경로와 같은 걸름(`_apply_section_edits`) — 최상위 None 만 걷음 + merged = {k: v for k, v in dumped.items() if k != "chainage_m" and v is not None} + assert merged == {"structure_note": "L형 측구 · 집수정 보강"} + # 빈 글 = 지움 — 걸러지지 않고 실림 + empty = CrossSectionPatch(chainage_m=40.0, structure_note="").model_dump() + assert empty["structure_note"] == "" + + +def test_글은_재계산에서도_남는다(monkeypatch): + monkeypatch.setattr(design_mod, "pavement_ranges", lambda root: [(0.0, 100.0)]) + monkeypatch.setattr(design_mod, "ford_surface_drops", lambda root: []) + monkeypatch.setattr(design_mod, "design_elevation_from_longitudinal", lambda lon, ch: 100.0) + monkeypatch.setattr(design_mod, "curve_widening_args", lambda section: {}) + monkeypatch.setattr( + design_mod, "compute_cross_design", lambda samples, elev, **kw: {"paved": kw["paved"]} + ) + sections = [{"chainage_m": 40.0, "design": {"paved": False, "structure_note": "메모"}}] + assert design_mod.enforce_pavement_ranges({}, sections, Path("."), None) == 1 + assert sections[0]["design"]["structure_note"] == "메모" + + +def test_브라우저가_글을_저장_patch_에_싣는다(): + persist = (PROJECT_ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text( + encoding="utf-8" + ) + assert "readStructureNotes(" in persist and ".structure_note = note" in persist + state = (PROJECT_ROOT / "A00_Common" / "b_page_state.ts").read_text(encoding="utf-8") + assert '"structure-notes": { bucket: "draft", scope: "route" }' in state + + +# ── 수량표 값 ──────────────────────────────────────────────────────────── + +SOURCE = {"chainage_m": 40.0, "center_z": 101.5, "samples": []} +DESIGN = { + "design_elevation_m": 100.25, + "cut_soil_area_m2": 1.234, + "cut_rock_area_m2": 0.5, + "fill_area_m2": 2.0, +} + + +def test_표_값은_B07_수량표_한_벌(): + sheet = table_mod.cross_quantity_sheet(SOURCE, DESIGN) + table = sheet["quantity_table"] + assert set(QUANTITY_VALUE_KEYS) <= set(table) + assert table["ground"] == 101.5 and table["planned"] == 100.25 + assert round(table["cut"], 6) == 1.25 and table["fill"] == 0.0 + assert table["cut_soil"] == 1.234 and table["embankment"] == 2.0 + # 글은 B07 표에 찍히는 꼴 그대로 · 값 없음 = 「-」 + assert sheet["text"]["cut_soil"] == "1.23" + assert sheet["text"]["fence"] == "-" + layout = sheet["layout"] + assert [row[1] for row in layout["header"]] == ["ground", "planned", "fill", "cut"] + assert len(layout["left"]) == len(layout["middle"]) == len(layout["right"]) == 6 + + +def test_설계_없으면_원본_칸만(): + sheet = table_mod.cross_quantity_sheet(SOURCE, None) + assert sheet["quantity_table"]["ground"] == 101.5 + assert sheet["quantity_table"]["planned"] is None + assert sheet["text"]["cut_soil"] == "-" + + +class _Pool: + def acquire(self): + pool = self + + class _Ctx: + async def __aenter__(self): + return pool + + async def __aexit__(self, *exc): + return False + + return _Ctx() + + +def test_POST_한_길이_캐시_설계로_표를_낸다(monkeypatch): + seen = {} + + async def longitudinal(connection, project_id, route_id): + return {"longitudinal_file_path": "B05_Profile/route/longitudinal.json"} + + async def storage(connection, project_id): + return "proj" + + def read_inputs(root, long_path, chainage): + seen["chainage"] = chainage + return [], 100.0, False, SOURCE + + monkeypatch.setattr(table_mod, "get_db_pool", lambda: _Pool()) + monkeypatch.setattr(table_mod, "get_longitudinal_section", longitudinal) + monkeypatch.setattr(table_mod, "get_project_storage_relative_path", storage) + monkeypatch.setattr(table_mod, "resolve_stored_project_path", lambda rel: "C:/x") + monkeypatch.setattr(table_mod, "read_cross_design_inputs", read_inputs) + app = FastAPI() + app.include_router(table_mod.router) + client = TestClient(app) + url = "/api/projects/00000000-0000-0000-0000-000000000001/sections/7/cross-quantity-table" + response = client.post(url, json={"chainage_m": 40.0, "design": DESIGN}) + assert response.status_code == 200, response.text + body = response.json() + assert seen["chainage"] == 40.0 + assert body["quantity_table"]["cut_soil"] == 1.234 + assert body["text"]["planned"] == "100.25" + + # 측점 파일이 없으면 404 · 사유 그대로 + def missing(*args): + raise FileNotFoundError("해당 측점의 횡단 상세 파일을 찾을 수 없습니다.") + + monkeypatch.setattr(table_mod, "read_cross_design_inputs", missing) + response = client.post(url, json={"chainage_m": 41.0, "design": None}) + assert response.status_code == 404 + assert "횡단 상세 파일" in response.json()["message"] diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index a96d3e121..b7ef06fa2 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -474,6 +474,7 @@ export const ui_locales_b2 = { ], B06_Notice_PaveSuggested: ["포장 권장", "Pavement recommended"], B06_Cross_Columns: ["한 줄 카드 수(1~6)", "Cards per row (1-6)"], + B06_Design_Detail: ["상세", "Detail"], B06_Design_Paved_Suggested: [ "종단경사 법정 상한 초과 — 포장 권장 (임도설치 및 관리 등에 관한 규정 별표 1-2)", "Grade exceeds legal limit — pavement recommended (Forest Road Regulation, Annex 1-2)", From 2fccd7c48ae3bdffd1f5c99817664d916a16b63f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 15:15:19 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat(M02):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20?= =?UTF-8?q?=EC=A1=B0=ED=95=A9=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20=3D=20M02=20=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=C2=B7=20=EC=82=AC=EB=B3=B8=20=EC=85=88=20?= =?UTF-8?q?=3D=20=EC=B8=B5=20=EC=85=88=20=ED=95=9C=20=EB=B2=8C(142-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 사본 셈 = M02_Labor_Doc.calc 한 벌(사본 단가 · 굳힌 참조사본 · 사본 알약 주입 · 빈 입력 = 자동값) - 모달 일위대가 카드 = ItemCards labor(자료 자리 ItemSource.labor · 쓰는 길 없으면 읽기 전용) - 사본 문서에 사본 입력값 · 고른자재를 줄 입력값 · 고름으로 얹음 · [수정] = 넣을 값 편집 화면 - 옛 요약 카드(picker_labor) · 넣을 값 카드 · 머리 옛 일위대가 이름 줄 지움 · 처음 펼침 = 개인 - 시험: 사본 셈 = 층 셈 · 모달 일위대가 카드 읽기 전용 · 새 셈 꼴로 고침 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU --- M02_MasterTemplete/M02_Item_Prices.py | 139 +++++------------- M02_MasterTemplete/M02_Labor_Doc.py | 26 ++-- .../M02_MasterTemplete_ItemCards.ts | 28 ++-- .../M02_MasterTemplete_ItemSource.ts | 12 ++ .../M02_MasterTemplete_Labor_Detail.ts | 16 +- .../M02_Project_Items_Router.py | 4 +- .../tester/helper_item_picker_project.cjs | 32 +++- resources/tester/test_item_copy_complete.py | 27 ++-- resources/tester/test_item_picker_detail.py | 61 ++++---- resources/tester/test_m02_card_shell.py | 9 +- resources/tester/test_m02_item_labor.py | 23 ++- resources/tester/test_m02_labor_layer.py | 4 +- resources/tester/test_m02_labor_ref.py | 6 +- ui_template/ui_template_item_picker.ts | 3 +- ui_template/ui_template_item_picker_detail.ts | 122 +++++++-------- ui_template/ui_template_item_picker_labor.ts | 60 -------- .../ui_template_item_picker_project.ts | 114 ++++++-------- 17 files changed, 305 insertions(+), 381 deletions(-) delete mode 100644 ui_template/ui_template_item_picker_labor.ts diff --git a/M02_MasterTemplete/M02_Item_Prices.py b/M02_MasterTemplete/M02_Item_Prices.py index 6e4c49b2f..3b98e61b7 100644 --- a/M02_MasterTemplete/M02_Item_Prices.py +++ b/M02_MasterTemplete/M02_Item_Prices.py @@ -13,7 +13,7 @@ `단가.json` = `{파일 이름: {그룹 · 머리 칸 …, 줄|표: [줄]}}` — `master_formula.Master` 가 그대로 읽는 꼴. 계산(`calc`)은 이 묶음과 `입력값.json` · `고른자재.json` · `면적.json`(알약 칸 값) 만 씀 — 마스터 -현재값을 읽지 않음. 수량 · 반영 · 합은 일위대가 문서와 같은 함수(`M02_Labor_Doc.settle`). +현재값을 읽지 않음. 셈은 층 일위대가 셈 한 벌(`M02_Labor_Doc.calc` · 142-1 · 빈 입력 = 자동값). """ from __future__ import annotations @@ -265,6 +265,27 @@ def missing_inputs(snapshot: Any, labor: dict[str, Any], values: dict[str, Any]) return out +def with_values(doc: dict[str, Any], values: dict[str, Any], picks: dict[str, Any]) -> dict: + """사본 문서에 사본 `입력값.json` · `고른자재.json` 을 줄 입력값 · 고름으로 얹음(142-1) — + 빈 값은 안 얹음(= 자동값 · M02 층 셈과 같음).""" + lines = [] + for one in doc.get("담은로직") or []: + code = one.get("로직") if isinstance(one, dict) else None + if not code: + lines.append(one) + continue + given = { + name: got.get("값") + for name, got in (values.get(code) or {}).items() + if isinstance(got, dict) and got.get("값") not in (None, "") + } + line = {**one, "입력값": given} + if isinstance((picks or {}).get(code), dict): + line["고름"] = picks[code] + lines.append(line) + return {**doc, "담은로직": lines} + + def calc( labor: dict[str, Any], prices: dict[str, Any], @@ -272,107 +293,25 @@ def calc( picks: dict[str, Any], area: Any = None, ) -> dict[str, Any]: - """복사본만으로 계산 — 입력이 빠진 로직은 안 돌리고 `멈춤` · 세 비목 · 계는 돈 로직 합 - (수량 1 기준). - - 일위대가 문서(39-1c)면 줄마다 수량 · 반영 · `자재` · `기준합` · `반영합` 을 덧붙임 — - 수량 = 손 · 면적 알약 칸(`area`) · 반영 = `M02_Labor_Doc.settle`(문서 계산과 한 벌).""" - snapshot = load_prices(prices) - gaps = missing_inputs(snapshot, labor, values) - blocked = {gap["로직"] for gap in gaps} + """복사본만으로 계산 — 층 셈(`M02_Labor_Doc.calc`) 한 벌(142-1) · 단가 = 사본 `단가.json` · + 참조 = 굳힌 `참조사본` · 알약 = 가져온 때 것 · 수량 칸 = 사본 `면적.json` · 빈 입력 = 자동값. + 답 = 층 셈 꼴(`줄` · `자재` · `기준합` · `반영합`) + `알약` + `빠진입력`(「빈 값 n」 표시).""" from M02_MasterTemplete import M02_Labor_Doc as labor_doc - - sums = dict.fromkeys(mf.COST_ITEMS, Decimal(0)) - lines, stuck = [], [] - rates = { # 켜쌓기율(85-2) — 문서 계산과 같은 `layered` - one.get("로직"): one.get("켜쌓기율") - for one in (labor.get("줄") or {}).get("담은로직") or [] - if isinstance(one, dict) - } - for at, key in enumerate(_logic_keys(labor), start=1): - head: dict[str, Any] = {"차례": at, "로직": key} - if key in blocked: - names = [gap["입력"] for gap in gaps if gap["로직"] == key] - lines.append({**head, "ok": False, "까닭": f"입력 없음 — {', '.join(names)}"}) - stuck.append(key) - continue - given = {name: one.get("값") for name, one in (values.get(key) or {}).items()} - try: - with mf.mp.choosing((picks or {}).get(key)): - got = labor_doc.layered(mf.run(snapshot, key, given), rates.get(key)) - except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as error: - lines.append({**head, "ok": False, "까닭": str(error) or type(error).__name__}) - stuck.append(key) - continue - if "결과" in got: - lines.append({**head, "ok": True, "갈래": "결과", "결과": got["결과"]}) - continue - for name in mf.COST_ITEMS: - sums[name] += got[name] - lines.append( - { - **head, - "ok": True, - "갈래": "돈", - **{k: got[k] for k in mf.COST_ITEMS}, - "계": got["계"], - } - ) - for line in _ref_lines(snapshot, labor, len(lines)): # 참조 줄(50-3) — 굳힌 사본으로 - if line["ok"]: - for name in mf.COST_ITEMS: - sums[name] += line[name] - else: - stuck.append(line["참조"]["키"]) - lines.append(line) - out = {"줄": lines, **sums, "계": sum(sums.values()), "빠진입력": gaps, "멈춤": stuck} - return to_json({**out, **_reflected(snapshot, labor, lines, area)}) - - -def _ref_lines(snapshot: Any, labor: dict[str, Any], start: int) -> list[dict]: - """참조 줄 — 기준 = 굳힌 사본(`참조사본`)을 복사본 단가로 펼친 1 단위 값 - (입력값은 승격 때 고정).""" from M02_MasterTemplete import M02_Labor_Ref as refs + snapshot = load_prices(prices) + doc = with_values(labor.get("줄") or {}, values or {}, picks or {}) copies = labor.get("참조사본") or {} - expand = refs.Expand(snapshot, lambda ref: copies.get(refs.ref_id(ref))) - out = [] - for at, one in enumerate((labor.get("줄") or {}).get("담은로직") or [], start=start + 1): - if not (isinstance(one, dict) and isinstance(one.get("참조"), dict)): - continue - got = expand(one["참조"]) - head = {"차례": at, "참조": one["참조"], "이름": got.get("이름"), "분모": got.get("단위")} - if got.get("기준") is None: - out.append({**head, "ok": False, "까닭": got.get("까닭")}) - continue - out.append({**head, "ok": True, "갈래": "돈", **got["기준"]}) - return out - - -def _reflected(snapshot: Any, labor: dict[str, Any], lines: list[dict], area: Any) -> dict: - """줄마다 수량 · 반영 + 자재 + 기준합 · 반영합 — 기준은 위 줄 그대로 · 알약은 가져온 때 것.""" - from M02_MasterTemplete import M02_Labor_Doc as labor_doc - - doc = labor.get("줄") or {} - pill_of = {code: pill for pill in labor.get("알약") or [] for code in pill.get("로직") or []} - base = [] - for line in lines: - money = {k: line[k] for k in (*mf.COST_ITEMS, "계")} if line.get("갈래") == "돈" else None - if "참조" in line: # 알약 키 = ref_id · 배 1(50-3) - head = {"참조": line["참조"], "알약": labor_doc.line_id(line), "배": 1} - base.append({**head, "분모": line.get("분모"), "기준": money}) - continue - pill = pill_of.get(line["로직"]) or {} - head = {"로직": line["로직"], "알약": pill.get("키"), "배": pill.get("배") or 1} - base.append({**head, "분모": pill.get("단위"), "기준": money}) - mats = [] - for one in doc.get("자재") or []: - code = str(one.get("코드") or "") - got = labor_doc.material_price(snapshot, code, one.get("단위")) # 가격 다섯 · 기준칸(43-3) - mats.append({"코드": code, "알약": labor_doc.material_key(code), **got}) - settled = labor_doc.settle( - to_json({"줄": base, "자재": mats}), doc, labor_doc.area_values(area) + saved = labor.get("알약") + got = labor_doc.calc( + doc, + labor_doc.area_values(area), + whole=snapshot, + read=lambda ref: copies.get(refs.ref_id(ref)), + pills=[p for p in saved if p.get("로직")] if isinstance(saved, list) else None, ) - for line, done in zip(lines, settled["줄"]): - line.update({k: done[k] for k in ("알약", "분모", "배", "수량", "수량출처", "반영")}) - return {"자재": settled["자재"], "기준합": settled["기준합"], "반영합": settled["반영합"]} + return { + **got, + "알약": saved if isinstance(saved, list) else labor_doc.pills(doc), + "빠진입력": missing_inputs(snapshot, labor, values or {}), + } diff --git a/M02_MasterTemplete/M02_Labor_Doc.py b/M02_MasterTemplete/M02_Labor_Doc.py index 5c99a203a..eb96ae447 100644 --- a/M02_MasterTemplete/M02_Labor_Doc.py +++ b/M02_MasterTemplete/M02_Labor_Doc.py @@ -380,16 +380,24 @@ def material_price(master: Any, code: str, unit: str | None) -> dict[str, Any]: } -def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]: +def calc( + doc: dict, + given: dict[str, Any] | None = None, + *, + whole: Any = None, + read: Any = None, + pills: list[dict] | None = None, +) -> dict[str, Any]: """`doc` = 정리한 문서 · `given` = {알약 키: 값}(없으면 지금 층 면적산출 파일에서) — - 줄마다 기준(서버 단독 · M01 엔진) → `settle`. 못 도는 줄은 까닭만(통째로 멈추지 않음).""" - whole = m01.loaded()[1] - expand = refs.Expand(whole) - by_logic = { - code: pill - for pill in combo_store.combo_pills({"담은로직": doc.get("담은로직") or []})["물량"] - for code in pill["로직"] - } + 줄마다 기준(서버 단독 · M01 엔진) → `settle`. 못 도는 줄은 까닭만(통째로 멈추지 않음). + + 프로젝트 사본 셈(142-1)도 이 한 벌 — `whole` = 사본 `단가.json`(`M02_Item_Prices.load_prices`) · + `read` = 참조 → 굳힌 `참조사본` · `pills` = 사본 `알약`(가져온 때 것). 없으면 지금 M01 · 층.""" + whole = m01.loaded()[1] if whole is None else whole + expand = refs.Expand(whole) if read is None else refs.Expand(whole, read) + if pills is None: + pills = combo_store.combo_pills({"담은로직": doc.get("담은로직") or []})["물량"] + by_logic = {code: pill for pill in pills for code in pill.get("로직") or []} lines = [] for one in doc.get("담은로직") or []: if _is_ref(one): diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts index 0c35d5b1d..466cb0411 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts @@ -20,11 +20,7 @@ import type { SpreadsheetHandle, Workbook, } from "../A00_Common/spreadsheet/spreadsheet"; -import { - readTemplate, - saveTemplate, - type StructureDoc, -} from "./M02_MasterTemplete_Api_Fetch"; +import { readTemplate, saveTemplate, type StructureDoc } from "./M02_MasterTemplete_Api_Fetch"; import { cardShell, helpButton, @@ -293,13 +289,14 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { if (!laborCard) return; laborEd?.destroy(); laborEd = null; - const layer = opts.source().layer; + const src = opts.source(); + const layer = src.layer; let mod: typeof import("./M02_MasterTemplete_Labor_Detail"); - let got: Awaited>; + let got: { 문서: unknown; 판?: string | null }; try { [mod, got] = await Promise.all([ import("./M02_MasterTemplete_Labor_Detail"), - readTemplate(layer, "labor", at, null), + src.labor ? src.labor.read(at) : readTemplate(layer, "labor", at, null), ]); } catch (error) { if (mine === seq) laborCard.shell.fail(fill(TEXT.noSheet, why(error)), reload); @@ -310,15 +307,24 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { const d = (got.문서 ?? {}) as { 일위대가번?: string }; laborCard.no.textContent = d.일위대가번 || numberOf("labor", at) || fill(TEXT.noFieldNumber, PART_FIELD.labor); + const via = src.labor; // 자료 자리 길(142-1 · 고르기 모달) — 셈 · 면적도 그 자리에서 laborEd = mod.mountLaborDetail(laborHost, got.문서, { layer, column: at, - readOnly: !opts.editable(), - onChanged: (dirty) => laborCard.saver.mark(dirty && opts.editable()), + readOnly: !laborWritable(), + ...(via + ? { + calc: (doc, 수량) => via.calc(at, doc, 수량), + readArea: () => src.read("area", at), + } + : {}), + onChanged: (dirty) => laborCard.saver.mark(dirty && laborWritable()), goArea: null, // 면적 칸 잇기는 수량 모달 안(43-1) — 카드에서 화면을 옮기지 않음 card: true, }); }; + /** 일위대가 카드를 고칠 수 있나 — 쓰는 길(`write`)이 없는 자료 자리(고르기 모달)는 보기만(142-1) */ + const laborWritable = (): boolean => opts.editable() && !!opts.source().write; /** 카드 [저장] — 상세와 같은 차례: 고른 칸 연결(면적 문서) 먼저 → 일위대가(판을 다시 읽어 409 막음) */ async function saveLabor(): Promise { const at = column; @@ -349,7 +355,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { const sheetEdit = !!opts.sheetEdit?.can(); for (const c of cards) { const can = - c.kind === "structure" ? drawEdit : c.kind === "labor" ? opts.editable() : sheetEdit; + c.kind === "structure" ? drawEdit : c.kind === "labor" ? laborWritable() : sheetEdit; c.saver.button.hidden = !can; c.edit.hidden = !opts.editable(); c.take.hidden = !opts.editable() || !opts.onImport; diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemSource.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemSource.ts index c8903d81c..4e9fb08cc 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemSource.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemSource.ts @@ -11,6 +11,8 @@ import { saveTemplate, type Layer, } from "./M02_MasterTemplete_Api_Fetch"; +import type { LaborDoc } from "./M02_MasterTemplete_Labor_Api"; +import type { LaborCalc } from "./M02_MasterTemplete_Labor_Calc"; /** 집계표 열 하나에 딸린 문서 — 구조물 도면 · 면적산출 */ export type ItemPart = "structure" | "area"; @@ -23,6 +25,16 @@ export interface ItemSource { read: (part: ItemPart, column: string) => Promise<{ 문서: unknown; 판?: string | null }>; /** 카드 안 [저장](PLAN 31-4) — 전체 편집기와 같은 저장 길 · 없으면 카드는 보기만 */ write?: (part: ItemPart, column: string, 판: string, 문서: unknown) => Promise<{ 판: string }>; + /** 일위대가 카드 자리(142-1) — 읽기 · 셈(고르기 모달 = 프로젝트 복사본 · 원본 층) · + * 없으면 카드가 지금 층 길(`readTemplate` · `calcLabor`) · `write` 가 없으면 카드는 보기만 */ + labor?: { + read: (column: string) => Promise<{ 문서: unknown; 판?: string | null }>; + calc: ( + column: string, + doc: LaborDoc, + 수량?: Record, + ) => Promise; + }; } /** M02 화면의 자료 자리 — 부를 때의 지금 층(`getActiveLayer`) 그대로 */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index 878146694..f8b040585 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -88,6 +88,9 @@ export interface LaborDetailOptions { mode?: "precombo"; /** 그 층 주인(134-3 · 사전조합 층 · null = 자기 층) — 없으면 페이지 층 것 */ owner?: string | null; + /** 셈 · 면적 자리 주입(142-1 · 고르기 모달 = 프로젝트 복사본) — 없으면 층 길 */ + calc?: (doc: LaborDoc, 수량?: Record) => Promise; + readArea?: () => Promise<{ 문서: unknown; 판?: string | null }>; } export interface LaborDetailHandle { @@ -245,13 +248,10 @@ export function mountLaborDetail( draw(); try { // 저장 안 한 칸 연결이 있으면 그 칸 값으로(없으면 서버가 저장된 면적산출을 읽음) - const got = await calcLabor( - opt.layer, - opt.column, - cur, - areaDirty ? given() : undefined, - opt.owner, - ); + const qty = areaDirty ? given() : undefined; + const got = opt.calc + ? await opt.calc(cur, qty) + : await calcLabor(opt.layer, opt.column, cur, qty, opt.owner); if (!alive || mine !== seq) return; calc = last = got; pills = new Map( @@ -644,7 +644,7 @@ export function mountLaborDetail( lib // 서고 모드엔 면적산출 없음 ? undefined : Promise.all([ - readTemplate(opt.layer, "area", opt.column, null), + opt.readArea ? opt.readArea() : readTemplate(opt.layer, "area", opt.column, null), import("../A00_Common/spreadsheet/spreadsheet_recalc"), ]).then( ([got, { recalcWorkbook }]) => { diff --git a/M02_MasterTemplete/M02_Project_Items_Router.py b/M02_MasterTemplete/M02_Project_Items_Router.py index d877cda49..7413e66ce 100644 --- a/M02_MasterTemplete/M02_Project_Items_Router.py +++ b/M02_MasterTemplete/M02_Project_Items_Router.py @@ -8,8 +8,8 @@ PUT …/items/{폴더}/{종류} {판, 내용} — 판이 다르면 409 · 면적은 서버 계산값 POST …/items/{폴더}/reapply 재적용(입력값 · 고른자재는 같은 키면 살림 · `덮임` 알림) POST …/items/{폴더}/save-as {to, 경로, 이름} 개인 층에 새 항목으로(단가 묶음은 안 가져감) - POST …/items/{폴더}/calc 복사본만으로 계산(세 비목 · 계 · 빠진 입력) - 일위대가 문서면 줄마다 수량 · 반영 + 자재 · 기준합 · 반영합 + POST …/items/{폴더}/calc 복사본만으로 계산 — 층 셈 한 벌(142-1 · 줄 · 자재 · + 기준합 · 반영합 + 알약 · 빠진입력 · 빈 입력 = 자동값) 종류 = 도면 · 면적 · 입력값 · 고른자재 · 일위대가(읽기만 · 쓰면 400) · `refresh` = 옛 이름(재적용). ⚠ 등록은 `main.py`(로그인만) — 프로젝트 권한(같은 회사)은 여기서 `_project` 로 봄(403 · 404). diff --git a/resources/tester/helper_item_picker_project.cjs b/resources/tester/helper_item_picker_project.cjs index a54bc1ac5..18928fc96 100644 --- a/resources/tester/helper_item_picker_project.cjs +++ b/resources/tester/helper_item_picker_project.cjs @@ -143,15 +143,18 @@ globalThis.fetch = async (url, init = {}) => { } if (part === "calc") return reply(200, { - 줄: [], - 계: 12345.6, + 줄: [ + { 로직: "GA1", 까닭: "없는 요소" }, + { 로직: "GA2", 까닭: null }, + ], + 자재: [], 빠진입력: [ { 로직: "GA1", 입력: "지역" }, { 로직: "GA2", 입력: "지역" }, ], - 멈춤: ["GA1"], 기준합: { 계: 100 }, 반영합: { 계: 300 }, + 알약: [], }); if (method === "GET") { const content = item.parts[part] ?? null; @@ -246,10 +249,25 @@ async function main() { out.calc = await items.calc(info.폴더); out.calcMissing = await items.calc("없는폴더"); - // 일위대가 — 복사본 요약(복사본 계산) · 원본 층 요약(층 calc) · 넣을 값 = 담은 로직 입력 전부 - out.laborCopy = await project.laborOf(row(), items, info.폴더); - out.laborOrigin = await project.laborOf(row(), null, null); - out.laborNone = await project.laborOf(row({ 열: "없는열" }), null, null); + // 일위대가(142-1) — 모달 카드 셈 = 복사본 셈 답 통째 · 문서 = 복사본 입력값 · 고른자재를 얹음 + const raw = await items.calcLabor(info.폴더); + out.laborCalc = { 줄: raw.줄.length, 반영합: raw.반영합, 빠진입력: raw.빠진입력.length }; + out.laborCalcMissing = await items.calcLabor("없는폴더").then( + () => "성공", + (error) => error.message, + ); + out.withValues = project.withCopyValues( + { + 일위대가번: "SU-000007", + 담은로직: [ + { 로직: "GA1", 입력값: { 지역: 1 } }, + { 로직: "GA2", 입력값: { 지역: 2 }, 고름: { 0: "MT1" } }, + { 참조: { 층: "system", 주인: null, 키: "SC-000001" } }, + ], + }, + { GA1: { 지역: { 값: 15, 확인: true }, 소량감률: { 값: "", 확인: false } } }, + { GA1: { 0: "MT9" } }, + ); const valueData = await project.laborValueData(["GA1", "GA2"], { GA1: {} }); out.laborValues = { keys: state.logicKeys, diff --git a/resources/tester/test_item_copy_complete.py b/resources/tester/test_item_copy_complete.py index fc5be79a1..d74bba761 100644 --- a/resources/tester/test_item_copy_complete.py +++ b/resources/tester/test_item_copy_complete.py @@ -84,6 +84,11 @@ def _read(path: Path) -> Any: return json.loads(path.read_text(encoding="utf-8")) +def _stuck(got: dict) -> list: + """못 돈 줄(까닭) — 층 셈 꼴(142-1).""" + return [line for line in got["줄"] if line.get("까닭")] + + def _auto_values(root: Path) -> dict[str, Any]: """담은 로직마다 자동값을 확인한 값으로 — 사용자가 다 채운 것과 같게.""" labor = _read(root / "templates/structures" / FOLDER / "일위대가.json") @@ -168,12 +173,13 @@ def _run(master: Any, key: str, values: dict) -> str: # ── 계산 · 마스터 바뀜 · 새로 가져오기 ─────────────────── -def test_입력이_없으면_빠진_입력_목록(world): +def test_입력이_없으면_빠진_입력_목록_셈은_자동값(world): + """142-1 — 빈 입력은 M02 층 셈과 같이 자동값으로 돎 · 빠진 입력은 「빈 값 n」 표시용 목록.""" root = world["root"] _save(root, [_pipe()]) got = copies.calc(root, FOLDER) - assert got["계"] == 0 and len(got["멈춤"]) == 3 assert {"로직": "GF000176", "입력": "관경"} in got["빠진입력"] + assert _stuck(got) == [] and got["기준합"]["계"] > 0 def test_마스터_단가를_바꿔도_계산_그대로_새로_가져오면_바뀜(world): @@ -181,7 +187,7 @@ def test_마스터_단가를_바꿔도_계산_그대로_새로_가져오면_바 _save(root, [_pipe()]) values = _fill_values(root) before = copies.calc(root, FOLDER) - assert before["빠진입력"] == [] and before["멈춤"] == [] and before["계"] > 0 + assert before["빠진입력"] == [] and _stuck(before) == [] and before["기준합"]["계"] > 0 files, whole = m01.loaded() ua = next( r @@ -192,7 +198,8 @@ def test_마스터_단가를_바꿔도_계산_그대로_새로_가져오면_바 ) given = {k: {n: v["값"] for n, v in one.items()} for k, one in values.items()} expected = prices.to_json(prices.mcb.run(whole, ua, given)) - assert before["계"] == pytest.approx(expected["계"]) and before["노무비"] > 0 + assert before["기준합"]["계"] == pytest.approx(expected["계"]) + assert before["기준합"]["노무비"] > 0 labor_file = master_dir / "인력.json" original = labor_file.read_bytes() try: @@ -204,7 +211,8 @@ def test_마스터_단가를_바꿔도_계산_그대로_새로_가져오면_바 assert copies.list_items(root)[0]["마스터바뀜"] is True copies.refresh(root, FOLDER) after = copies.calc(root, FOLDER) - assert after["노무비"] > before["노무비"] and after["재료비"] == before["재료비"] + assert after["기준합"]["노무비"] > before["기준합"]["노무비"] + assert after["기준합"]["재료비"] == before["기준합"]["재료비"] assert copies.read_file(root, FOLDER, "입력값")["내용"] == values # 같은 키 살림 assert copies.list_items(root)[0]["마스터바뀜"] is False finally: @@ -299,7 +307,7 @@ def test_옛_복사본은_읽을_때_채움(world): assert copies.read_file(root, FOLDER, "입력값")["내용"] == {} filled = _read(folder / "manifest.json") assert filled["가져온날"] == filled["적용일"] and filled["옮겨받음"] - assert (folder / "단가.json").is_file() and copies.calc(root, FOLDER)["멈춤"] + assert (folder / "단가.json").is_file() and copies.calc(root, FOLDER)["빠진입력"] assert copies.list_items(root)[0]["마스터바뀜"] is False # 원본이 안 바뀐 옛 복사본 @@ -328,7 +336,7 @@ def test_길_권한_남의_프로젝트는_403(world, monkeypatch): stale = client.put(f"{base}/{FOLDER}/입력값", json={"판": got["판"] + "x", "내용": {}}) assert stale.status_code == 409 and stale.json()["detail"]["판"] == put.json()["판"] assert client.get(f"{base}/{FOLDER}/모름").status_code == 422 - assert client.post(f"{base}/{FOLDER}/calc").json()["멈춤"] + assert client.post(f"{base}/{FOLDER}/calc").json()["빠진입력"] wall = {"structure": "st9", "layer": "system", "owner": None} assert client.post(base, json={**wall, "summary_item": {"layer": "x"}}).status_code == 400 assert client.post(base, json={"layer": "system", "key": "SS000022"}).status_code == 400 @@ -388,8 +396,9 @@ def test_같은_항목_구조물_둘은_따로(world): assert copies.read_file(root, "a1", "면적")["내용"]["메모"] == "a1 만 고침" assert "메모" not in copies.read_file(root, "a2", "면적")["내용"] # 다른 쪽 그대로 _fill_values(root) # pipe@40.0 에만 값 - assert copies.calc(root, FOLDER)["멈춤"] == [] and copies.calc(root, FOLDER)["계"] > 0 - assert len(copies.calc(root, "pipe@60.0")["멈춤"]) == 3 # 계산도 각자 + got = copies.calc(root, FOLDER) + assert _stuck(got) == [] and got["기준합"]["계"] > 0 + assert copies.calc(root, "pipe@60.0")["빠진입력"] and not got["빠진입력"] # 계산도 각자 flags = {i["폴더"]: i["고침"] for i in copies.list_items(root)} assert flags == {"a1": True, "a2": False, FOLDER: True, "pipe@60.0": False} diff --git a/resources/tester/test_item_picker_detail.py b/resources/tester/test_item_picker_detail.py index 83ee822ee..a6e00779f 100644 --- a/resources/tester/test_item_picker_detail.py +++ b/resources/tester/test_item_picker_detail.py @@ -82,7 +82,8 @@ def test_재적용은_덮임을_돌려주고_계산_한_줄(out): assert out["reapplyCall"][0] == "POST /api/projects/P1/items/pipe%4085.05/reapply" assert out["reapply"]["covered"] == ["면적"] # 덮이는 것을 알림으로 assert out["reapply"]["info"]["마스터바뀜"] is False - assert out["calc"] == {"total": 12345.6, "gaps": 2, "stuck": 1, "base": 100, "reflected": 300} + # 142-1 — 계 = 반영합 · 빈 값 = 빠진입력 · 못 돈 줄 = 까닭 있는 줄 + assert out["calc"] == {"total": 300, "gaps": 2, "stuck": 1, "base": 100, "reflected": 300} assert out["calcMissing"] is None # 없는 복사본 = 계산 줄 없음 @@ -140,28 +141,20 @@ def test_넣을_값_카드는_sub2_파일_한_벌(out): assert "M02_Labor_Values" in text and 'InputsCard"' not in text, name -def test_일위대가_요약_복사본_원본_넣을_값_전부(out): - """39-1c — 복사본이면 복사본 일위대가 · 계산 · 아니면 원본 층 calc · - 넣을 값 = 담은 로직 입력 전부.""" - assert out["laborCopy"] == { - "번호": "SU-000007", - "로직": ["GA1", "GA2"], - "자재": 1, - "base": 100, - "reflected": 300, - "gaps": 2, - "stuck": 1, - } - assert out["laborOrigin"] == { - "번호": None, - "로직": ["GA1", "GA2"], - "자재": 1, - "base": 500, - "reflected": 1500, - "gaps": 0, - "stuck": 1, - } - assert out["laborNone"] is None # 일위대가 없는 열(층 calc 404) +def test_일위대가_카드_셈은_복사본_셈_답_통째_문서는_복사본_값(out): + """142-1 — 모달 일위대가 카드 = M02 카드 · 셈 = 복사본 셈(층 셈 꼴) · 문서 = 복사본 입력값 · + 고른자재를 줄 입력값 · 고름으로(빈 값 = 자동값이라 안 얹음 · 서버 `with_values` 와 같은 규칙).""" + assert out["laborCalc"] == {"줄": 2, "반영합": {"계": 300}, "빠진입력": 2} + assert out["laborCalcMissing"] == "복사본이 없음" # 서버 까닭 그대로(카드 안 글) + lines = out["withValues"]["담은로직"] + assert lines[0] == {"로직": "GA1", "입력값": {"지역": 15}, "고름": {"0": "MT9"}} + assert lines[1] == { + "로직": "GA2", + "입력값": {}, + "고름": {"0": "MT1"}, + } # 복사본에 값 없음 = 자동값 + assert lines[2] == {"참조": {"층": "system", "주인": None, "키": "SC-000001"}} + assert out["withValues"]["일위대가번"] == "SU-000007" values = out["laborValues"] assert values["keys"] == ["GA1", "GA2"] assert values["logics"] == [["GA1", ["지역", "소량감률"]], ["GA2", ["지역"]]] @@ -169,9 +162,27 @@ def test_일위대가_요약_복사본_원본_넣을_값_전부(out): def test_모달_카드_차례_도면_일위대가_면적(): + """142-1 — 일위대가 카드 = M02 카드 한 벌(`createItemCards` 의 `labor`) · 옛 요약 카드 · 넣을 값 + 카드 · 머리 옛 「일위대가 {조합 이름}」 줄 없음.""" detail = (UI / "ui_template_item_picker_detail.ts").read_text(encoding="utf-8") - assert 'import("./ui_template_item_picker_labor")' in detail - assert "cards.root.insertBefore(labor.root, cards.root.children[1]" in detail + assert not (UI / "ui_template_item_picker_labor.ts").exists() + assert "ui_template_item_picker_labor" not in detail + assert 'labor: () => void edit("inputs"),' in detail # [수정] = 넣을 값 편집 화면 + assert "createInputsValueCard" not in detail and "extraCards" not in detail + assert "folderLines" not in detail and "unitPrice" not in detail + + +def test_모달_일위대가_카드_읽기_전용(): + """142-1 — 모달 자료 자리엔 쓰는 길(`write`)이 없고 카드는 그때 보기만(원본 층 [저장]을 안 엶).""" + detail = (UI / "ui_template_item_picker_detail.ts").read_text(encoding="utf-8") + body = detail[detail.index("const makeSource") : detail.index("const badge")] + assert "write" not in body and "labor: {" in body and "items.calcLabor(at)" in body + cards = (M02 / "M02_MasterTemplete_ItemCards.ts").read_text(encoding="utf-8") + assert "const laborWritable = (): boolean => opts.editable() && !!opts.source().write;" in cards + assert "readOnly: !laborWritable()," in cards + assert 'c.kind === "labor" ? laborWritable()' in cards + picker = (UI / "ui_template_item_picker.ts").read_text(encoding="utf-8") + assert '"personal" : "system"' in picker # 처음 펼침 = 개인(없으면 마스터) def test_모달은_원본_층에_쓰는_길을_안_부름(): diff --git a/resources/tester/test_m02_card_shell.py b/resources/tester/test_m02_card_shell.py index fbd950f94..91d4a39a6 100644 --- a/resources/tester/test_m02_card_shell.py +++ b/resources/tester/test_m02_card_shell.py @@ -60,10 +60,8 @@ def test_실패는_카드_안_글과_다시_불러오기(): assert "showToast" not in cards # 토스트 안 씀(저장 알림은 틀) assert "c.shell.fail(" in cards and "reload" in cards picker = (ROOT / "ui_template" / "ui_template_item_picker_detail.ts").read_text("utf-8") - # 일위대가 카드 · 값 카드가 다시 불러오기 하나(retry)를 같이 씀(39-1c) - assert "const retry = (): void => void paintLabor(row, seq);" in picker - assert "inputs.set(null, why(error), retry);" in picker - assert "const stops = [labor.loading(), inputs.loading()];" in picker + # 모달 일위대가 카드도 M02 카드 틀 안 실패 글(142-1 · 옛 요약 · 값 카드 retry 없음) + assert "paintLabor" not in picker and 'labor: () => void edit("inputs"),' in picker def test_일위대가_카드는_상세_카드_갈래(): @@ -75,7 +73,8 @@ def test_일위대가_카드는_상세_카드_갈래(): assert 'makeCard("labor", PART_WORD.labor, laborHost, () => saveLabor())' in cards save = cards[cards.index("async function saveLabor()") :] assert save.index("laborEd.saveArea()") < save.index('saveTemplate(layer, "labor"') - assert "onChanged: (dirty) => laborCard.saver.mark(dirty && opts.editable())," in cards + # 고칠 수 있음 = editable + 쓰는 길(142-1 · 고르기 모달은 보기만) + assert "onChanged: (dirty) => laborCard.saver.mark(dirty && laborWritable())," in cards assert "Labor_View" not in cards # 한 줄 표(40-2) 안 씀 detail = _text("M02_MasterTemplete_Labor_Detail.ts") assert "move: reorder," in detail # 85-3 — 카드도 순서(▲▼ · 끌기) · 빼기는 그대로 diff --git a/resources/tester/test_m02_item_labor.py b/resources/tester/test_m02_item_labor.py index 90c0f831c..eb480e157 100644 --- a/resources/tester/test_m02_item_labor.py +++ b/resources/tester/test_m02_item_labor.py @@ -169,14 +169,15 @@ def test_계산_반영_합은_손셈_원본과_같음(world): _save(root) _fill_auto(root) got = copies.calc(root, FOLDER) - assert got["멈춤"] == [], got["줄"] + assert [line for line in got["줄"] if line.get("까닭")] == [], got["줄"] lines = {line["로직"]: line for line in got["줄"]} hand, area = lines[LOGICS[1]], lines[LOGICS[2]] assert (hand["수량"], hand["수량출처"]) == (HAND, "손") assert (area["수량"], area["수량출처"]) == (AREA_QTY, "칸") assert lines[LOGICS[0]]["수량출처"] == "없음" and lines[LOGICS[0]]["반영"] is None for line in (hand, area): - expect = Decimal(str(line["계"])) * Decimal(str(line["수량"])) / Decimal(str(line["배"])) + base = Decimal(str(line["기준"]["계"])) + expect = base * Decimal(str(line["수량"])) / Decimal(str(line["배"])) assert Decimal(str(line["반영"]["계"])) == pytest.approx(expect) (mat,) = got["자재"] assert mat["코드"] == MATERIAL and mat["수량"] == MAT_QTY and mat["기준단가"] @@ -208,3 +209,21 @@ def test_일위대가_쓰기는_막힘(world): assert current["내용"]["줄"]["열"] == COLUMN with pytest.raises(ValueError, match="원본"): copies.write_file(root, FOLDER, "일위대가", current["판"], current["내용"]) + + +def test_같은_문서_같은_단가면_사본_셈은_층_셈(world): + """142-1 — 사본 셈 = 층 셈 한 벌(`M02_Labor_Doc.calc`) · 빈 입력은 둘 다 자동값 · 줄 꼴 같음.""" + root = world["root"] + _save(root) + got = copies.calc(root, FOLDER) + given = {world["area_pill"]: AREA_QTY, labor_doc.material_key(MATERIAL): MAT_QTY} + origin = labor_doc.calc(world["doc"], given) + keys = ("로직", "알약", "분모", "배", "기준", "수량", "수량출처", "반영", "까닭") + assert [{k: line.get(k) for k in keys} for line in got["줄"]] == [ + {k: line.get(k) for k in keys} for line in origin["줄"] + ] + assert got["자재"] == origin["자재"] + assert got["기준합"] == origin["기준합"] and got["반영합"] == origin["반영합"] + assert got["반영합"]["계"] > 0 + assert {p["키"] for p in got["알약"]} == set(world["pills"]) # 사본 알약(가져온 때 것) + assert got["빠진입력"] # 비운 입력은 목록으로(화면 「빈 값 n」) diff --git a/resources/tester/test_m02_labor_layer.py b/resources/tester/test_m02_labor_layer.py index 36c32ad76..25de3f108 100644 --- a/resources/tester/test_m02_labor_layer.py +++ b/resources/tester/test_m02_labor_layer.py @@ -78,9 +78,9 @@ def test_문서_계산과_프로젝트_사본_같음() -> None: got = prices.calc(copy, prices.to_json(prices.collect([CODE])), values, {}) origin = labor.calc(doc, {}) (line,) = got["줄"] - assert line["계"] == pytest.approx(origin["줄"][0]["기준"]["계"]) + assert line["기준"]["계"] == pytest.approx(origin["줄"][0]["기준"]["계"]) # 층 셈 꼴(142-1) full = labor.calc({**doc, "담은로직": [{**doc["담은로직"][0], "켜쌓기율": None}]}, {}) - assert _d(line["계"]) == pytest.approx(_d(full["줄"][0]["기준"]["계"]) * Decimal("0.9")) + assert _d(line["기준"]["계"]) == pytest.approx(_d(full["줄"][0]["기준"]["계"]) * Decimal("0.9")) def test_옛_단가승격도_율() -> None: diff --git a/resources/tester/test_m02_labor_ref.py b/resources/tester/test_m02_labor_ref.py index 78516ffb4..ac152eb1f 100644 --- a/resources/tester/test_m02_labor_ref.py +++ b/resources/tester/test_m02_labor_ref.py @@ -373,8 +373,8 @@ def test_프로젝트는_참조사본으로_굳힘_원본을_지워도_같은_ (world["system"] / kind / f"{key}.json").unlink() got = prices.calc(rows, bundle, {}, {}, None) line = got["줄"][0] - assert line["ok"] and line["참조"] == ref - assert line["계"] == pytest.approx(live, rel=1e-9) + assert not line["까닭"] and line["참조"] == ref # 층 셈 꼴(142-1) + assert line["기준"]["계"] == pytest.approx(live, rel=1e-9) assert line["수량"] == 1 and line["반영"]["계"] == pytest.approx(live, rel=1e-9) @@ -458,7 +458,7 @@ def test_사전조합_로직_줄_섞임_합은_손셈_지우기_409_굳힘(world for key in (sc, sp): # 원본이 사라져도 굳힌 사본으로 같은 값 (world["system"] / refs.kind_of(key) / f"{key}.json").unlink() got = prices.calc(rows, bundle, {}, {}, None)["줄"][0] - assert got["계"] == pytest.approx(float(want), rel=1e-9) + assert got["기준"]["계"] == pytest.approx(float(want), rel=1e-9) def test_사전조합이_자기_자신을_담으면_422_수량_기본_1(world): diff --git a/ui_template/ui_template_item_picker.ts b/ui_template/ui_template_item_picker.ts index e3ba0935b..9c8868547 100644 --- a/ui_template/ui_template_item_picker.ts +++ b/ui_template/ui_template_item_picker.ts @@ -245,7 +245,8 @@ export function openItemPicker(opts: ItemPickerOptions): Promise layerOf(row) === "personal") ? "personal" : "system"); } paintTree(); paintDetail(); diff --git a/ui_template/ui_template_item_picker_detail.ts b/ui_template/ui_template_item_picker_detail.ts index 6aea8414c..faa400fad 100644 --- a/ui_template/ui_template_item_picker_detail.ts +++ b/ui_template/ui_template_item_picker_detail.ts @@ -1,9 +1,10 @@ /* ============================================================================= * ui_template_item_picker_detail.ts - * 항목 고르기 모달 오른쪽 상세(계획서 25-2d · 26-3b · 39-1c) — 카드 = 도면 · 일위대가 · 면적산출 · - * 넣을 값. 카드는 M02 와 같은 한 벌(`M02_MasterTemplete_ItemCards` · `M02_Labor_Values`) + 일위대가 카드 - * (`ui_template_item_picker_labor`)를 늦게 받아 씀(B05 · B06 번들에 CAD · 스프레드시트가 안 섞이게 `import()`). - * 넣을 값 = 일위대가 담은 로직 입력 전부(복사본이면 복사본 입력값). + * 항목 고르기 모달 오른쪽 상세(계획서 25-2d · 26-3b · 39-1c · 142-1) — 카드 = 도면 · 일위대가 · 면적산출. + * 카드는 M02 와 같은 한 벌(`M02_MasterTemplete_ItemCards` · 일위대가 = `Labor_Detail` 카드 갈래)을 늦게 받아 씀 + * (B05 · B06 번들에 CAD · 스프레드시트가 안 섞이게 `import()`). 일위대가 카드 = 자료 자리 길(`ItemSource.labor`) · + * 읽기 전용(쓰는 길 없음) · 복사본이면 복사본 문서 + 복사본 입력값 · 고른자재 · 셈은 복사본 셈(층 셈 한 벌). + * 넣을 값 고침 = 일위대가 카드 [수정] → 넣을 값 편집 화면. * * 자료 자리 — **그 구조물의 복사본**(폴더 = 구조물 id · 같은 항목을 고른 구조물도 복사본은 따로)을 읽고, * 그 구조물에 아직 없으면 **원본 층**을 읽음(읽기만). [수정] 은 복사본만 고침 — 아직이면 「이 구조물로 가져와 고침」 @@ -14,14 +15,12 @@ import { createButton, el, showConfirmDialog, showToast } from "./ui_template_elements"; import { readTemplate } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; +import { calcLabor, type LaborDoc } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api"; import type { EditKind, ItemCardsHandle } from "../M02_MasterTemplete/M02_MasterTemplete_ItemCards"; import type { ItemSource } from "../M02_MasterTemplete/M02_MasterTemplete_ItemSource"; -import type { InputsValueHandle } from "../M02_MasterTemplete/M02_Labor_Values"; import type { ValueDoc } from "../M02_MasterTemplete/M02_Labor_Values_Model"; -import type { LaborCardHandle } from "./ui_template_item_picker_labor"; import { askFields } from "./ui_template_item_picker_ask"; import { - folderLines, itemName, layerOf, pathOf, @@ -30,8 +29,6 @@ import { type PickerRow, } from "./ui_template_item_picker_data"; import { - laborOf, - laborValueData, PART_OF, projectItems, sameItem, @@ -39,6 +36,7 @@ import { type CreatedItem, type ProjectItemInfo, type ProjectItems, + withCopyValues, } from "./ui_template_item_picker_project"; /** 편집 화면을 여는 데 필요한 것 — 「넣을 값」 도 한 종류 */ @@ -105,10 +103,9 @@ const TEXT = { empty: "{label} 을(를) 넣을 것", importFailed: "이 구조물로 가져오지 못함 — {value}", listFailed: "프로젝트 항목을 못 읽음 — {value}", - calc: "복사본 계산 — 계 {total}원 · 빠진 입력 {gaps}개", - calcStuck: " · 멈춘 로직 {stuck}개", + calc: "복사본 계산 — 계 {total}원 · 빈 값 {gaps}개(자동값)", + calcStuck: " · 못 돈 줄 {stuck}개", noCopy: "복사본에 없음", - price: "일위대가", parts: { 도면: "도면", 면적: "면적산출", @@ -131,8 +128,6 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { let seq = 0; let infos: ProjectItemInfo[] | null = null; let cards: ItemCardsHandle | null = null; - let inputs: InputsValueHandle | null = null; - let labor: LaborCardHandle | null = null; let source: ItemSource = { layer: "system", projectId: null, read: () => Promise.reject() }; /** 이 구조물에 있는 복사본 — 고른 항목의 것일 때만(다른 항목의 것이면 `other`) */ let info: ProjectItemInfo | null = null; @@ -149,7 +144,6 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { const where = [opts.layerLabels[layerOf(row)], ...pathOf(row).slice(0, -1)] .filter(Boolean) .join(" › "); - const pairs = folderLines(row).filter((line) => line.kind === "unitPrice"); head.replaceChildren( el("h4", { className: "ui-item-picker__name", @@ -158,29 +152,46 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { }), small(where), ...(row.단위 ? [small(`${opts.unit} ${row.단위}`)] : []), - ...pairs - .filter((line) => line.name || line.key) - .map((line) => small(`${TEXT.price} ${line.name || line.key}`)), ); }; - /** 이 구조물 복사본 또는 원본 층에서 열 하나의 문서를 읽음 — 없으면 던짐(카드가 「없음」) */ - const makeSource = (row: PickerRow): ItemSource => - info && opts.projectId - ? { - layer: "project", - projectId: opts.projectId, - read: async (part) => { - const doc = await items?.read(info!.폴더, PART_OF[part]); - if (doc == null) throw new Error(TEXT.noCopy); - return { 문서: doc }; + /** 이 구조물 복사본 또는 원본 층에서 열 하나의 문서를 읽음 — 없으면 던짐(카드가 「없음」) · + * 쓰는 길(`write`)은 없음 — 카드는 보기만(원본 층 · 복사본 고침은 편집 화면 · 142-1) */ + const makeSource = (row: PickerRow): ItemSource => { + if (info && opts.projectId && items) { + const at = info.폴더; + return { + layer: "project", + projectId: opts.projectId, + read: async (part) => { + const doc = await items.read(at, PART_OF[part]); + if (doc == null) throw new Error(TEXT.noCopy); + return { 문서: doc }; + }, + labor: { + read: async () => { + const [labor, values, picks] = await Promise.all([ + items.read(at, "일위대가") as Promise<{ 줄?: LaborDoc } | null>, + items.read(at, "입력값") as Promise, + items.read(at, "고른자재") as Promise | null>, + ]); + return { 문서: labor?.줄 ? withCopyValues(labor.줄, values, picks) : {} }; }, - } - : { - layer: layerOf(row), - projectId: null, - read: (part, column) => readTemplate(layerOf(row), part, column, null), - }; + calc: () => items.calcLabor(at), + }, + }; + } + const layer = layerOf(row); + return { + layer, + projectId: null, + read: (part, column) => readTemplate(layer, part, column, null), + labor: { + read: (column) => readTemplate(layer, "labor", column, null), + calc: (column, doc, 수량) => calcLabor(layer, column, doc, 수량, null), + }, + }; + }; const badge = (text: string, className = ""): HTMLElement => el("span", { className: `ui-item-picker__badge ${className}`.trim(), text }); @@ -293,46 +304,17 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { opts.onEdit({ kind, row, info, items, projectId: opts.projectId }); } - /** 일위대가 카드 · 넣을 값 카드 — 복사본이 있으면 복사본(값도) · 아니면 원본 층(39-1c) */ - async function paintLabor(row: PickerRow, mine: number): Promise { - if (!inputs || !labor) return; - const stops = [labor.loading(), inputs.loading()]; // 카드 안 「불러오는 중」(M02 31-7) - try { - const copy = info && items ? info.폴더 : null; - const summary = await laborOf(row, copy ? items : null, copy); - const values = copy ? (((await items?.read(copy, "입력값")) as ValueDoc | null) ?? {}) : {}; - const data = summary ? await laborValueData(summary.로직, values) : null; - stops.forEach((stop) => stop()); - if (mine !== seq) return; - labor.set(summary); - inputs.set(data); - } catch (error) { - stops.forEach((stop) => stop()); - if (mine !== seq) return; - const retry = (): void => void paintLabor(row, seq); - labor.set(null, why(error), retry); - inputs.set(null, why(error), retry); - } - } - async function ensureCards(): Promise { - if (cards && inputs && labor) return; - const [cardModule, inputModule, laborModule] = await Promise.all([ - import("../M02_MasterTemplete/M02_MasterTemplete_ItemCards"), - import("../M02_MasterTemplete/M02_Labor_Values"), - import("./ui_template_item_picker_labor"), - ]); - labor = laborModule.createLaborCard(); - inputs = inputModule.createInputsValueCard({ mode: "view", onEdit: () => void edit("inputs") }); + if (cards) return; + const cardModule = await import("../M02_MasterTemplete/M02_MasterTemplete_ItemCards"); + // 차례 = 도면 · 일위대가 · 면적산출(M02 카드 한 벌) · 일위대가 [수정] = 넣을 값 편집 화면 cards = cardModule.createItemCards({ source: () => source, editable: () => canCopy, onEdit: (kind) => void edit(kind), warnOnFail: () => false, - extraCards: [inputs.root], + labor: () => void edit("inputs"), }); - // 차례 = 도면 · 일위대가 · 면적산출 — 일위대가 카드를 도면 카드 바로 뒤에 - cards.root.insertBefore(labor.root, cards.root.children[1] ?? null); cardsHost.replaceChildren(cards.root); } @@ -344,8 +326,6 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { status.hidden = true; cardsHost.replaceChildren(); cards = null; - inputs = null; - labor = null; return; } paintHead(row); @@ -362,14 +342,12 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { other = own && !info ? own : null; source = makeSource(row); await ensureCards(); - if (mine !== seq || !cards || !inputs || !labor) return; + if (mine !== seq || !cards) return; const label = !info && canCopy ? TEXT.importEdit : null; cards.relabel({ structure: label, area: label }); - inputs.setEditLabel(label ?? "넣을 값 수정"); cards.root.hidden = false; paintStatus(row, mine); void cards.load({ id: row.열, label: itemName(row) }); - void paintLabor(row, mine); })(); } diff --git a/ui_template/ui_template_item_picker_labor.ts b/ui_template/ui_template_item_picker_labor.ts deleted file mode 100644 index 2666dbebc..000000000 --- a/ui_template/ui_template_item_picker_labor.ts +++ /dev/null @@ -1,60 +0,0 @@ -/* ============================================================================= - * ui_template_item_picker_labor.ts - * 항목 고르기 모달 「일위대가」 카드(계획서 39-1c) — 담은 로직 수 · 자재 수 · 기준 합 · 반영 합 글. - * 보기만 · 고치기는 원본 층 일위대가 상세. 일위대가 보기 부품(39-3 C)이 오면 몸을 그것으로 바꿈. - * 자료 = `ui_template_item_picker_project.laborOf`(복사본이면 복사본 계산 · 아니면 원본 층 계산). - * 상세(`ui_template_item_picker_detail.ts`)가 카드를 받을 때 함께 `import()`. - * ========================================================================== */ - -import { el } from "./ui_template_elements"; -import { cardNote, cardShell } from "../M02_MasterTemplete/M02_MasterTemplete_CardShell"; -import type { LaborSummary } from "./ui_template_item_picker_project"; - -const TEXT = { - title: "일위대가", - none: "일위대가 없음", - failed: "일위대가를 못 읽음 — {value}", - logics: "단가산출 로직 {value}개", - materials: "자재 {value}개", - base: "기준 합 {value}원", - reflected: "반영 합 {value}원", - gaps: "빠진 입력 {value}개", - stuck: "멈춘 로직 {value}개", -}; - -const fill = (text: string, value: string | number): string => - text.replace("{value}", String(value)); -const won = (value: number | null): string => - value === null ? "—" : Math.round(value).toLocaleString(); - -export interface LaborCardHandle { - root: HTMLElement; - loading: () => () => void; - /** `null` = 일위대가 없음 · `note` 가 있으면 못 읽은 까닭 */ - set: (summary: LaborSummary | null, note?: string, retry?: () => void) => void; -} - -export function createLaborCard(): LaborCardHandle { - const number = el("span", { className: "m02-struct__no" }); - const body = el("div", { className: "m02-struct__sheet ui-item-picker__labor" }); - const shell = cardShell({ title: TEXT.title, sub: [number], body }); - const line = (text: string, className = ""): HTMLElement => - el("p", { className: `ui-item-picker__labor-line ${className}`.trim(), text }); - - return { - root: shell.root, - loading: () => shell.loading(), - set: (summary, note, retry) => { - number.textContent = summary?.번호 ?? ""; - if (note && retry) return shell.fail(fill(TEXT.failed, note), retry); - if (!summary) return void body.replaceChildren(cardNote(TEXT.none)); - body.replaceChildren( - line(`${fill(TEXT.logics, summary.로직.length)} · ${fill(TEXT.materials, summary.자재)}`), - line(fill(TEXT.base, won(summary.base))), - line(fill(TEXT.reflected, won(summary.reflected)), "is-strong"), - ...(summary.gaps ? [line(fill(TEXT.gaps, summary.gaps), "is-warn")] : []), - ...(summary.stuck ? [line(fill(TEXT.stuck, summary.stuck), "is-warn")] : []), - ); - }, - }; -} diff --git a/ui_template/ui_template_item_picker_project.ts b/ui_template/ui_template_item_picker_project.ts index a719abd88..d20a9786f 100644 --- a/ui_template/ui_template_item_picker_project.ts +++ b/ui_template/ui_template_item_picker_project.ts @@ -9,13 +9,15 @@ * GET|PUT /api/projects/{id}/items/{폴더}/{부분} → {종류, 판, 내용} · 고침은 {판, 내용}(판이 다르면 409) * POST …/{폴더}/reapply 재적용 → {폴더, 가져온날, 원본판, 덮임: [부분]} * POST …/{폴더}/save-as {to, 경로, 이름} 개인에 저장 → {key, 열, 이름, summary_item} - * POST …/{폴더}/calc 복사본만으로 계산(계 · 빠진 입력 · 기준합 · 반영합) - * 일위대가(39-1c) — 복사본 `일위대가` 부분 · 원본 층은 `calcLabor` · 넣을 값 목록은 `fetchLogicInputs` + * POST …/{폴더}/calc 복사본만으로 계산 — 층 셈 꼴(줄 · 자재 · 기준합 · 반영합) + 알약 · 빠진입력(142-1) + * 일위대가(39-1c · 142-1) — 모달 일위대가 카드(M02 카드 · 읽기 전용)가 `calcLabor` 로 셈 · 넣을 값 목록은 `fetchLogicInputs` * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import type { ItemInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; -import { calcLabor, fetchLogicInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api"; +import { fetchLogicInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api"; +import type { LaborDoc } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api"; +import type { LaborCalc } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Calc"; import type { ValueDoc } from "../M02_MasterTemplete/M02_Labor_Values_Model"; import { layerOf, ownerOf, type PickerRow } from "./ui_template_item_picker_data"; @@ -63,7 +65,10 @@ export interface CreatedItem { summary_item: { layer: string; owner: string | null; key: string; name: string }; } -/** 복사본 계산 결과에서 모달이 보이는 것 — 계 · 빠진 입력 수 · 멈춘 로직 수 · 일위대가 기준합 · 반영합 계 */ +/** 복사본 셈 답(142-1) — 층 셈 꼴 + 사본 알약 · 빈 입력 목록(셈은 자동값으로 돎) */ +export type CopyCalc = LaborCalc & { 알약: unknown[]; 빠진입력?: unknown[] }; + +/** 복사본 계산 결과에서 모달이 보이는 것 — 계 · 빈 입력 수 · 못 돈 줄 수 · 일위대가 기준합 · 반영합 계 */ export interface ProjectCalc { total: number; gaps: number; @@ -127,6 +132,8 @@ export interface ProjectItems { ) => Promise; /** 복사본만으로 계산 — 계산할 것이 없거나 못 하면 null */ calc: (folder: string) => Promise; + /** 복사본 셈 답 통째 — 모달 일위대가 카드(Labor_Detail)가 씀 · 못 하면 서버 까닭 그대로 던짐 */ + calcLabor: (folder: string) => Promise; } const clients = new Map(); @@ -142,6 +149,12 @@ export function projectItems(projectId: string): ProjectItems { for (const key of [...versions.keys()]) if (key.startsWith(`${folder}/`)) versions.delete(key); }; const at = (folder: string, part: string): string => `${base}/${enc(folder)}/${enc(part)}`; + /** 복사본 셈(142-1) — 못 하면 서버 까닭 그대로 던짐 */ + const calcCopy = async (folder: string): Promise => { + const reply = await call(`${base}/${enc(folder)}/calc`, post({})); + if (reply.status !== 200) throw new Error(reasonOf(reply)); + return reply.body as CopyCalc; + }; const client: ProjectItems = { list: async () => { @@ -213,78 +226,23 @@ export function projectItems(projectId: string): ProjectItems { return reply.body as CreatedItem; }, calc: async (folder) => { - const reply = await call(`${base}/${enc(folder)}/calc`, post({})); - if (reply.status !== 200) return null; - const got = reply.body as { - 계?: number; - 빠진입력?: unknown[]; - 멈춤?: unknown[]; - 기준합?: { 계?: number }; - 반영합?: { 계?: number }; - }; + const got = await calcCopy(folder).catch(() => null); + if (!got) return null; return { - total: Number(got.계 ?? 0), + total: Number(got.반영합?.계 ?? got.기준합?.계 ?? 0), gaps: got.빠진입력?.length ?? 0, - stuck: got.멈춤?.length ?? 0, + stuck: (got.줄 ?? []).filter((line) => line.까닭).length, base: got.기준합?.계 ?? null, reflected: got.반영합?.계 ?? null, }; }, + calcLabor: (folder) => calcCopy(folder), }; clients.set(projectId, client); return client; } -/* ── 일위대가(39-1c) — 카드 요약 · 넣을 값 자료 ─────────────────────────────── */ - -/** 일위대가 카드에 보이는 것 — 복사본이면 복사본 계산 · 아니면 원본 층 계산(저장 안 함) */ -export interface LaborSummary { - 번호: string | null; - /** 담은 로직 키(차례 그대로) — 넣을 값 목록을 받는 데도 씀 */ - 로직: string[]; - 자재: number; - base: number | null; - reflected: number | null; - gaps: number; - stuck: number; -} - -interface LaborCopy { - 줄?: { 일위대가번?: string; 담은로직?: { 로직?: string }[]; 자재?: unknown[] }; -} - -/** 그 항목의 일위대가 — 복사본(`folder`)이 있으면 그것 · 없으면 원본 층 열 · 일위대가가 없으면 null */ -export async function laborOf( - row: PickerRow, - items: ProjectItems | null, - folder: string | null, -): Promise { - if (items && folder) { - const copy = (await items.read(folder, "일위대가")) as LaborCopy | null; - if (!copy?.줄) return null; - const got = await items.calc(folder); - return { - 번호: copy.줄.일위대가번 ?? null, - 로직: (copy.줄.담은로직 ?? []).map((one) => String(one.로직 ?? "")).filter(Boolean), - 자재: copy.줄.자재?.length ?? 0, - base: got?.base ?? null, - reflected: got?.reflected ?? null, - gaps: got?.gaps ?? 0, - stuck: got?.stuck ?? 0, - }; - } - const got = await calcLabor(layerOf(row), row.열, null).catch(() => null); - if (!got) return null; - return { - 번호: null, - 로직: got.줄.map((line) => line.로직), - 자재: got.자재.length, - base: got.기준합?.계 ?? null, - reflected: got.반영합?.계 ?? null, - gaps: 0, - stuck: got.줄.filter((line) => line.까닭).length, - }; -} +/* ── 일위대가(39-1c) — 넣을 값 자료 ─────────────────────────────── */ /** 넣을 값 카드 자료 — 담은 로직 입력 전부 */ export async function laborValueData( @@ -295,3 +253,29 @@ export async function laborValueData( const list: ItemInputs = { 일위대가: null, 로직: got.로직, 빠진로직: got.빠진로직 }; return { list, values }; } + +/** 복사본 일위대가 문서에 복사본 입력값 · 고른자재를 줄 입력값 · 고름으로 얹음(보기용 · 142-1) — + * 빈 값은 안 얹음(= 자동값) · 셈은 서버가 같은 규칙으로(`M02_Item_Prices.with_values`) */ +export function withCopyValues( + doc: LaborDoc, + values: ValueDoc | null, + picks: Record | null, +): LaborDoc { + const lines = (doc.담은로직 ?? []).map((one) => { + const code = (one as { 로직?: string }).로직; + if (!code) return one; + const given = Object.fromEntries( + Object.entries(values?.[code] ?? {}).flatMap(([name, got]) => { + const v = (got as { 값?: unknown } | null)?.값; + return v === null || v === undefined || v === "" ? [] : [[name, v]]; + }), + ); + const pick = picks?.[code]; + return { + ...one, + 입력값: given, + ...(pick && typeof pick === "object" ? { 고름: pick } : {}), + } as typeof one; + }); + return { ...doc, 담은로직: lines }; +}