From ebdf2988e1cc9fb93bfd90aff554c7a13cddf822 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 20:17:37 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat(B05):=20=EC=A0=84=EB=A9=B4=20=EA=B8=B0?= =?UTF-8?q?=EC=9A=B8=EA=B8=B0=20=EB=8D=AE=EC=96=B4=EC=93=B0=EA=B8=B0=20?= =?UTF-8?q?=EC=B9=B8=20=EC=8B=A0=EC=84=A4=20=E2=80=94=20=EB=B9=88=20?= =?UTF-8?q?=EA=B0=92=EC=9D=B4=20=E3=80=8C=EC=9E=90=EB=8F=99=20=ED=8C=90?= =?UTF-8?q?=EC=A0=95=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 확정 ⑨(2026-09-09) 「품셈 표준경사 표대로 자동 + 사용자가 바꿀 수 있게」. 지금은 칸이 없어 코드가 늘 0.3 으로 돌아 찰 H=2.5 와 메 H=2.0 이 같은 값으로 나왔다. - `face_slope_ratio`(1:n 의 n) — 돌쌓기(찰·메)·큰돌쌓기·골막이·바닥막이·기슭막이 여섯. - ⚠ **기본값 없음(빈 값)** — 빈 값이 「자동 판정」의 뜻이다. 기본값을 박으면 「정한 적 없음」과 「그 값으로 정함」이 구별되지 않는다. 읽는 쪽이 「비어 있으면 표대로 자동, 값이 있으면 그 값」으로 가른다. - 그래서 「기본값 없으면 필수」 정책의 예외로 두고, 까닭을 시험 주석에 남김. - 엔진이 읽는 이름과 글자까지 대조(`B08_Quantity_Engine_UnitQuantity` 의 `options.get("face_slope_ratio")`), 저장 왕복도 실제로 확인(PUT 200 → 0.5 되읽음). Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_Structure_Types.json | 48 ++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/B05_Profile/B05_Profile_Structure_Types.json b/B05_Profile/B05_Profile_Structure_Types.json index bfb217ae..013fe8db 100644 --- a/B05_Profile/B05_Profile_Structure_Types.json +++ b/B05_Profile/B05_Profile_Structure_Types.json @@ -779,6 +779,14 @@ "required": false, "phase": "detail" }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail" + }, { "key": "side", "label": "설치 측", @@ -862,6 +870,14 @@ "required": false, "phase": "detail" }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail" + }, { "key": "side", "label": "설치 측", @@ -1010,6 +1026,14 @@ "required": false, "phase": "detail" }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail" + }, { "key": "side", "label": "설치 측", @@ -1115,6 +1139,14 @@ "required": false, "phase": "detail" }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail" + }, { "key": "length_m", "label": "길이", @@ -1160,6 +1192,14 @@ "required": false, "phase": "detail" }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail" + }, { "key": "area_m2", "label": "면적", @@ -1268,6 +1308,14 @@ "required": false, "phase": "detail" }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail" + }, { "key": "height_m", "label": "높이", From e29f55782e777720066fcfde4dc450cd31cfc36e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 20:21:12 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat(=EA=B3=B5=EC=9A=A9):=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=EB=AC=BC=20=EC=9E=90=EB=A6=AC=EC=9D=98=20=EC=84=B1?= =?UTF-8?q?=ED=86=A0/=EC=A0=88=ED=86=A0=20=ED=8C=90=EC=A0=95=20=ED=95=9C?= =?UTF-8?q?=20=EB=B2=8C=20+=20=EC=A0=84=EB=A9=B4=20=EA=B8=B0=EC=9A=B8?= =?UTF-8?q?=EA=B8=B0=20=EB=B2=94=EC=9C=84=20=EC=95=88=EB=82=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 품셈 표준경사 표가 높이 × 메/찰 × **성토/절토** 로 갈리는데 성절토만 없었다. 새 입력을 만들지 않고 이미 저장되는 두 값으로 가린다 — `design.section_mode` + 구조물 `options.side`. - `common_util_structure_face_role.structure_face_role()` — ("성토"|"절토"|None, 까닭 문구). ⚠ 못 가르면 **None**. 성토로 눅이지 않는다 — 받는 쪽이 근거 없음을 알아야 한다. 예외 하나: `both_cut` + 「자동(성토 쪽)」은 세울 성토면이 없어 근거 없음. 판정을 두 벌로 짜면 그 예외에서 갈리므로 여기 하나만 둔다(CLAUDE.md 5장). 파이썬 단독 — 읽는 쪽이 B08 뿐이다. 화면이 쓰게 되면 그때 TS 짝·거울 시험을 붙일 것. - 전면 기울기 칸: 표 범위(0.20~0.50) 밖이면 **막지 않고 알리기만** 한다. 실무 도면이 S0.7·S0.8 을 쓴다 — 막으면 실물이 안 들어간다. 시험 14건(네 단면유형 × 좌/우 여덟 · 자동 셋 · 모르는 값 · 옛 영문 표기 · 문구). Co-Authored-By: Claude Opus 5 (1M context) --- .../B05_Profile_UI_Structures_Panel.ts | 16 +++++ .../B05_Profile_UI_Style_Structures.css | 6 ++ .../common_util_structure_face_role.py | 61 +++++++++++++++++++ 3 files changed, 83 insertions(+) create mode 100644 common_util/common_util_structure_face_role.py diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index fe1ab070..019232c4 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -354,6 +354,22 @@ export function createStructuresSection( read: () => (option.input === "number" ? Number(input.value) || 0 : input.value), isEmpty: () => input.value.trim() === "", }); + // 전면 기울기는 품셈 표준경사 표(0.20~0.50) 밖 값도 받는다 — 실무 도면이 S0.7·S0.8 을 + // 쓴다(2026-09-09 실무 DWG 확인). **막지 않고 알리기만** 한다: 막으면 실물이 안 들어간다. + if (option.key === "face_slope_ratio") { + const warn = (): void => { + const value = Number(input.value); + const outside = + input.value.trim() !== "" && Number.isFinite(value) && (value < 0.2 || value > 0.5); + input.title = outside + ? "품셈 표준경사 표 범위(0.20~0.50) 밖 값입니다 — 근거를 적어 두시기 바랍니다." + : "비워 두면 품셈 표대로 자동 판정합니다."; + input.classList.toggle("is-outside-standard", outside); + }; + warn(); + input.addEventListener("input", warn); + input.addEventListener("change", warn); + } // 길이·기준측점 전/후가 바뀌면 서로 연동하고 측점 범위 표시가 즉시 따라온다 // (범위 계산 타입, 2026-08-19). 전·후는 **나중에 고친 쪽**이 살아남고 반대쪽이 // 길이 − 그 값으로 맞춰진다. 길이를 고치면 나중에 고쳤던 쪽을 지키며 재배분. diff --git a/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css index 650db38c..6ddb3807 100644 --- a/B05_Profile/B05_Profile_UI_Style_Structures.css +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -419,3 +419,9 @@ .b05-structure-section .b05-structure__position-row .b05-route__field { gap: 6px; } + +/* 전면 기울기가 품셈 표준경사 표(0.20~0.50) 밖일 때 — 실무가 S0.7 을 쓰므로 **막지 않고** + 테두리로만 알린다(2026-09-09). 말풍선에 까닭이 들어 있다. */ +.is-outside-standard { + border-color: var(--color-warning, #c98a00); +} diff --git a/common_util/common_util_structure_face_role.py b/common_util/common_util_structure_face_role.py new file mode 100644 index 00000000..85422a1e --- /dev/null +++ b/common_util/common_util_structure_face_role.py @@ -0,0 +1,61 @@ +"""구조물이 선 자리가 **성토면인가 절토면인가** — 판정 한 벌. + +왜 있나(2026-09-09) — 품셈 표준경사 표가 **높이 × 메/찰 × 성토/절토** 셋으로 갈리는데, +앞 둘은 구조물 제원에 있고 **성절토만 없었다**. 그 값은 횡단 설계가 아는 것이라 B06 이 +내주기로 했고, **새 입력을 만들지 않고** 이미 저장되는 두 값으로 가린다: + + design.section_mode left_cut · right_cut · both_cut · both_fill + 구조물 options.side "자동(성토 쪽)" · "좌" · "우" + +판정이 두 곳에 두 벌로 짜이면 `both_cut` + 「자동」의 예외에서 갈린다 — 그래서 여기 하나만 +둔다(CLAUDE.md 5장 「코드는 한 벌」). **파이썬 단독**이다: 읽는 쪽이 B08 수량뿐이고 브라우저는 +이 판정을 쓰지 않는다. 화면이 쓰게 되면 그때 TS 짝을 만들고 거울 시험을 붙일 것. + +⚠ **못 가르면 `None` 을 돌려준다.** 성토로 눅여 돌려주지 않는다 — 받는 쪽이 「근거가 없다」는 +사실을 알아야 임의값이 금액으로 굳지 않는다(측구 토사/암 분리와 같은 태도). +""" + +from __future__ import annotations + +FILL = "성토" +CUT = "절토" + +#: 단면유형 → (좌측 역할, 우측 역할). 좌 = +offset, 우 = −offset (B06 규약). +_MODE_ROLES: dict[str, tuple[str, str]] = { + "left_cut": (CUT, FILL), + "right_cut": (FILL, CUT), + "both_cut": (CUT, CUT), + "both_fill": (FILL, FILL), +} + +#: 설치 측 표기 — 레지스트리 선택지와 옛 저장분(영문)을 함께 받는다. +_LEFT = {"좌", "left", "L"} +_RIGHT = {"우", "right", "R"} +_AUTO = {"자동(성토 쪽)", "자동", "auto", ""} + + +def structure_face_role(section_mode: str | None, side: str | None) -> tuple[str | None, str]: + """(`"성토"` | `"절토"` | `None`, 까닭 문구)를 돌려준다. + + 까닭 문구는 화면 근거 줄에 그대로 실을 수 있는 한 줄이다 + (예: `right_cut · 좌측 → 성토면`). + """ + mode = (section_mode or "").strip() + roles = _MODE_ROLES.get(mode) + if roles is None: + return None, f"단면유형을 알 수 없음({section_mode!r}) — 성절토를 가를 근거 없음" + + left_role, right_role = roles + picked = (side or "").strip() + + if picked in _LEFT: + return left_role, f"{mode} · 좌측 → {left_role}면" + if picked in _RIGHT: + return right_role, f"{mode} · 우측 → {right_role}면" + if picked in _AUTO: + # 「자동(성토 쪽)」은 성토면에 세운다는 뜻이다 — 그런데 양쪽이 다 절토면 + # 세울 성토면이 없어 어느 쪽인지 정할 근거가 없다. 임의로 몰지 않는다. + if left_role == CUT and right_role == CUT: + return None, f"{mode} · 자동(성토 쪽)인데 성토면이 없음 — 가를 근거 없음" + return FILL, f"{mode} · 자동(성토 쪽) → 성토면" + return None, f"설치 측을 알 수 없음({side!r}) — 성절토를 가를 근거 없음" From a35215a598f9ea3474a555715bb356a8f1c01e18 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 20:22:26 +0900 Subject: [PATCH 3/4] auto: 2026-09-08 20:22 (EOMSANGDON-HOME) --- B08_Quantity/B08_Quantity_Wording.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/B08_Quantity/B08_Quantity_Wording.py b/B08_Quantity/B08_Quantity_Wording.py index 2ce69b9a..86f22eca 100644 --- a/B08_Quantity/B08_Quantity_Wording.py +++ b/B08_Quantity/B08_Quantity_Wording.py @@ -44,7 +44,9 @@ OPTION_LABELS = { "form": ("옹벽 형식", "구조물 상세 입력"), "height_m": ("높이(m)", "구조물 배치"), "length_m": ("연장(m)", "구조물 배치"), - "face_slope_ratio": ("전면 기울기", "아직 입력 칸이 없음"), + # 2026-09-09 칸이 생겼음(`ebdf2988`) — 「아직 칸이 없음」이 거짓이 되어 고침. + # ⚠ 비워 두면 품셈 표준경사표로 자동 판정된다(확정 ⑨). + "face_slope_ratio": ("전면 기울기", "구조물 상세 입력 — 비우면 품셈 표준경사로 자동"), } From 1b03ad025ace6bcd9f2ad7a7767dc73947e5c15a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 20:27:40 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat(B08):=20=EC=84=B1=ED=86=A0/=EC=A0=88?= =?UTF-8?q?=ED=86=A0=EB=A5=BC=20=ED=8C=90=EC=A0=95=20=ED=95=9C=20=EB=B2=8C?= =?UTF-8?q?=EB=A1=9C=20=EB=B0=9B=EC=95=84=20=ED=91=9C=EC=A4=80=EA=B2=BD?= =?UTF-8?q?=EC=82=AC=20=EC=97=B4=EC=9D=84=20=EA=B3=A0=EB=A6=84=20(?= =?UTF-8?q?=ED=99=95=EC=A0=95=20=E2=91=A8=20=EB=A7=88=EB=AC=B4=EB=A6=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앞서 「성토 열로 잠정」 딱지를 달아 두었던 자리를 채움. 랩탑 메인이 낸 공용 함수 `common_util_structure_face_role(section_mode, side)` 를 **그대로** 부름 — 판정을 두 벌로 짜면 `both_cut` + 「자동」 예외에서 갈리므로 우리 쪽에 다시 안 짬. - 새 저장 키 없음 — 이미 저장되는 `design.section_mode` 와 구조물 `options.side` 만 씀. 라우터가 측점별 단면유형을 모아 넘기고, 구조물은 **가장 가까운 측점** 값을 씀. - ⚠ 못 가르면 `None` 을 **성토로 눅이지 않음** — 종전값 1:0.3 으로 서되 **왜 못 갈랐는지**를 근거 줄에 적음. 임의값이 금액으로 굳으면 안 됨. - 근거 줄에 판정 문구가 그대로 실림 — 「… 1:0.35 · right_cut · 자동(성토 쪽) → 성토면」. 곁들여 — 전면 기울기 칸이 레지스트리에 생겨(`ebdf2988`) 「아직 칸이 없음」 문구와 없는-키 목록에서 뺌. ⚠ 표 밖 값(실무 도면의 S0.7·0.8)은 **막지 않고 그대로** 씀 — 접으면 사용자가 넣은 값이 조용히 다른 값이 됨. 시험으로 잠금. 검증 프로젝트 실측 — 62측점 전부 `right_cut`, 돌쌓기 「자동(성토 쪽)」 → 성토면. 잠정으로 쓰던 열과 **결과는 같고 근거가 값에 생김**. 돌쌓기(메) 21.190㎡(1:0.35) 유지. 시험 747 통과. Co-Authored-By: Claude Opus 5 (1M context) --- .../B08_Quantity_Engine_UnitQuantity.py | 77 +++++++++++++++---- B08_Quantity/B08_Quantity_Router_Material.py | 28 ++++++- 2 files changed, 89 insertions(+), 16 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py index cc2d9ace..d52dad12 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -37,6 +37,7 @@ from functools import lru_cache from dataclasses import dataclass, field from typing import Any, Iterable +from common_util.common_util_structure_face_role import structure_face_role from B08_Quantity.B08_Quantity_Engine_ObservedUnit import ( BASIS_DERIVED, BASIS_OBSERVED, @@ -281,7 +282,13 @@ def load_slope_table(path: str | None = None) -> dict[str, Any]: def face_slope_ratio( - options: dict[str, Any], *, wet: bool, height_m: float, table: dict[str, Any] | None = None + options: dict[str, Any], + *, + wet: bool, + height_m: float, + face: str | None = None, + face_reason: str = "", + table: dict[str, Any] | None = None, ) -> tuple[float, str]: """(기울기 1:n 의 n, 근거 문구). @@ -299,10 +306,15 @@ def face_slope_ratio( found = table if table is not None else load_slope_table() steps = found.get("steps_m") or [] bond = "찰쌓기" if wet else "메쌓기" - # ⚠ 성토/절토는 **저장이 아직 안 갖고 있다.** 레지스트리에 없는 키를 읽지 않는다 - # (읽으면 「없는 칸을 읽는」 자리가 되어 옵션 키 검사가 잡는다). 값이 생기면 - # 여기 한 줄만 바꾸면 된다 — 그때까지 성토 열로 잠정 적용하고 근거에 적는다. - face = DEFAULT_SLOPE_FACE + # ⚠ 성토/절토는 **판정 한 벌**(`common_util_structure_face_role`)이 준다. 못 가르면 + # `None` 이 오는데, **성토로 눅이지 않는다** — 임의값이 금액으로 굳으면 안 된다. + # 그때는 종전값으로 서되 **왜 못 갈랐는지**를 근거에 적는다. + if not face: + why = face_reason or "성절토를 가를 근거 없음" + return ( + LEGACY_FACE_SLOPE_RATIO, + f"⚠ {why} — 표준경사표를 못 골라 종전값 1:{LEGACY_FACE_SLOPE_RATIO} 로 섰음", + ) row = ((found.get("table") or {}).get(bond) or {}).get(face) if not row: return LEGACY_FACE_SLOPE_RATIO, f"표준경사표를 못 읽어 종전값 1:{LEGACY_FACE_SLOPE_RATIO}" @@ -315,7 +327,8 @@ def face_slope_ratio( ratio = float(row[min(index, len(row) - 1)]) label = f"직고 {height_m:g}m" + (f" ≤{steps[index]:g}m" if index < len(steps) else " 7m 초과") note = f"품셈 13-4-4 [주]⑪ 표준경사 · {bond} {face} · {label} → 1:{ratio:g}" - note += " · ⚠ 성토/절토가 저장에 없어 **성토 열로 잠정**" + if face_reason: + note += f" · {face_reason}" return ratio, note @@ -513,7 +526,12 @@ def boulder_masonry( def stone_masonry( - height_m: float, length_m: float, options: dict[str, Any], wet: bool + height_m: float, + length_m: float, + options: dict[str, Any], + wet: bool, + face: str | None = None, + face_reason: str = "", ) -> tuple[list[Component], list[str]]: """돌쌓기(찰/메) 1구간 전개 — 실무 `기슭막이(찰쌓기, H=1.5, 기초무)` 시트의 식. @@ -561,7 +579,9 @@ def stone_masonry( # 돌 메쌓기 2.0m 이하 **1:0.3** / 큰돌쌓기 **1:0.3 이상**(전도 방지)」 # (`지식DB 02_상세설계/구조물/돌쌓기.md §1`, 값은 `data_masonry` 의 `face_slope`). # 전면 기울기 — 품셈 표준경사표로 자동 판정하고, 저장 제원에 값이 있으면 그것이 이긴다. - slope_ratio, slope_basis = face_slope_ratio(options, wet=wet, height_m=height_m) + slope_ratio, slope_basis = face_slope_ratio( + options, wet=wet, height_m=height_m, face=face, face_reason=face_reason + ) constants = STONE_MASONRY face_area = height_m * length_m # 정면적 @@ -735,9 +755,11 @@ OBSERVED_SPEC_KEYS: dict[str, tuple[str, ...]] = { } EXPANDERS = { - "masonry_wet": lambda h, l, o: stone_masonry(h, l, o, wet=True), - "masonry_dry": lambda h, l, o: stone_masonry(h, l, o, wet=False), - "boulder_masonry": lambda h, l, o: boulder_masonry(h, l, o), + # 넷째·다섯째 인자는 **성토/절토와 그 까닭** — 표준경사 표가 그것으로 갈린다. + "masonry_wet": lambda h, l, o, f=None, r="": stone_masonry(h, l, o, True, f, r), + "masonry_dry": lambda h, l, o, f=None, r="": stone_masonry(h, l, o, False, f, r), + # 큰돌쌓기는 표준경사 표 대상이 아니라 성절토를 안 쓴다(교본 「1:0.3 이상」). + "boulder_masonry": lambda h, l, o, f=None, r="": boulder_masonry(h, l, o), # ⚠⚠ **큰돌쌓기(`boulder_masonry`)를 여기에 두지 않는다** (2026-09-07 발견). # 큰돌쌓기는 품셈 **13-6** 이고 돌쌓기는 **13-4** 다 — **규격 축이 다르다.** # 돌쌓기는 **뒷길이**(35·45·55·60㎝), 큰돌쌓기는 **직경**(40~60·60~80·80~100㎝). @@ -817,6 +839,7 @@ def expand( structure: dict[str, Any], names: dict[str, str] | None = None, observed: ObservedUnitTable | None = None, + section_mode: str | None = None, ) -> StructureQuantity: """구조물 하나를 전개한다. 치수는 저장된 제원에서만 읽는다(치수 두 벌 금지).""" type_id = str(structure.get("type_id") or "") @@ -862,7 +885,9 @@ def expand( f"{type_label(type_id, names)}의 수량 산출식이 아직 없습니다 — 물량이 서지 않습니다" ) return result - result.components, notes = expander(height, length, options) + # 성토/절토 — **판정 한 벌**을 부른다(우리가 따로 짜지 않는다). + face, face_reason = structure_face_role(section_mode, options.get("side")) + result.components, notes = expander(height, length, options, face, face_reason) result.notes.extend(notes) return result @@ -881,8 +906,29 @@ def verify_no_mix_components(quantities: Iterable[StructureQuantity]) -> list[st return found +def _section_mode_at( + structure: dict[str, Any], section_modes: dict[float, str] | None +) -> str | None: + """구조물이 선 자리의 단면유형. **가장 가까운 측점**의 값을 쓴다. + + ⚠ 구조물은 구간(start~end)이고 단면유형은 측점 값이라 딱 맞는 측점이 없을 수 있다. + 가장 가까운 측점을 쓰되, 목록이 없으면 `None`(가를 근거 없음)으로 둔다 — + **성토로 눅이지 않는다.** + """ + if not section_modes: + return None + center = _num(structure.get("chainage_m")) + if not center: + start, end = _num(structure.get("start_m")), _num(structure.get("end_m")) + center = (start + end) / 2.0 if (start or end) else 0.0 + nearest = min(section_modes, key=lambda chainage: abs(float(chainage) - center)) + return section_modes.get(nearest) + + def build_table( - structures: Iterable[dict[str, Any]], names: dict[str, str] | None = None + structures: Iterable[dict[str, Any]], + names: dict[str, str] | None = None, + section_modes: dict[float, str] | None = None, ) -> dict[str, Any]: """화면·API 가 그대로 쓰는 모양. 성분별 총량과 구조물별 내역을 함께 낸다.""" observed = load_observed_table() @@ -891,7 +937,10 @@ def build_table( for item in structures: expanded_inputs.append(item) expanded_inputs.extend(attachments_of(item)) - quantities = [expand(item, names, observed) for item in expanded_inputs] + quantities = [ + expand(item, names, observed, _section_mode_at(item, section_modes)) + for item in expanded_inputs + ] violations = verify_no_mix_components(quantities) totals: dict[str, dict[str, Any]] = {} diff --git a/B08_Quantity/B08_Quantity_Router_Material.py b/B08_Quantity/B08_Quantity_Router_Material.py index b95b5d64..659cb2fd 100644 --- a/B08_Quantity/B08_Quantity_Router_Material.py +++ b/B08_Quantity/B08_Quantity_Router_Material.py @@ -25,6 +25,8 @@ from fastapi import APIRouter from fastapi.responses import JSONResponse from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from B06_Section.B06_Section_Repository import get_cross_section_designs +from B06_Section.B06_Section_Repository import get_workflow_route_context from B05_Profile.B05_Profile_Structures_Repository import load_structures from B05_Profile.B05_Profile_Structures_Schema import structure_type_map from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff, load_mapping, summarize @@ -78,6 +80,28 @@ def _collect_structures( return targets, names, sorted(set(skipped)) +async def _section_modes(project_id: UUID) -> dict[float, str]: + """측점별 단면유형(`left_cut` 등). 구조물이 **성토면인가 절토면인가**를 가릴 때 쓴다. + + ⚠ 새 저장 키를 만들지 않는다 — 이미 저장되는 `design.section_mode` 를 읽기만 한다. + 못 읽으면 빈 표로 두고, 판정이 「가를 근거 없음」이 되게 한다(성토로 눅이지 않음). + """ + try: + context = await run_with_connection(get_workflow_route_context, project_id) + route_id = int((context or {}).get("route_id") or 0) + if not route_id: + return {} + designs = await run_with_connection(get_cross_section_designs, route_id) + except Exception: + logger.exception("B08 단면유형 조회 실패: project_id=%s", project_id) + return {} + return { + float(item["chainage_m"]): str((item.get("design") or {}).get("section_mode") or "") + for item in designs + if (item.get("design") or {}).get("section_mode") + } + + @router.get("/{project_id}/quantity/material-summary") async def get_material_summary(project_id: UUID) -> JSONResponse: """구조물 원단위와 자재총괄을 **한 응답**으로 낸다. @@ -103,7 +127,7 @@ async def get_material_summary(project_id: UUID) -> JSONResponse: content={"status": "error", "message": "구조물 정본을 읽지 못했습니다."}, ) - unit_table = build_unit_table(structures, names) + unit_table = build_unit_table(structures, names, await _section_modes(project_id)) settings = quantity_settings(project_root) material_table = build_material_table( unit_table, @@ -153,7 +177,7 @@ async def get_handoff(project_id: UUID) -> JSONResponse: ) structures, names, skipped = _collect_structures(project_root) - unit_table = build_unit_table(structures, names) + unit_table = build_unit_table(structures, names, await _section_modes(project_id)) settings = quantity_settings(project_root) material_table = build_material_table( unit_table, supply_map=settings.get("material_supply") or {}