From 330421fe94aef90454c78ce9f6d718296ec8180a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 12:42:49 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(b05,b08):=20=EB=A9=94=EC=8C=93?= =?UTF-8?q?=EA=B8=B0=20=EB=86=92=EC=9D=B4=20=EB=91=90=20=EA=B8=B0=EC=A4=80?= =?UTF-8?q?=20=EC=82=AC=EC=9C=A0(=EA=B5=90=EB=B3=B8=207-3:27=20=C2=B7=20?= =?UTF-8?q?=ED=92=88=EC=85=88=20L7192=20=C2=B7=20=EC=B9=B8=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=A0=EB=A7=8C)=20=C2=B7=20=EC=84=B1=ED=86=A0/=EC=A0=88?= =?UTF-8?q?=ED=86=A0=20=EC=B9=B8=20face=5Frole=20=E2=80=94=20=EB=B9=84?= =?UTF-8?q?=EC=9A=B0=EB=A9=B4=20=EC=9E=90=EB=8F=99=20=ED=8C=90=EC=A0=95(B2?= =?UTF-8?q?=C2=B7B3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- B05_Profile/B05_Profile_Api_Structures.ts | 3 + B05_Profile/B05_Profile_Structure_Types.json | 34 ++++++++- B05_Profile/B05_Profile_Structures_Schema.py | 4 + .../B05_Profile_UI_Structures_Fields.ts | 14 ++++ .../B08_Quantity_Engine_StructureSheet.py | 4 +- .../B08_Quantity_Engine_StructureTemplate.py | 6 +- .../B08_Quantity_Engine_UnitQuantity.py | 4 +- ...08_Quantity_Engine_UnitQuantity_Masonry.py | 4 + ..._Quantity_Engine_UnitQuantity_StoneSpec.py | 23 ++++++ .../common_util_structure_face_role.py | 15 ++++ .../tester/test_b08_dry_height_face_role.py | 76 +++++++++++++++++++ 11 files changed, 178 insertions(+), 9 deletions(-) create mode 100644 resources/tester/test_b08_dry_height_face_role.py diff --git a/B05_Profile/B05_Profile_Api_Structures.ts b/B05_Profile/B05_Profile_Api_Structures.ts index a25738ca..a98038b2 100644 --- a/B05_Profile/B05_Profile_Api_Structures.ts +++ b/B05_Profile/B05_Profile_Api_Structures.ts @@ -35,6 +35,9 @@ export interface StructureOptionField { * ⚠ 이 칸의 select 는 **빈 보기를 둔다** — 첫 보기를 슬쩍 고르면 기준값과 다른 값이 * 조용히 저장된다(2026-09-13: `fill_concrete_mpa` 가 180 으로 박혀 엔진 기준 210 과 갈렸다). */ empty_means?: string | null; + /** 이 값을 넘으면 칸이 경고색 + 툴팁(막지 않음) — 기준값과 까닭 한 줄. */ + warn_above?: number | null; + warn_message?: string | null; } /** 구조물 배치 폼을 어느 화면이 쓰는가 — B05 는 유무·종류·위치만, **B06/B07 은 상세 diff --git a/B05_Profile/B05_Profile_Structure_Types.json b/B05_Profile/B05_Profile_Structure_Types.json index e1318d77..e81f21b3 100644 --- a/B05_Profile/B05_Profile_Structure_Types.json +++ b/B05_Profile/B05_Profile_Structure_Types.json @@ -921,6 +921,16 @@ "phase": "detail", "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" }, + { + "key": "face_role", + "label": "성토/절토", + "input": "select", + "choices": ["성토", "절토"], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 측점 단면유형 + 설치 측으로 자동 판정(계산이지 기본값이 아님) — 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191)가 성토/절토로 갈림 · 고르면 그 값이 이김" + }, { "key": "foundation", "label": "기초", @@ -990,7 +1000,9 @@ "default": 2.5, "required": false, "phase": "b05", - "empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)" + "empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)", + "warn_above": 2.0, + "warn_message": "메쌓기 높이 기준을 넘음 — 임도기술교본 7-3:27 「메쌓기는 2.0m 이하」 · 품셈 13-4-4 [주]⑪(원문 L7192) 「높이 3m 이상이면 전부 또는 하부를 찰쌓기」 · 막지 않음(설계자 확인)" }, { "key": "length_m", @@ -1095,6 +1107,16 @@ "phase": "detail", "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" }, + { + "key": "face_role", + "label": "성토/절토", + "input": "select", + "choices": ["성토", "절토"], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 측점 단면유형 + 설치 측으로 자동 판정(계산이지 기본값이 아님) — 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191)가 성토/절토로 갈림 · 고르면 그 값이 이김" + }, { "key": "foundation", "label": "기초", @@ -1995,6 +2017,16 @@ "phase": "detail", "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" }, + { + "key": "face_role", + "label": "성토/절토", + "input": "select", + "choices": ["성토", "절토"], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 측점 단면유형 + 설치 측으로 자동 판정(계산이지 기본값이 아님) — 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191)가 성토/절토로 갈림 · 고르면 그 값이 이김" + }, { "key": "foundation", "label": "기초", diff --git a/B05_Profile/B05_Profile_Structures_Schema.py b/B05_Profile/B05_Profile_Structures_Schema.py index c484602f..2cc9e302 100644 --- a/B05_Profile/B05_Profile_Structures_Schema.py +++ b/B05_Profile/B05_Profile_Structures_Schema.py @@ -60,6 +60,10 @@ class StructureOptionField(BaseModel): # 기본값이 **도메인 확정값이 아닐 때** 그 뜻을 적는다(예: 「다단 없음」·「안 더함」). # 법정·확정 수치면 비워 둔다 — 비어 있는 것이 「확정값」이라는 뜻이다. default_basis: str | None = None + # 이 값을 **넘으면** 칸이 경고색 + 툴팁 — 막지 않음(2026-09-14 브레인 판정 ①·㉰ 「놓기를 막지 말 것」). + # 넘는지 보는 기준값과 그 까닭 한 줄(원문 쪽·줄 번호). + warn_above: float | None = None + warn_message: str | None = None class StructureType(BaseModel): diff --git a/B05_Profile/B05_Profile_UI_Structures_Fields.ts b/B05_Profile/B05_Profile_UI_Structures_Fields.ts index cdfcd657..2dc90b6d 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Fields.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Fields.ts @@ -40,6 +40,9 @@ interface OptionShape { required?: boolean; /** 비워 두면 어떻게 되나 — 칸에 마우스를 올리면 보임. */ empty_means?: string | null; + /** 이 값을 넘으면 칸이 경고색 + 툴팁(막지 않음). */ + warn_above?: number | null; + warn_message?: string | null; } /** @@ -71,6 +74,17 @@ export function optionControl( element.value = value; element.placeholder = suggested ? `제안 ${suggested}` : option.required ? "필수 입력" : ""; if (option.empty_means) element.title = option.empty_means; + const limit = option.warn_above; + if (typeof limit === "number") { + // 기준을 넘으면 알리기만 — 막지 않음(2026-09-14 브레인 판정 ①·㉰). + const warn = (): void => { + const over = element.value.trim() !== "" && Number(element.value) > limit; + element.classList.toggle("is-outside-standard", over); + element.title = over ? (option.warn_message ?? "") : (option.empty_means ?? ""); + }; + element.addEventListener("input", warn); + warn(); + } return element; } diff --git a/B08_Quantity/B08_Quantity_Engine_StructureSheet.py b/B08_Quantity/B08_Quantity_Engine_StructureSheet.py index 33ede3c6..fe705ce2 100644 --- a/B08_Quantity/B08_Quantity_Engine_StructureSheet.py +++ b/B08_Quantity/B08_Quantity_Engine_StructureSheet.py @@ -212,7 +212,7 @@ def build_standard_sheets( ⚠ 계산을 다시 하지 않는다 — 들어온 전개를 제원 조합으로 묶고 단위당으로 나눌 뿐이다. """ from B08_Quantity.B08_Quantity_Engine_UnitQuantity import _section_mode_at - from common_util.common_util_structure_face_role import structure_face_role + from common_util.common_util_structure_face_role import structure_face_role_of groups: dict[str, dict[str, Any]] = {} for structure in unit_table.get("structures") or []: @@ -220,7 +220,7 @@ def build_standard_sheets( # 근거를 못 받아 종전값으로 떨어져 **표와 갈린다**(2026-09-09 실측: 표 1:0.35 · # 제목 1:0.3). 자리를 고르는 것도 B08 이 쓰는 그 함수를 쓴다. mode = _section_mode_at(structure, section_modes) - face, face_reason = structure_face_role(mode, (structure.get("options") or {}).get("side")) + face, face_reason = structure_face_role_of(mode, structure.get("options")) structure = {**structure, "face": face, "face_reason": face_reason} key = sheet_key(structure) sheet = groups.get(key) diff --git a/B08_Quantity/B08_Quantity_Engine_StructureTemplate.py b/B08_Quantity/B08_Quantity_Engine_StructureTemplate.py index f378c12b..1a3e183b 100644 --- a/B08_Quantity/B08_Quantity_Engine_StructureTemplate.py +++ b/B08_Quantity/B08_Quantity_Engine_StructureTemplate.py @@ -227,7 +227,7 @@ def replace_with_templates( from B08_Quantity.B08_Quantity_Engine_Formula import evaluate_sheets from B08_Quantity.B08_Quantity_Engine_StructureSheet import slope_of from B08_Quantity.B08_Quantity_Engine_UnitQuantity import Component, _section_mode_at - from common_util.common_util_structure_face_role import structure_face_role + from common_util.common_util_structure_face_role import structure_face_role_of settings = {"rubble_base_thickness_m": rubble_base_thickness_m} targets: list[tuple[Any, dict[str, Any]]] = [] @@ -239,9 +239,7 @@ def replace_with_templates( if (quantity.billing_unit or "m") not in _PER_LENGTH_UNITS: continue options = item.get("options") or {} - face, face_reason = structure_face_role( - _section_mode_at(item, section_modes), options.get("side") - ) + face, face_reason = structure_face_role_of(_section_mode_at(item, section_modes), options) sheet = { "type_id": quantity.type_id, "height_m": quantity.height_m, diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py index 69c2fc15..fda877bf 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -80,7 +80,7 @@ from B08_Quantity.B08_Quantity_Engine_UnitQuantity_StoneSpec import ( # noqa: F weep_hole_spec, ) from common_util.common_util_quantity_spread import spread_by_unit -from common_util.common_util_structure_face_role import structure_face_role +from common_util.common_util_structure_face_role import structure_face_role_of # 구조물 종류 → 전개식. 없는 종류는 전개하지 않고 이름만 남긴다(지어내지 않는다). # ⚠ **관측 원단위표로 가는 종류** — 치수가 저장돼 있지 않아 전개식을 못 세우는 것들이다. @@ -319,7 +319,7 @@ def expand( ) return result # 성토/절토 — **판정 한 벌**을 부른다(우리가 따로 짜지 않는다). - face, face_reason = structure_face_role(section_mode, options.get("side")) + face, face_reason = structure_face_role_of(section_mode, options) result.components, notes = expander(height, length, options, face, face_reason) result.notes.extend(notes) billing = EXPANDER_BILLING.get(type_id) diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Masonry.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Masonry.py index 66089043..83157d30 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Masonry.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Masonry.py @@ -21,6 +21,7 @@ from B08_Quantity.B08_Quantity_Engine_UnitQuantity_StoneSpec import ( STONE_PILE_BASIS, _back_length, back_length_default_note, + dry_masonry_height_note, face_slope_ratio, fill_concrete_mpa, STONE_SUPPLY_DEFAULT_NOTE, @@ -270,6 +271,9 @@ def stone_masonry( default_note = back_length_default_note(options, back_cm, wet=wet, height_m=height_m) if default_note: notes.append(default_note) + height_note = None if wet else dry_masonry_height_note(height_m) + if height_note: + notes.append(height_note) # ⚠ **돌 종류로 계수가 갈린다** (품셈 13-4-3 · 13-4-4 [주]① · 교본 7-3). # 안 고르면 종전 값(건설품셈 참고자료)으로 돌되 그 사실을 알린다 — 값을 못 낸다고 # 멈추면 이미 저장된 프로젝트가 통째로 빈다. diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_StoneSpec.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_StoneSpec.py index 58e203d3..e22b0719 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_StoneSpec.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_StoneSpec.py @@ -444,6 +444,29 @@ def wants_blinding(options: dict[str, Any]) -> bool: BACK_LENGTH_KEYS = ("back_len_cm", "stone_back_length_cm") +#: 메쌓기 높이 — **두 기준이 공존**(2026-09-14 브레인 판정 ㉰ · 지식DB 대조 정합): +#: 임도기술교본 7-3:27 「돌흙막이 높이는 원칙적으로 찰쌓기는 3.0m 이하, 메쌓기는 2.0m 이하」 ↔ +#: 품셈 13-4-4 [주]⑪ 원문 L7192 「돌쌓기는 높이 3m 이상이면 전부 또는 하부를 찰쌓기로 한다」. +#: 막지 않고 사유만(판정 ① 「놓기를 막지 말 것」과 한 결). +DRY_MASONRY_GUIDE_M = 2.0 +DRY_MASONRY_LIMIT_M = 3.0 + + +def dry_masonry_height_note(height_m: float) -> str | None: + """메쌓기 높이가 기준을 넘으면 사유 한 줄 — 두 기준을 다 적음.""" + if height_m >= DRY_MASONRY_LIMIT_M: + return ( + f"⚠ 메쌓기 높이 {height_m:g}m — 품셈 13-4-4 [주]⑪(원문 L7192) 「돌쌓기는 높이 3m 이상이면" + " 전부 또는 하부를 찰쌓기로 한다」 · 임도기술교본 7-3:27 은 메쌓기 2.0m 이하 — 막지 않음(설계자 확인)" + ) + if height_m > DRY_MASONRY_GUIDE_M: + return ( + f"⚠ 메쌓기 높이 {height_m:g}m — 임도기술교본 7-3:27 「메쌓기는 2.0m 이하」를 넘음" + " · 품셈 13-4-4 [주]⑪(원문 L7192)은 3m 이상부터 찰쌓기 — 두 기준이 다름 · 막지 않음" + ) + return None + + #: 뒷길이 표준표 — 품셈 13-4-4 [주]⑩. 안 고른 돌쌓기는 붙박이 45 가 아니라 이 표의 하한 #: (2026-09-14 브레인 판정 ㉮ 「범위 안에 드는 규격 중 가장 작은 것」). 파일이 없으면 종전 45. BACK_LENGTH_PREFIX = "masonry_back_length_" diff --git a/common_util/common_util_structure_face_role.py b/common_util/common_util_structure_face_role.py index 85422a1e..af614785 100644 --- a/common_util/common_util_structure_face_role.py +++ b/common_util/common_util_structure_face_role.py @@ -59,3 +59,18 @@ def structure_face_role(section_mode: str | None, side: str | None) -> tuple[str return None, f"{mode} · 자동(성토 쪽)인데 성토면이 없음 — 가를 근거 없음" return FILL, f"{mode} · 자동(성토 쪽) → 성토면" return None, f"설치 측을 알 수 없음({side!r}) — 성절토를 가를 근거 없음" + + +#: 성토/절토를 **사용자가 고르는 칸** — 비우면 위 판정(계산이지 기본값이 아님 · 2026-09-14 브레인 판정 ㉲). +FACE_ROLE_KEY = "face_role" + + +def structure_face_role_of( + section_mode: str | None, options: dict | None +) -> tuple[str | None, str]: + """칸 `face_role`(성토·절토)을 골랐으면 그 값, 비었으면 단면유형 + 설치 측 판정.""" + values = options or {} + picked = str(values.get(FACE_ROLE_KEY) or "").strip() + if picked in (FILL, CUT): + return picked, f"사용자 지정 {picked}면(성토/절토 칸)" + return structure_face_role(section_mode, values.get("side")) diff --git a/resources/tester/test_b08_dry_height_face_role.py b/resources/tester/test_b08_dry_height_face_role.py new file mode 100644 index 00000000..465478e1 --- /dev/null +++ b/resources/tester/test_b08_dry_height_face_role.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +"""메쌓기 높이 사유(B2) · 성토/절토 칸(B3) — 2026-09-14 브레인 판정 ㉰·㉲. + +㉰ 두 기준 공존 — 임도기술교본 7-3:27 「메쌓기 2.0m 이하」 ↔ 품셈 13-4-4 [주]⑪ 원문 L7192 + 「3m 이상이면 전부 또는 하부를 찰쌓기」. 막지 않고 사유(B08 줄) · 칸 경고(B05, `warn_above`). +㉲ `face_role` — 비우면 단면유형 + 설치 측 자동 판정(계산이지 기본값이 아님) · 고르면 그 값이 이김. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import expand, stone_masonry # noqa: E402 +from common_util.common_util_structure_face_role import structure_face_role_of # noqa: E402 + +REGISTRY = json.loads( + (ROOT / "B05_Profile" / "B05_Profile_Structure_Types.json").read_text(encoding="utf-8") +) +FIELDS = (ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Fields.ts").read_text(encoding="utf-8") + + +def _options(type_id: str) -> dict[str, dict]: + item = next(t for t in REGISTRY["types"] if t["type_id"] == type_id) + return {o["key"]: o for o in item["options"]} + + +def _height_notes(height: float, wet: bool) -> list[str]: + _rows, notes = stone_masonry(height, 10.0, {"back_len_cm": 45}, wet) + return [n for n in notes if "메쌓기 높이" in n] + + +def test_메쌓기_높이_두_기준_사유() -> None: + assert _height_notes(2.0, False) == [] + over_guide = _height_notes(2.5, False) + assert len(over_guide) == 1 and "7-3:27" in over_guide[0] and "L7192" in over_guide[0] + over_limit = _height_notes(3.0, False) + assert len(over_limit) == 1 and "3m 이상이면" in over_limit[0] + assert _height_notes(3.5, True) == [] # 찰쌓기는 이 사유 대상 아님 + + +def test_등록부_메쌓기_높이_칸은_경고만() -> None: + height = _options("masonry_dry")["height_m"] + assert height["warn_above"] == 2.0 and "L7192" in height["warn_message"] + assert "is-outside-standard" in FIELDS and "warn_above" in FIELDS + + +def test_성토절토_칸이_이긴다() -> None: + assert structure_face_role_of("both_fill", {"face_role": "절토"}) == ( + "절토", + "사용자 지정 절토면(성토/절토 칸)", + ) + # 비우면 지금 판정 그대로 + assert structure_face_role_of("both_fill", {})[0] == "성토" + for type_id in ("masonry_wet", "masonry_dry", "revetment"): + role = _options(type_id)["face_role"] + assert role["choices"] == ["성토", "절토"] and role["default"] is None, type_id + + +def test_전개가_칸_값으로_기울기를_고른다() -> None: + structure = { + "structure_id": "w", + "type_id": "masonry_wet", + "start_m": 0.0, + "end_m": 10.0, + "options": {"height_m": 2.5, "length_m": 10.0, "back_len_cm": 45, "face_role": "절토"}, + } + result = expand(structure, None, None, "both_fill") + wall = next(c for c in result.components if c.name == "돌쌓기") + # 찰쌓기 · 절토 · 직고 ≤3m → 1:0.25 (성토였으면 1:0.30) + assert "절토" in wall.basis and "1:0.25" in wall.basis From ad63dd8f9949cfe09aaa4143b2af0f57216c4933 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 12:49:08 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(b05,b06,b08):=20=EB=8F=85=EB=A6=BD=20?= =?UTF-8?q?=EA=B8=B0=EC=8A=AD=EB=A7=89=EC=9D=B4=20=EC=B9=98=EC=88=98=20?= =?UTF-8?q?=ED=95=9C=20=EB=B2=8C(revetment=5Fsabang)=20=E2=80=94=20?= =?UTF-8?q?=EB=86=92=EC=9D=B4=20=EC=84=A4=EA=B3=84=EC=9E=90=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=C2=B7=EC=97=86=EC=9C=BC=EB=A9=B4=20=EB=AF=B8=ED=99=95?= =?UTF-8?q?=EC=A0=95=20=C2=B7=20B05=20=EC=B9=B8=20=EB=B0=91=20=EA=B7=BC?= =?UTF-8?q?=EA=B1=B0=EC=99=80=20B08=20=EC=82=AC=EC=9C=A0=EA=B0=80=20?= =?UTF-8?q?=EA=B0=99=EC=9D=80=20=ED=8C=8C=EC=9D=BC(=EC=82=AC=EB=B0=A9?= =?UTF-8?q?=EA=B5=90=EB=B3=B8=20=EC=AA=BD=C2=B7=EC=A4=84=20=EB=B2=88?= =?UTF-8?q?=ED=98=B8)=20=C2=B7=20=EB=93=B1=EB=A1=9D=EB=B6=80=20=EB=86=92?= =?UTF-8?q?=EC=9D=B4=202.5=20=EA=B1=B7=EC=9D=8C(B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- B05_Profile/B05_Profile_Structure_Types.json | 5 +- ...B05_Profile_UI_Drainage_Facility_Fields.ts | 38 +++++++++-- .../B06_Section_UI_Cross_Card_Chrome.ts | 7 +++ B08_Quantity/B08_Quantity_Engine_Pipe.py | 63 +++++++++++++++++-- .../revetment_sabang_2026-01-01.json | 44 +++++++++++++ .../tester/test_b05_revetment_sabang_basis.py | 57 +++++++++++++++++ .../tester/test_b05_structures_migration.py | 3 +- .../test_b05_structures_registry_policy.py | 4 +- .../tester/test_b05_structures_router.py | 3 +- .../tester/test_b08_facility_structures.py | 13 +++- 10 files changed, 223 insertions(+), 14 deletions(-) create mode 100644 resources/data_masonry/revetment_sabang_2026-01-01.json create mode 100644 resources/tester/test_b05_revetment_sabang_basis.py diff --git a/B05_Profile/B05_Profile_Structure_Types.json b/B05_Profile/B05_Profile_Structure_Types.json index e81f21b3..782915b8 100644 --- a/B05_Profile/B05_Profile_Structure_Types.json +++ b/B05_Profile/B05_Profile_Structure_Types.json @@ -2041,9 +2041,10 @@ "label": "높이", "input": "number", "unit": "m", - "default": 2.5, + "default": null, "required": false, - "phase": "b05" + "phase": "b05", + "empty_means": "설계자 입력 — 계획홍수위 + 0.5~0.7m(사방기술교본 2-나:141 · 3-가:181 · 사방(계류) 교본 기준). 비우면 줄만 서고 미확정이라 금액에 안 들어감 · 옛 기본 2.5 는 근거 없어 걷음(2026-09-14 브레인 판정)" }, { "key": "length_m", diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts index db569500..da745045 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts @@ -9,6 +9,7 @@ * ========================================================================== */ import { FORD_MANNING_N, FORD_SLOPE } from "@config/config_frontend"; +import revetmentSabang from "../resources/data_masonry/revetment_sabang_2026-01-01.json"; /* ── 유입구 구조 ──────────────────────────────────────────────────────────── */ @@ -252,16 +253,43 @@ export interface RevetSideGroup { slot: HTMLElement; } +/** 독립 기슭막이 치수 근거 줄 — `revetment_sabang_*.json` 한 벌(B08 사유와 같은 파일 · 쪽·줄 번호). + * ⚠ 파일 판이 바뀌면 이 import 도 옮길 것 — 시험(`test_b05_revetment_sabang_basis`)이 최신 판을 대조. */ +function sabangBasisLines(): string[] { + const { items, scope } = revetmentSabang; + const span = (values: number[], unit = ""): string => `${values[0]}~${values[1]}${unit}`; + return [ + `높이 — ${items.height.rule}(${items.height.source}) · ${items.height.input}`, + `계획비탈 1:${span(items.face_slope.standard_ratio)}(${items.face_slope.source}) — 제안값은 품셈 표준경사`, + `둑마루 두께 ${span(items.crown_thickness.standard_m, "m")}(${items.crown_thickness.source}) — 콘크리트 기준 · 돌쌓기는 뒷길이 + 0.30 식`, + `뒷채움 조약돌 두께 — ${items.backfill_pebble.source}`, + scope, + ]; +} + export function createRevetSideGroup(keys: RevetmentKeys, formDefault: string): RevetSideGroup { const box = group("기슭막이"); const kind = document.createElement("select"); kind.replaceChildren(new Option("기슭막이", "기슭막이")); - const fields = createRevetmentFields(keys, { ...REVET_COMMON_DEFAULTS, form: formDefault }); + // 높이는 제안 없음 — 설계자 입력(계획홍수위 + 여유고 · 2026-09-14 브레인 판정). 옛 2.5 는 근거 없어 걷음. + const fields = createRevetmentFields(keys, { + ...REVET_COMMON_DEFAULTS, + form: formDefault, + height: "", + }); + const basis = document.createElement("p"); + basis.className = "b05-route__note"; + basis.replaceChildren( + ...sabangBasisLines().flatMap((line, index) => + index ? [document.createElement("br"), line] : [line], + ), + ); const slot = document.createElement("div"); slot.className = "b05-structure__adjust-slot"; box.body.append( grid(labeled("구조", kind), fields.formField), ...fields.rows, + basis, // 단 수는 여기 두지 않는다 — 조정창에서 옮겨 오는 [추가 기슭막이(단)] 행과 // 같은 값이라 두 벌이면 헷갈린다(2026-08-30 사용자). slot, @@ -301,9 +329,11 @@ export function createRevetmentFields( ...REVET_FORMS.map((value) => new Option(value, value)), ); const length = keys.length ? numberInput("0.1", "0", `제안 ${defaults.length}`) : null; - const height = numberInput("0.1", "0", `제안 ${suggestHeight()}`); + // 제안이 없는 칸(독립 기슭막이 높이)은 「설계자 입력」 — 값을 지어내지 않음. + const heightHint = (): string => (suggestHeight() ? `제안 ${suggestHeight()}` : "설계자 입력"); + const height = numberInput("0.1", "0", heightHint()); // 관 벽 높이 제안은 관경을 따라감 — 칸을 볼 때마다 다시 셈. - height.addEventListener("focus", () => (height.placeholder = `제안 ${suggestHeight()}`)); + height.addEventListener("focus", () => (height.placeholder = heightHint())); // 기준측점 전/후(2026-08-23 사용자) — 구조물 패널(D군)과 같은 연동: // 전 + 후 = 길이, 나중에 고친 쪽이 살아남고 반대쪽이 재배분된다. const before = keys.before && length ? numberInput("0.1", "0", `제안 ${defaults.before}`) : null; @@ -363,7 +393,7 @@ export function createRevetmentFields( form.value = text(keys.form); if (length && keys.length) length.value = text(keys.length); height.value = text(keys.height); - height.placeholder = `제안 ${suggestHeight()}`; + height.placeholder = heightHint(); if (before && keys.before) before.value = text(keys.before); if (after && keys.after) after.value = text(keys.after); syncSplit("length"); diff --git a/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts b/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts index 5695a5c9..1d4fa376 100644 --- a/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts +++ b/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts @@ -67,6 +67,13 @@ function facilityNotes(section: CrossSection): Array<[string, string]> { `B05 시설 칸에 안 적어 기본값으로 그림 — ${items.join(" · ")} · 적으면 그 값으로 그림`, ]); } + const own = section.culvert?.hidden_pipe ? section.culvert : null; + if (own && [own.inlet, own.outlet].some((side) => !side.revet_height_m)) { + notes.push([ + "⚠ 기슭막이 높이 없음 — 근입만 그림", + "독립 기슭막이 높이는 설계자 입력 — 계획홍수위 + 0.5~0.7m(사방기술교본 2-나:141 · 3-가:181) · 비우면 수량도 미확정 · B05 시설 칸에 적으면 그림", + ]); + } if (section.ford_pavement && !section.ford_pavement.depth_m) { notes.push([ "⚠ 월류 높이 없음 — 파임 안 그림", diff --git a/B08_Quantity/B08_Quantity_Engine_Pipe.py b/B08_Quantity/B08_Quantity_Engine_Pipe.py index ddbde4aa..f9f8e95a 100644 --- a/B08_Quantity/B08_Quantity_Engine_Pipe.py +++ b/B08_Quantity/B08_Quantity_Engine_Pipe.py @@ -26,6 +26,9 @@ from __future__ import annotations +import json +from functools import lru_cache +from pathlib import Path from typing import Any #: 배관으로 보는 `facility` 값. 그 밖(BOX암거·물넘이·세월교·독립 기슭막이)은 관이 아니다. @@ -214,9 +217,52 @@ REVET_ROLES = ( NOTE_DEFAULT_WALL = ( "⚠ 시설 지점에 {filled} 을 안 적어 기본값으로 섰음 — 미확정이라 금액에 안 들어감" ) -#: 꼬리 — 관 벽은 횡단도와 같은 높이 · 독립 기슭막이는 아직 갈림(관경이 없음). +#: 꼬리 — 관 벽은 횡단도와 같은 높이. NOTE_WALL_HEIGHT_PIPE = " · 높이는 횡단도와 같은 관경 기준 최소 높이(실제 설계값은 더 클 수 있음)" -NOTE_WALL_HEIGHT_OWN = " · ⚠ 횡단도는 저장 높이가 없으면 근입 0.5m 만 그림 — 표 높이와 다름" + +#: 독립 기슭막이 치수 **한 벌**(`resources/data_masonry/revetment_sabang_*.json`) — B05 칸 밑 근거와 +#: 이 사유가 같은 파일을 읽음(2026-09-14 브레인 판정). 높이는 **설계자 입력** — 등록부 기본 2.5 걷음. +REVET_SABANG_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_masonry" +REVET_SABANG_PREFIX = "revetment_sabang_" +UNCONFIRMED_OWN_HEIGHT = "높이를 안 적음 — {rule}({source}) 설계자 입력 · 적으면 금액이 섬" + + +@lru_cache(maxsize=1) +def load_revetment_sabang() -> dict[str, Any]: + """독립 기슭막이 치수 한 벌. 없으면 빈 표 — 사유가 근거 없이 짧아질 뿐 값은 안 바뀜.""" + files = sorted(REVET_SABANG_DIR.glob(REVET_SABANG_PREFIX + "*.json")) + return json.loads(files[-1].read_text(encoding="utf-8")) if files else {} + + +def _span(values: list[float], unit: str = "") -> str: + return f"{values[0]:g}~{values[1]:g}{unit}" + + +def own_revetment_basis() -> str: + """독립 기슭막이 근거 한 줄 — 높이·계획비탈·둑마루·뒷채움, 쪽·줄 번호 그대로.""" + data = load_revetment_sabang() + items = data.get("items") or {} + if not items: + return "" + height, slope = items["height"], items["face_slope"] + crown, pebble = items["crown_thickness"], items["backfill_pebble"] + return ( + f"ⓘ 독립 기슭막이 치수 근거 — 높이 {height['rule']}({height['source']})" + f" · 계획비탈 1:{_span(slope['standard_ratio'])}({slope['source']})" + f" · 둑마루 두께 {_span(crown['standard_m'], 'm')}({crown['source']} · 콘크리트 기준)" + f" · 뒷채움 조약돌 두께 {pebble['source']} · {data.get('scope', '')}" + ) + + +def own_height_unconfirmed() -> str: + """높이를 안 적은 독립 기슭막이의 미확정 까닭.""" + height = (load_revetment_sabang().get("items") or {}).get("height") or {} + return UNCONFIRMED_OWN_HEIGHT.format( + rule=height.get("rule", "계획홍수위 + 0.5~0.7m"), + source=height.get("source", "사방기술교본"), + ) + + #: 금액에서 뺄 까닭(2026-09-14 브레인 판정) — 줄·표·그림은 서되 내역 합에는 안 듦. UNCONFIRMED_WALL = "벽 치수({filled})가 시설 지점에 없어 기본값으로 섰음 — 적으면 금액이 섬" NOTE_SIDE_UNKNOWN = ( @@ -330,8 +376,13 @@ def facility_structures(points: list[dict[str, Any]]) -> list[dict[str, Any]]: values, filled = _revet_values(options, role, wall_defaults, legacy, min_height) notes = list(inlet_notes) if role == "inlet" else [] if filled: - tail = NOTE_WALL_HEIGHT_OWN if legacy else NOTE_WALL_HEIGHT_PIPE + tail = "" if legacy else NOTE_WALL_HEIGHT_PIPE notes.append(NOTE_DEFAULT_WALL.format(filled=" · ".join(filled)) + tail) + if legacy: + # 독립 기슭막이 — 치수 한 벌 근거(사방 교본 기준 · 쪽·줄 번호)를 늘 붙임. + basis = own_revetment_basis() + if basis: + notes.append(basis) walls.append([role, own_label if legacy else pipe_label, values, notes, False, filled]) side = str(options.get("side") or "") if legacy else "" if side in ("좌", "우"): @@ -345,13 +396,17 @@ def facility_structures(points: list[dict[str, Any]]) -> list[dict[str, Any]]: for role, label, values, notes, withheld, filled in walls: before, after = _num_or_zero(values["before_m"]), _num_or_zero(values["after_m"]) row = child_row(base, "revetment", label, f"{role}_revet") + reasons = [UNCONFIRMED_WALL.format(filled=" · ".join(filled))] if filled else [] + if legacy and _blank(values["height_m"]): + # 높이는 설계자 입력(계획홍수위 + 여유고) — 없으면 줄만 서고 금액 밖. + reasons.append(own_height_unconfirmed()) row.update( start_m=chainage - before, end_m=chainage + after, options={**(kept if legacy else {}), **values, "foundation": foundation}, notes=notes, withheld=withheld, - unconfirmed=UNCONFIRMED_WALL.format(filled=" · ".join(filled)) if filled else "", + unconfirmed=" · ".join(reasons), ) rows.append(row) return rows diff --git a/resources/data_masonry/revetment_sabang_2026-01-01.json b/resources/data_masonry/revetment_sabang_2026-01-01.json new file mode 100644 index 00000000..b992c956 --- /dev/null +++ b/resources/data_masonry/revetment_sabang_2026-01-01.json @@ -0,0 +1,44 @@ +{ + "schema_version": "1.0", + "dataset_id": "revetment_sabang", + "effective_date": "2026-01-01", + "note": "독립 기슭막이 치수 **한 벌** — 높이 · 계획비탈 · 둑마루 두께 · 뒷채움 조약돌 두께를 한 자리에 둠(2026-09-14 브레인 판정). B05 칸 밑 근거와 B08 사유가 이 파일 하나를 같이 읽음.", + "scope": "⚠ 사방(계류) 교본 기준 — 임도 배수관 유입·유출부 적용은 확인 남음", + "rank": "품셈(산림청고시 제2025-82호)은 행정규칙 · 사방기술교본은 참고자료 — 제안값은 품셈 표, 교본은 근거로 병기(브레인 판정 ㉯ · 운영지침 「프로그램 기본값 = 현행 법령·행정규칙」)", + "items": { + "height": { + "label": "높이", + "rule": "계획홍수위 + 0.5~0.7m", + "freeboard_m": [0.5, 0.7], + "input": "설계자 입력 · 비우면 미확정(금액에 안 들어감)", + "source": "사방기술교본 2-나:141 · 3-가:181", + "quote": "기슭막이 높이는 계획홍수위 이상으로 하여 홍수시에도 넘치지 않도록 시공한다, 일반적으로 계획홍수위 보다 0.5~0.7m 높게 하며, 계류의 만곡부에서는 凹안이 凸안에 비해 수위가 높아지므로 더 높게 시공한다" + }, + "face_slope": { + "label": "계획비탈", + "standard_ratio": [0.3, 0.5], + "extended_ratio": [1.0, 1.5], + "source": "사방기술교본 2-나:129 · 3-가:151", + "quote": "기슭막이의 계획비탈은 1:0.3∼0.5를 표준으로 하고, 사용 재료와 현장 연건에 따라 1:1.0∼1.5 정도로 계획할 수 있다", + "applied": "제안값은 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191) — 이 범위는 근거로만 보임 · 표 값이 범위 밖이면 사유만(교본을 이기려 들지 않음)" + }, + "crown_thickness": { + "label": "둑마루 두께", + "standard_m": [0.3, 0.5], + "source": "사방기술교본 2-나:143 · 3-가:201 · 3-가:219(콘크리트기슭막이) · 2-나:135(콘크리트 0.3m)", + "quote": "기슭막이 둑마루 두께는 0.3∼0.5m를 표준으로 하고, 둑마루 기울기는 원칙적으로 계획계상기울기와 일치하도록 시공한다", + "applied": "⚠ 콘크리트 기슭막이 기준 — 돌쌓기 상부 두께는 식(뒷길이 + 0.30)으로 따로 셈 · 식을 안 바꾸고 근거로만(브레인 판정 ㉱)" + }, + "backfill_pebble": { + "label": "뒷채움 조약돌 두께", + "source": "사방기술교본 2-나:133(「사방품셈 규정에 의하여」) → 품셈 13-4-4 [주]⑨ 원문 L7164~7171", + "steps_m": [1.5, 3.0, 5.0, 7.0], + "fill_cm": { + "top": [20, 40], + "bottom": [[30, 60], [45, 75], [60, 100], [80, 140]] + }, + "cut_cm": [30, 40], + "applied": "막자갈은 정본 뒷채움 폭(상 0.30 · 하 0.45)으로 셈 — 이 표는 근거로만 보임(`STONE_BACKFILL_WIDTH_M` 주석의 갈림)" + } + } +} diff --git a/resources/tester/test_b05_revetment_sabang_basis.py b/resources/tester/test_b05_revetment_sabang_basis.py new file mode 100644 index 00000000..da087535 --- /dev/null +++ b/resources/tester/test_b05_revetment_sabang_basis.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +"""독립 기슭막이 치수 한 벌 — B05 칸 밑 근거와 B08 사유가 **같은 파일**을 읽음 (2026-09-14 브레인 판정). + +한 자리 = `resources/data_masonry/revetment_sabang_*.json`. TS 는 판 이름을 import 로 박으므로 판이 +바뀌면 import 도 옮겨야 함 — 여기서 최신 판과 대조. 쪽·줄 번호가 사유에 그대로 실리는지도 봄. +""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_Pipe import ( # noqa: E402 + own_height_unconfirmed, + own_revetment_basis, +) + +DATA_DIR = ROOT / "resources" / "data_masonry" +FIELDS = (ROOT / "B05_Profile" / "B05_Profile_UI_Drainage_Facility_Fields.ts").read_text( + encoding="utf-8" +) +CHROME = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Card_Chrome.ts").read_text(encoding="utf-8") + + +def test_TS_가_최신_판을_읽는다() -> None: + latest = sorted(DATA_DIR.glob("revetment_sabang_*.json"))[-1].name + imported = re.search(r'from "\.\./resources/data_masonry/(revetment_sabang_[^"]+)"', FIELDS) + assert imported and imported.group(1) == latest + + +def test_TS_가_쓰는_칸이_파일에_있다() -> None: + latest = sorted(DATA_DIR.glob("revetment_sabang_*.json"))[-1] + data = json.loads(latest.read_text(encoding="utf-8")) + items = data["items"] + assert data["scope"] and "사방(계류) 교본 기준" in data["scope"] + assert items["height"]["rule"] == "계획홍수위 + 0.5~0.7m" + assert items["face_slope"]["standard_ratio"] == [0.3, 0.5] + assert items["crown_thickness"]["standard_m"] == [0.3, 0.5] + assert "L7164" in items["backfill_pebble"]["source"] + + +def test_B08_사유가_같은_근거를_쪽_줄_번호로() -> None: + basis = own_revetment_basis() + for ref in ("2-나:141", "3-가:181", "2-나:129", "3-가:151", "2-나:143", "L7164~7171"): + assert ref in basis, ref + assert "2-나:141" in own_height_unconfirmed() + + +def test_B05_높이_칸은_제안_없이_설계자_입력() -> None: + assert 'height: "",' in FIELDS and '"설계자 입력"' in FIELDS + assert "기슭막이 높이 없음 — 근입만 그림" in CHROME diff --git a/resources/tester/test_b05_structures_migration.py b/resources/tester/test_b05_structures_migration.py index c5f3bc49..a99c28ec 100644 --- a/resources/tester/test_b05_structures_migration.py +++ b/resources/tester/test_b05_structures_migration.py @@ -68,7 +68,8 @@ def test_client_format_gisungmagi_maps_to_revetment(): # 기준측점 전/후 5m — 레지스트리 기본값. assert (item.start_m, item.end_m) == (115.0, 125.0) assert item.options["form"] == "돌쌓기(메)" - assert item.options["height_m"] == 2.5 and item.options["length_m"] == 10 + # 높이는 설계자 입력 — 등록부 기본 2.5 걷음(2026-09-14 브레인 판정 · 사방기술교본 2-나:141) + assert "height_m" not in item.options and item.options["length_m"] == 10 def test_client_format_escape_route_width_field_wins(): diff --git a/resources/tester/test_b05_structures_registry_policy.py b/resources/tester/test_b05_structures_registry_policy.py index 7b5ff97e..e7d154db 100644 --- a/resources/tester/test_b05_structures_registry_policy.py +++ b/resources/tester/test_b05_structures_registry_policy.py @@ -485,7 +485,9 @@ def test_revetment_range_options_like_slope_group(): pipe_options = {option.key: option for option in by_id["pipe"].options} assert item.placement == "point" and item.managed_by == "pipe_points" - expected = {"height_m": 2.5, "length_m": 10, "before_m": 5, "after_m": 5} + # ⚠ 높이는 2026-09-14 부터 **설계자 입력**(기본 없음) — 계획홍수위 + 0.5~0.7m(사방기술교본 + # 2-나:141 · 3-가:181) · 옛 2.5 는 근거 없어 걷음(브레인 판정). + expected = {"height_m": None, "length_m": 10, "before_m": 5, "after_m": 5} for key, default in expected.items(): assert key in options, f"revetment.{key} 옵션 없음" assert options[key].phase == "b05" and not options[key].required, key diff --git a/resources/tester/test_b05_structures_router.py b/resources/tester/test_b05_structures_router.py index ab0d2ecb..cd843de0 100644 --- a/resources/tester/test_b05_structures_router.py +++ b/resources/tester/test_b05_structures_router.py @@ -247,7 +247,8 @@ def test_migrate_converts_legacy_stations(client, tmp_path): assert pipe["facility"] == "revetment" and pipe["chainage_m"] == 120.0 # 구간·제원은 레지스트리 기본값 승계 — 기준측점 전/후 5m. assert (pipe["start_m"], pipe["end_m"]) == (115.0, 125.0) - assert pipe["options"]["form"] == "돌쌓기(메)" and pipe["options"]["height_m"] == 2.5 + # 높이는 설계자 입력 — 등록부 기본 2.5 걷음(2026-09-14 브레인 판정) + assert pipe["options"]["form"] == "돌쌓기(메)" and "height_m" not in pipe["options"] def test_migrate_without_pipe_points_defers_revetment(client): diff --git a/resources/tester/test_b08_facility_structures.py b/resources/tester/test_b08_facility_structures.py index dec80ba1..d0cf477c 100644 --- a/resources/tester/test_b08_facility_structures.py +++ b/resources/tester/test_b08_facility_structures.py @@ -95,7 +95,7 @@ def test_유입구가_기슭막이인데_집수정_형식이_남아_있으면_ def test_독립_기슭막이_한쪽이면_두_칸이_같을_때만_세고_다르면_사유() -> None: - same = {"facility": "revetment", "chainage_m": 50.0, "options": {"side": "좌"}} + same = {"facility": "revetment", "chainage_m": 50.0, "options": {"side": "좌", "height_m": 2.0}} rows = _table([same])["structures"] assert [r["name"] for r in rows] == ["기슭막이 · 좌 벽"] and rows[0]["components"] differ = {**same, "options": {"side": "우", "inlet_revet_height_m": 3.0}} @@ -103,6 +103,17 @@ def test_독립_기슭막이_한쪽이면_두_칸이_같을_때만_세고_다르 assert not row["components"] and "서버가 못 가림" in row["notes"][-1] +def test_독립_기슭막이_높이는_설계자_입력_없으면_미확정과_사방_근거() -> None: + """2026-09-14 브레인 판정 — 높이 = 계획홍수위 + 0.5~0.7m(사방기술교본 2-나:141 · 3-가:181).""" + point = {"facility": "revetment", "chainage_m": 50.0, "options": {"side": "좌"}} + (row,) = _table([point])["structures"] + assert "높이를 안 적음" in row["unconfirmed"] and "2-나:141" in row["unconfirmed"] + assert any("사방(계류) 교본 기준" in note and "3-가:151" in note for note in row["notes"]) + with_height = {**point, "options": {"side": "좌", "height_m": 2.0, "form": "돌쌓기(메)"}} + (row,) = _table([with_height])["structures"] + assert "높이를 안 적음" not in row["unconfirmed"] + + def test_산출식_없는_세월교는_사유로_서고_터파기_빠짐_줄을_안_만든다() -> None: table = _table([{"facility": "ford_bridge", "chainage_m": 30.0, "options": {}}]) assert "산출식이 아직 없습니다" in table["structures"][0]["notes"][0]