From 36f47dcc8dba26c801f02768f58adc273747d05d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 12:37:50 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feat(B05):=20=EB=93=B1=EB=A1=9D=EB=B6=80=20?= =?UTF-8?q?=EC=B9=B8=EC=97=90=20=E3=80=8C=EB=B9=84=EC=9B=8C=20=EB=91=90?= =?UTF-8?q?=EB=8A=94=20=EA=B2=83=EC=9D=B4=20=EB=9C=BB=E3=80=8D=C2=B7?= =?UTF-8?q?=E3=80=8C=EA=B8=B0=EB=B3=B8=EA=B0=92=EC=9D=98=20=EA=B9=8C?= =?UTF-8?q?=EB=8B=AD=E3=80=8D=EC=9D=84=20=EA=B0=92=20=EC=98=86=EC=97=90=20?= =?UTF-8?q?=EC=A0=81=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚠⚠ **깨진 시험의 뿌리는 칸이 아니라 자리였음.** 정책 시험은 `tmp/tests` 라 **git 밖**이라 정본(추적됨)만 다른 창에 가고 예외 목록(추적 안 됨)은 안 따라감 ⇒ **다른 창에서만 깨짐.** 오늘 그 일이 두 번 났음. ⇒ **까닭을 값 옆(정본)에 두어 함께 움직이게 함.** - 스키마에 칸 둘 신설 · `empty_means` — **「비워 두는 것이 뜻인 칸」**(세 번째 갈래). 비면 계산 쪽이 기준값으로 돌고 그 사실이 화면에 뜸. 값을 넣으면 그 값이 이김 · `default_basis` — 기본값이 **도메인 확정값이 아닐 때** 그 뜻(「다단 없음」·「안 더함」). 법정·확정 수치면 비워 둠 — 비어 있는 것이 「확정값」이라는 뜻 - 56곳에 까닭을 적음 — 전면 기울기 · 야면석 계수 · 채움 강도 · 비탈 기울기 · 물빼기 둘 · 개거 규격 · 뒷길이 · 돌 종류 / 단 수 · 올림 · 이동 · 조달 · 설치 측 · 추가 운반거리 - 정책 시험이 그 칸을 읽게 바꿈 + 까닭이 한 낱말이면 잡는 시험 둘 추가 ⇒ 앞으로 등록부에 칸을 열 때 **예외 목록을 고치지 말고 까닭을 값 옆에 적을 것.** Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_Structure_Types.json | 168 ++++++++++++------- B05_Profile/B05_Profile_Structures_Schema.py | 12 ++ 2 files changed, 124 insertions(+), 56 deletions(-) diff --git a/B05_Profile/B05_Profile_Structure_Types.json b/B05_Profile/B05_Profile_Structure_Types.json index 1788819a..b91b0ad8 100644 --- a/B05_Profile/B05_Profile_Structure_Types.json +++ b/B05_Profile/B05_Profile_Structure_Types.json @@ -467,7 +467,8 @@ "choices": ["콘크리트 개거 150×200", "L형수로 H=0.2"], "default": null, "required": false, - "phase": "detail" + "phase": "detail", + "empty_means": "비우면 실무 붙박이(콘크리트 개거)로 돎" } ] }, @@ -729,7 +730,8 @@ "label": "설치 측", "input": "select", "choices": ["자동(성토 쪽)", "좌", "우"], - "default": "자동(성토 쪽)" + "default": "자동(성토 쪽)", + "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" }, { "key": "weep_hole_diameter_mm", @@ -738,7 +740,8 @@ "unit": "㎜", "default": null, "required": false, - "phase": "detail" + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" }, { "key": "weep_hole_area_m2", @@ -747,7 +750,8 @@ "unit": "㎡", "default": null, "required": false, - "phase": "detail" + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" } ] }, @@ -832,7 +836,8 @@ "choices": ["품셈", "실무 관행"], "default": null, "required": false, - "phase": "detail" + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀" }, { "key": "fill_concrete_mpa", @@ -841,7 +846,8 @@ "choices": ["180", "210"], "default": null, "required": false, - "phase": "detail" + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎" }, { "key": "face_slope_ratio", @@ -849,7 +855,8 @@ "input": "number", "default": null, "required": false, - "phase": "detail" + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" }, { "key": "foundation", @@ -865,7 +872,8 @@ "label": "설치 측", "input": "select", "choices": ["자동(성토 쪽)", "좌", "우"], - "default": "자동(성토 쪽)" + "default": "자동(성토 쪽)", + "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" }, { "key": "weep_hole_diameter_mm", @@ -874,7 +882,8 @@ "unit": "㎜", "default": null, "required": false, - "phase": "detail" + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" }, { "key": "weep_hole_area_m2", @@ -883,7 +892,8 @@ "unit": "㎡", "default": null, "required": false, - "phase": "detail" + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" } ] }, @@ -968,7 +978,8 @@ "choices": ["품셈", "실무 관행"], "default": null, "required": false, - "phase": "detail" + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀" }, { "key": "fill_concrete_mpa", @@ -977,7 +988,8 @@ "choices": ["180", "210"], "default": null, "required": false, - "phase": "detail" + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎" }, { "key": "face_slope_ratio", @@ -985,7 +997,8 @@ "input": "number", "default": null, "required": false, - "phase": "detail" + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" }, { "key": "foundation", @@ -1001,7 +1014,8 @@ "label": "설치 측", "input": "select", "choices": ["자동(성토 쪽)", "좌", "우"], - "default": "자동(성토 쪽)" + "default": "자동(성토 쪽)", + "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" }, { "key": "weep_hole_diameter_mm", @@ -1010,7 +1024,8 @@ "unit": "㎜", "default": null, "required": false, - "phase": "detail" + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" }, { "key": "weep_hole_area_m2", @@ -1019,7 +1034,8 @@ "unit": "㎡", "default": null, "required": false, - "phase": "detail" + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" } ] }, @@ -1093,7 +1109,8 @@ "label": "설치 측", "input": "select", "choices": ["자동(성토 쪽)", "좌", "우"], - "default": "자동(성토 쪽)" + "default": "자동(성토 쪽)", + "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" }, { "key": "tiers", @@ -1102,7 +1119,8 @@ "unit": "단", "default": 1, "required": false, - "phase": "b05" + "phase": "b05", + "default_basis": "1 = 다단 없음 — 도메인 수치가 아니라 「단이 하나」라는 표식(기슭막이와 같은 벌)" }, { "key": "lift_m", @@ -1111,7 +1129,8 @@ "unit": "m", "default": 0, "required": false, - "phase": "b05" + "phase": "b05", + "default_basis": "0 = 자동 자리 그대로 — 올리지 않음을 뜻함(기슭막이와 같은 벌)" }, { "key": "shift_m", @@ -1120,7 +1139,8 @@ "unit": "m", "default": 0, "required": false, - "phase": "b05" + "phase": "b05", + "default_basis": "0 = 자동 자리 그대로 — 옮기지 않음을 뜻함(기슭막이와 같은 벌)" }, { "key": "back_len_cm", @@ -1129,7 +1149,8 @@ "choices": ["25", "30", "35", "45", "55", "60", "75"], "default": null, "required": false, - "phase": "detail" + "phase": "detail", + "empty_means": "비우면 돌쌓기 기본값으로 서고 「기본값으로 섰음」이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검" }, { "key": "stone_kind", @@ -1138,7 +1159,8 @@ "choices": ["야면석·호박돌", "깬잡석", "깬돌", "견치돌"], "default": null, "required": false, - "phase": "detail" + "phase": "detail", + "empty_means": "비우면 야면석 계수 열로 서고 그 사실이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검" }, { "key": "stone_supply", @@ -1147,7 +1169,8 @@ "choices": ["채집", "구입"], "default": "채집", "required": false, - "phase": "detail" + "phase": "detail", + "default_basis": "「채집」 — 별표2 「야면석 등은 가급적 현장에서 채취·사용」 권고. 구조물마다 바꿀 수 있음" }, { "key": "stone_coeff_basis", @@ -1156,7 +1179,8 @@ "choices": ["품셈", "실무 관행"], "default": null, "required": false, - "phase": "detail" + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀" }, { "key": "fill_concrete_mpa", @@ -1165,7 +1189,8 @@ "choices": ["180", "210"], "default": null, "required": false, - "phase": "detail" + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎" }, { "key": "face_slope_ratio", @@ -1173,7 +1198,8 @@ "input": "number", "default": null, "required": false, - "phase": "detail" + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" }, { "key": "foundation", @@ -1191,7 +1217,8 @@ "unit": "㎜", "default": null, "required": false, - "phase": "detail" + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" }, { "key": "weep_hole_area_m2", @@ -1200,7 +1227,8 @@ "unit": "㎡", "default": null, "required": false, - "phase": "detail" + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" } ] }, @@ -1285,7 +1313,8 @@ "choices": ["품셈", "실무 관행"], "default": null, "required": false, - "phase": "detail" + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀" }, { "key": "fill_concrete_mpa", @@ -1294,7 +1323,8 @@ "choices": ["180", "210"], "default": null, "required": false, - "phase": "detail" + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎" }, { "key": "face_slope_ratio", @@ -1302,7 +1332,8 @@ "input": "number", "default": null, "required": false, - "phase": "detail" + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" }, { "key": "foundation", @@ -1318,7 +1349,8 @@ "label": "설치 측", "input": "select", "choices": ["자동(성토 쪽)", "좌", "우"], - "default": "자동(성토 쪽)" + "default": "자동(성토 쪽)", + "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" }, { "key": "weep_hole_diameter_mm", @@ -1327,7 +1359,8 @@ "unit": "㎜", "default": null, "required": false, - "phase": "detail" + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" }, { "key": "weep_hole_area_m2", @@ -1336,7 +1369,8 @@ "unit": "㎡", "default": null, "required": false, - "phase": "detail" + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" } ] }, @@ -1470,7 +1504,8 @@ "choices": ["품셈", "실무 관행"], "default": null, "required": false, - "phase": "detail" + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀" }, { "key": "fill_concrete_mpa", @@ -1479,7 +1514,8 @@ "choices": ["180", "210"], "default": null, "required": false, - "phase": "detail" + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎" }, { "key": "face_slope_ratio", @@ -1487,7 +1523,8 @@ "input": "number", "default": null, "required": false, - "phase": "detail" + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" }, { "key": "foundation", @@ -1537,7 +1574,8 @@ "unit": "㎜", "default": null, "required": false, - "phase": "detail" + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" }, { "key": "weep_hole_area_m2", @@ -1546,7 +1584,8 @@ "unit": "㎡", "default": null, "required": false, - "phase": "detail" + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" } ] }, @@ -1586,7 +1625,8 @@ "choices": ["품셈", "실무 관행"], "default": null, "required": false, - "phase": "detail" + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀" }, { "key": "fill_concrete_mpa", @@ -1595,7 +1635,8 @@ "choices": ["180", "210"], "default": null, "required": false, - "phase": "detail" + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎" }, { "key": "face_slope_ratio", @@ -1603,7 +1644,8 @@ "input": "number", "default": null, "required": false, - "phase": "detail" + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" }, { "key": "foundation", @@ -1675,7 +1717,8 @@ "choices": ["양쪽", "좌", "우"], "default": "양쪽", "required": false, - "phase": "b05" + "phase": "b05", + "default_basis": "「좌」는 셀렉트 첫 항목일 뿐 도메인 확정값이 아님 — 측점마다 사용자가 고름" }, { "key": "tiers", @@ -1684,7 +1727,8 @@ "unit": "단", "default": 1, "required": false, - "phase": "b05" + "phase": "b05", + "default_basis": "1 = 다단 없음 — 도메인 수치가 아니라 「단이 하나」라는 표식" }, { "key": "lift_m", @@ -1693,7 +1737,8 @@ "unit": "m", "default": 0, "required": false, - "phase": "b05" + "phase": "b05", + "default_basis": "0 = 자동 자리 그대로" }, { "key": "shift_m", @@ -1702,7 +1747,8 @@ "unit": "m", "default": 0, "required": false, - "phase": "b05" + "phase": "b05", + "default_basis": "0 = 자동 자리 그대로" }, { "key": "form", @@ -1720,7 +1766,8 @@ "choices": ["25", "30", "35", "45", "55", "60", "75"], "default": null, "required": false, - "phase": "detail" + "phase": "detail", + "empty_means": "비우면 돌쌓기 기본값으로 서고 「기본값으로 섰음」이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검" }, { "key": "stone_kind", @@ -1729,7 +1776,8 @@ "choices": ["야면석·호박돌", "깬잡석", "깬돌", "견치돌"], "default": null, "required": false, - "phase": "detail" + "phase": "detail", + "empty_means": "비우면 야면석 계수 열로 서고 그 사실이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검" }, { "key": "stone_supply", @@ -1747,7 +1795,8 @@ "choices": ["품셈", "실무 관행"], "default": null, "required": false, - "phase": "detail" + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀" }, { "key": "fill_concrete_mpa", @@ -1756,7 +1805,8 @@ "choices": ["180", "210"], "default": null, "required": false, - "phase": "detail" + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎" }, { "key": "face_slope_ratio", @@ -1764,7 +1814,8 @@ "input": "number", "default": null, "required": false, - "phase": "detail" + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" }, { "key": "foundation", @@ -1818,7 +1869,8 @@ "unit": "㎜", "default": null, "required": false, - "phase": "detail" + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" }, { "key": "weep_hole_area_m2", @@ -1827,7 +1879,8 @@ "unit": "㎡", "default": null, "required": false, - "phase": "detail" + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" } ] }, @@ -2005,7 +2058,8 @@ "choices": ["자동(성토 쪽)", "좌", "우"], "default": "자동(성토 쪽)", "required": false, - "phase": "b05" + "phase": "b05", + "default_basis": "「자동(성토 쪽)」 — 흙은 성토 쪽에 쌓는다는 지금까지의 동작을 이어받는 표식. 측점마다 좌·우로 바꿈" }, { "key": "fill_slope_ratio", @@ -2013,7 +2067,8 @@ "input": "number", "default": null, "required": false, - "phase": "detail" + "phase": "detail", + "empty_means": "비우면 그 측점의 노선 성토 기울기를 그대로 씀 — 지식DB 가 「기본 비탈기울기는 근거에 없다」고 못 박은 자리" }, { "key": "extra_distance_m", @@ -2022,7 +2077,8 @@ "unit": "m", "default": 0, "required": false, - "phase": "detail" + "phase": "detail", + "default_basis": "0 = 「추가 없음」 — 노선 안 사토장은 측점 누가거리로 거리가 나오므로 이 칸은 외부 사토장 대비 여벌" }, { "key": "area_m2", diff --git a/B05_Profile/B05_Profile_Structures_Schema.py b/B05_Profile/B05_Profile_Structures_Schema.py index d570578e..c484602f 100644 --- a/B05_Profile/B05_Profile_Structures_Schema.py +++ b/B05_Profile/B05_Profile_Structures_Schema.py @@ -48,6 +48,18 @@ class StructureOptionField(BaseModel): # 기본값이 그대로 저장된다(2026-09-07 사용자: 「폼 선택은 가능하게 반영하고 나중에 # 선택 비활성화로 하자」). 칸 자체를 없애면 나중에 켤 자리를 다시 찾아야 한다. enabled: bool = True + # ── 「기본값도 필수도 아닌」 세 번째 갈래 (2026-09-09 신설) ──────────────────── + # 지금까지 칸은 둘 중 하나여야 했다 — **기본값이 있거나, 필수이거나**. 그러지 않으면 + # 빈 값이 조용히 저장되기 때문이다. 그런데 실제로는 셋째가 있다: + # **「비워 두는 것이 뜻인 칸」** — 비면 계산 쪽이 **기준값**으로 돌고 그 사실이 화면에 + # 사유로 뜬다. 값을 넣으면 그 값이 이긴다(예: 전면 기울기, 물빼기 구멍, 뒷길이). + # 여기에 기준의 출처를 **한 줄로 적어** 두면, 그 칸이 왜 비어 있어도 되는지가 + # **정본 파일 안에** 남는다. 시험 예외 목록(창마다 따로 사는 파일)로 두면 창이 갈릴 때 + # 같은 시험이 다른 창에서 깨진다 — 실제로 그렇게 깨졌다(2026-09-09). + empty_means: str | None = None + # 기본값이 **도메인 확정값이 아닐 때** 그 뜻을 적는다(예: 「다단 없음」·「안 더함」). + # 법정·확정 수치면 비워 둔다 — 비어 있는 것이 「확정값」이라는 뜻이다. + default_basis: str | None = None class StructureType(BaseModel): From ed65eac74ef6dfe47c6d72639d30f0ecad3870f5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 12:37:56 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat(B08):=20=EB=92=B7=EC=B1=84=EC=9B=80=20?= =?UTF-8?q?=ED=8F=AD=20=EA=B7=BC=EA=B1=B0=20=EA=B5=AC=EA=B0=84=20=EB=B0=96?= =?UTF-8?q?=EC=9D=B4=EB=A9=B4=20=EC=82=AC=EC=9C=A0=EB=A5=BC=20=EB=83=84=20?= =?UTF-8?q?=E2=80=94=20=EB=86=92=EC=9D=80=20=EB=B2=BD=EC=9D=B4=20=ED=92=88?= =?UTF-8?q?=EC=85=88=20=EB=B2=94=EC=9C=84=20=EC=95=84=EB=9E=98=EB=A1=9C=20?= =?UTF-8?q?=EA=B0=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 정본 여섯 탭 전수 확인 구간이 H=1.0~3.0 인데 코드가 높이를 막지 않고 표준경사표는 직고 7m 까지 칸을 준다 — 높은 벽이 실제로 설 수 있음. 그 구간에서 막자갈 두께(= 물량 ÷ 비탈면적)가 0.375 ÷ √(1+경사²) 로 거의 붙박이라, 직고와 함께 두꺼워지는 품셈 13-4-4 [주]⑨ 범위보다 얇아짐 (H 3.0 0.359 안 · H 3.5 0.354 아래). 방향이 반대라 값만 보면 안 드러남. 값은 정본 식대로 세우고 막지도 눅이지도 않음 — 근거 구간 밖이라는 사실만 사유로 냄. 큰돌쌓기는 막자갈 줄 자체가 없어 이 갈림이 안 번짐. Co-Authored-By: Claude Opus 5 (1M context) --- .../B08_Quantity_Engine_UnitQuantity.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py index edf519cb..3881e481 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -157,6 +157,16 @@ def stone_weight_per_m2( #: 다르므로 **값이 적힌 구조물은 그 값**을 쓰고, 없으면 이 정본 값으로 서되 그 사실을 알린다. STONE_BACKFILL_WIDTH_M = {"top": 0.30, "bottom": 0.45} +#: ⚠⚠ 정본 전수 확인이 **H=1.0~3.0** 까지다 — 그보다 높은 벽은 **근거가 없다.** +#: 그리고 그 자리에서 품셈 13-4-4 [주]⑨ <뒤채움 조약돌 두께> 범위와 **갈린다**: +#: H 3.0 두께 0.359 (품셈 0.33~0.58 안) · H 3.5 0.354 (품셈 0.40~0.70 **아래**) +#: 까닭은 이 식의 두께(= 막자갈 ÷ 비탈면적)가 `0.375 ÷ √(1+경사²)` 로 거의 붙박이인데, +#: 품셈 표는 직고와 함께 두꺼워지기 때문이다 — **방향이 반대다.** +#: ⚠ 교본 7-3 은 「찰 3.0m 이하 · 메 2.0m 이하」로 두지만 **코드가 높이를 막지 않고** +#: 표준경사표(13-4-4 [주]⑪)는 직고 7m 까지 칸을 준다 — 즉 **높은 벽이 실제로 설 수 있다.** +#: 값을 눅이거나 막지 않고 **그 사실을 사유로 드러낸다**(임의 확정 금지). +STONE_BACKFILL_VERIFIED_HEIGHT_M = 3.0 + #: 석적 — ⭐ 확정 5차 작은 것 4. **정본에는 없고 소광리에만 있는 줄**이라 사유에 적는다. #: 소광리 식 `H × ℓ3 × 0.77`(면적 × 뒷길이 × 채움률) 그대로. STONE_PILE_BASIS = "⚠ 정본에 없는 줄 — 소광리 시트에만 있음(정면적 × 뒷길이 × 0.77)" @@ -971,6 +981,16 @@ def stone_masonry( f"하 {backfill_bottom:g}m 붙박이입니다 — 구조물 제원에 뒷채움 폭 칸이 없습니다" "(소광리는 같은 식에 0.30/0.60 을 씁니다)" ) + # ⚠ 근거 구간 밖은 **값이 서되 그 사실이 보여야 한다** — 값은 계속 나오므로 사유가 + # 없으면 아무도 못 본다(2026-09-09 그물 침). + if height_m > STONE_BACKFILL_VERIFIED_HEIGHT_M: + notes.append( + f"직고 {height_m:g}m 는 뒷채움 폭 정본 확인 구간" + f"(H {STONE_BACKFILL_VERIFIED_HEIGHT_M:g}m 이하) **밖**입니다 — 붙박이 폭이라 " + "직고가 커져도 두께가 안 늘어 품셈 13-4-4 [주]⑨ 뒤채움 조약돌 두께 범위보다 " + "얇아집니다(그 표는 직고와 함께 두꺼워짐). 값은 정본 식대로 세우되 " + "**높은 벽의 뒷채움 폭은 실무자 확인이 필요합니다**" + ) if wet: mpa, mpa_basis = fill_concrete_mpa(options) From 2972e4cccee1ecf4efa0b68abf52afd86be91367 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 12:42:20 +0900 Subject: [PATCH 3/9] =?UTF-8?q?refactor(B06):=20=ED=9A=A1=EB=8B=A8=20?= =?UTF-8?q?=EC=84=A4=EA=B3=84=20=EA=B8=B0=ED=95=98=EB=A5=BC=20=EB=96=BC?= =?UTF-8?q?=EC=96=B4=20=EB=83=84=20=E2=80=94=20924=20=E2=86=92=20522?= =?UTF-8?q?=EC=A4=84=20(=E2=91=A0/=E2=91=A1=20=EC=A4=91=20=EC=B2=AB=20?= =?UTF-8?q?=EC=A1=B0=EA=B0=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TS 짝이 2026-09-04 에 이미 이 선으로 갈라 두었음(`common_util_cross_design_geometry.ts`). **새로 긋는 선이 아니라 TS 에 있던 선을 파이썬에도 그은 것**이라 거울이 안 깨짐. - `_SectionGeometry` + `_side_role`(TS `sideRole`) → `…_Engine_Design_Geometry.py` - 안 옮긴 것 — 측구 방향 해석 · 지반 보간 · 사면 폐합 허용오차. **TS 도 본체에 둔 것들** - 계산은 **한 줄도 안 바꿈**. 옮기기만 함 검증 — 거울 시험(횡단 설계선·단면적 두 쪽 대조) 통과. 전체 610 통과 / 22 건너뜀. 남은 것 — ② 표준값 해석·저장분 읽기 도우미 분리(522줄이라 급하진 않음). Co-Authored-By: Claude Opus 5 (1M context) --- B06_Section/B06_Section_Engine_Design.py | 413 +---------------- .../B06_Section_Engine_Design_Geometry.py | 430 ++++++++++++++++++ 2 files changed, 433 insertions(+), 410 deletions(-) create mode 100644 B06_Section/B06_Section_Engine_Design_Geometry.py diff --git a/B06_Section/B06_Section_Engine_Design.py b/B06_Section/B06_Section_Engine_Design.py index 9680168c..849bfe3a 100644 --- a/B06_Section/B06_Section_Engine_Design.py +++ b/B06_Section/B06_Section_Engine_Design.py @@ -27,8 +27,6 @@ 경사비는 수평:수직 = ratio:1 (예: 1:1.2 → ratio=1.2). """ -import math -from collections.abc import Callable from typing import Any from B06_Section.B06_Section_Engine_Areas import ( @@ -38,12 +36,12 @@ from B06_Section.B06_Section_Engine_Areas import ( _split_ditch_area, _trapezoid_areas, ) +from B06_Section.B06_Section_Engine_Design_Geometry import ( + _SectionGeometry, +) from common_util.common_util_cross_berm import ( BermSpec, - cut_profile_points, - fill_profile_points, ) -from common_util.common_util_cross_berm import elevation_at as berm_elevation_at from common_util.common_util_spoil_fill import spoil_fill_section from config.config_system import ( CURVE_WIDENING_MAX_WIDTH_M, @@ -61,19 +59,6 @@ from config.config_system import ( _SLOPE_CLOSE_TOLERANCE_M = 0.01 -def _side_role(section_mode: str) -> tuple[str, str]: - """단면유형 → (좌측 역할, 우측 역할). 역할은 'cut' 또는 'fill'.""" - if section_mode == "left_cut": - return "cut", "fill" - if section_mode == "right_cut": - return "fill", "cut" - if section_mode == "both_cut": - return "cut", "cut" - if section_mode == "both_fill": - return "fill", "fill" - raise ValueError(f"지원하지 않는 단면유형입니다: {section_mode}") - - def _resolve_ditch_side(section_mode: str, ditch_side: str | None) -> str: """측구(배수) 배치 측을 결정한다. @@ -156,398 +141,6 @@ def _ground_interpolator(valid: list[tuple[float, float]]): return ground_at -class _SectionGeometry: - """설계선 피스와이즈 평가기. 노면 → 측구 → 사면 순으로 offset의 설계고를 계산한다.""" - - def __init__( - self, - *, - design_elevation_m: float, - group: dict[str, float], - section_mode: str, - ditch_side: str, - ditch_type: str, - cross_slope_pct: float, - ground_at: Callable[[float], float] | None = None, - soil_cut_ratio: float | None = None, - rock_boundary_offset_m: float | None = None, - two_stage_slope: bool = False, - ditch_enabled: bool | None = None, - widening_left_m: float = 0.0, - widening_right_m: float = 0.0, - berm: BermSpec | None = None, - ) -> None: - half_road = group["road_width_m"] / 2.0 - # 곡선부 확폭은 **한쪽으로만** 붙는다(2026-09-06 사용자 확정: 곡선 바깥쪽). - # 그래서 반폭을 좌·우로 나눠 든다 — 확폭이 0이면 예전과 똑같은 대칭 단면이다. - self.half_road_left = half_road + max(widening_left_m, 0.0) - self.half_road_right = half_road + max(widening_right_m, 0.0) - self.half_road = half_road # 규격 차도 반폭(확폭 전) — 수량·표기 기준 - self.left_extent = self.half_road_left + group["shoulder_left_m"] # 좌(+) 노면 끝 - self.right_extent = self.half_road_right + group["shoulder_right_m"] # 우(-) 노면 끝 - self.z_center = design_elevation_m - self.cut_ratio = max(group["cut_slope_ratio"], 1e-6) # 암 구간(하단) 절토 경사 - self.fill_ratio = max(group["fill_slope_ratio"], 1e-6) - self.left_role, self.right_role = _side_role(section_mode) - self.ditch_side = ditch_side - # 2단계 절토: 암반 경계선(지반선 + rock_boundary_offset) 아래는 암 경사(cut_ratio), - # 위는 토사 경사(soil_cut_ratio)를 쓴다. 경계 아래→위 전환점(무릎)을 측별로 미리 구한다. - self.soil_cut_ratio = max(soil_cut_ratio or group["cut_slope_ratio"], 1e-6) - self.two_stage = bool( - two_stage_slope and ground_at is not None and rock_boundary_offset_m is not None - ) - self._ground_at = ground_at - self._rock_offset = rock_boundary_offset_m or 0.0 - # 소단 제원(없으면 None) — 절토 사면 꼭짓점 셈에 그대로 넘어간다. - self.berm = berm - self._cut_points_cache: dict[str, list[tuple[float, float]]] = {} - self._fill_points_cache: dict[str, list[tuple[float, float]]] = {} - # 절토 사면·지반 최초 교차거리(측별 캐시) — 교차 후 절토 종료용(N-2-4). - self._cut_cross: dict[str, float | None] = {} - self._fill_cross: dict[str, float | None] = {} - self.ditch_type = ditch_type - # 횡단경사: 측구 방향으로 내려가는 단일 사면 (좌=+offset 규약). - slope = cross_slope_pct / 100.0 - self.slope_per_offset = -slope if ditch_side == "left" else slope - - # 단면 유형 자동 판정(D-2): 각 측 절/성토 역할을 노면 끝 지반이 설계면보다 - # 높은지(절토)/낮은지(성토)로 결정한다. 좌절/우절/양절/양성이 모두 지형에서 - # 자연 도출된다. 사용자 입력 section_mode는 측구 방향(ditch_side) 기본값에만 쓰고 - # 절/성토 역할은 손대지 않는다. ground_at이 없으면 section_mode 기반 역할을 쓴다. - if ground_at is not None: - self.left_role = ( - "cut" - if ground_at(self.left_extent) > self.road_z(self.left_extent) + 1e-3 - else "fill" - ) - self.right_role = ( - "cut" - if ground_at(-self.right_extent) > self.road_z(-self.right_extent) + 1e-3 - else "fill" - ) - - # 측구 생성 여부(D-1): 양성은 항상 미생성. ditch_enabled가 오면 그 값을 따르고(수동 - # override), None이면 자동 판정 — 측구측 노면 끝에서 지반이 설계면보다 높으면(절토 - # 상황) 생성, 낮으면(성토 상황, 자연 배수) 미생성. ground_at 없으면 보수적으로 생성. - if section_mode == "both_fill": - self.has_ditch = False - elif ditch_enabled is not None: - self.has_ditch = ditch_enabled - elif ground_at is not None: - ditch_edge = self.left_extent if ditch_side == "left" else -self.right_extent - self.has_ditch = ground_at(ditch_edge) > self.road_z(ditch_edge) + 1e-3 - else: - self.has_ditch = True - - # 측구 꼭짓점(측구측 노면 끝 기준, 바깥 방향 부호 적용). - self.ditch_points: list[tuple[float, float]] = [] - edge_offset = self.left_extent if ditch_side == "left" else -self.right_extent - outward = 1.0 if ditch_side == "left" else -1.0 - edge_z = self.road_z(edge_offset) - if self.has_ditch: - if ditch_type == "l_type": - # L형: 노면 끝에서 폭 W 동안 깊이 D로 내려가는 경사 바닥 + 바깥 수직벽. - # 바깥(노견 반대측) 상단은 **노견과 같은 표고**로 닫는다 — 사면 시작점이 - # 측구 바닥 높이로 내려가면 안 된다(2026-08-23 사용자 지시). - width = group["l_ditch_width_m"] - depth = group["l_ditch_depth_m"] - self.ditch_points = [ - (edge_offset, edge_z), - (edge_offset + outward * width, edge_z - depth), - (edge_offset + outward * width, edge_z), - ] - else: - # 일반: 상단폭/저폭/깊이 사다리꼴. - top = group["ditch_top_width_m"] - bottom = min(group["ditch_bottom_width_m"], top) - depth = group["ditch_depth_m"] - inset = (top - bottom) / 2.0 - self.ditch_points = [ - (edge_offset, edge_z), - (edge_offset + outward * inset, edge_z - depth), - (edge_offset + outward * (inset + bottom), edge_z - depth), - (edge_offset + outward * top, edge_z), - ] - - def road_z(self, offset_m: float) -> float: - """노면(노견 포함) 설계고 — 중심 계획고에서 횡단경사로 기운 단일 평면.""" - return self.z_center + self.slope_per_offset * offset_m - - def _slope_start(self, side: str) -> tuple[float, float]: - """사면 시작점(오프셋 절대값 기준 거리, 표고)을 계산한다.""" - if side == "left": - edge_offset, edge_z = self.left_extent, self.road_z(self.left_extent) - else: - edge_offset, edge_z = self.right_extent, self.road_z(-self.right_extent) - if side == self.ditch_side and self.ditch_points: - outer = self.ditch_points[-1] - return abs(outer[0]), outer[1] - return edge_offset, edge_z - - def _rock_boundary_z(self, side: str, dist: float) -> float: - """측·거리(절대 오프셋)에서 암반 경계선 표고 = 지반선 + 오프셋(음수=하향).""" - signed = dist if side == "left" else -dist - assert self._ground_at is not None # two_stage일 때만 호출 - return self._ground_at(signed) + self._rock_offset - - def cut_points(self, side: str) -> list[tuple[float, float]]: - """절토 사면 꼭짓점 `[(거리, 표고), ...]` — 무릎과 소단이 모두 여기 들어 있다. - - 셈은 짝 모듈 `common_util_cross_berm` 한 벌이 한다(TS 도 같은 것을 부른다). - 소단이 없으면 종전 무릎 방식과 **같은 값**이다(동치 시험으로 지킨다). - """ - if side in self._cut_points_cache: - return self._cut_points_cache[side] - start_dist, start_z = self._slope_start(side) - boundary = (lambda dist: self._rock_boundary_z(side, dist)) if self.two_stage else None - points = cut_profile_points( - start_dist, - start_z, - self.cut_ratio, - self.soil_cut_ratio, - boundary, - self.berm, - # 소단이 있으면 경계를 오갈 때마다 꺾는다 — 소단은 평탄한데 경계선은 지반을 - # 따라 올라가서 되돌아 들어가는 일이 흔하다. 한 번만 꺾으면 그 구간을 암인데 - # 토사 경사로 그려 절토가 조용히 커진다(2026-09-07). - multi_knee=self.berm is not None, - ) - self._cut_points_cache[side] = points - return points - - def _cut_slope_z(self, side: str, dist: float) -> float: - """절토 사면선 표고(무릎·소단 반영). 지반 교차 클램프는 하지 않는다.""" - return berm_elevation_at(self.cut_points(side), dist) - - def fill_points(self, side: str) -> list[tuple[float, float]]: - """성토 사면 꼭짓점 — 소단이 들어 있다. 절토와 달리 무릎은 없다.""" - if side in self._fill_points_cache: - return self._fill_points_cache[side] - start_dist, start_z = self._slope_start(side) - points = fill_profile_points(start_dist, start_z, self.fill_ratio, self.berm) - self._fill_points_cache[side] = points - return points - - def _fill_slope_z(self, side: str, dist: float) -> float: - """성토 사면선 표고(소단 반영). 지반 교차 클램프는 하지 않는다.""" - return berm_elevation_at(self.fill_points(side), dist) - - def cut_slope_segments(self) -> list[dict[str, Any]]: - """절토 사면을 **경사 구간별로** 쪼갠 목록. - - ⚠ **지금 이 값을 읽는 곳은 없다**(2026-09-07). 임자였던 별표2 법정 경사 검사가 폐기됐고 - (암질을 횡단도에서 안 고르기로 사용자 확정), 저장분에도 안 들어간다. 소단 기하가 이 셈 - 위에 서 있어 남겨 둔다 — **되살릴 때는 저장분에서 읽지 말고 계산해서 쓸 것.** - - 원래 필요했던 까닭(되살릴 때 그대로 유효) — 소단이 서면 사면 전체를 하나로 재는 - 「실효 경사」가 완만해져 **위반이 사라진 것처럼** 보인다(폭 1.0·간격 2 이면 설계 1:1 이 - 실효 1:1.71). 검사는 소단을 뺀 **사면 구간 자체의 경사**를 봐야 한다. - - · 평탄부(소단)는 싣지 않는다 — 경사 구간이 아니고 경사비가 무한대가 된다. - · 지반과 만난 뒤 구간도 싣지 않는다 — 절토가 아니다. - · `material` 은 암반 경계 기준 `rock`/`soil`. 경계를 모르면(2단계 아님) None. - 암을 다시 가르는 값은 측점의 `cut_rock_kind` 를 읽는다(구간에 싣지 않는다). - """ - segments: list[dict[str, Any]] = [] - for side in ("left", "right"): - role = self.left_role if side == "left" else self.right_role - if role != "cut": - continue - cross = self.cut_cross_dist(side) - points = self.cut_points(side) - sign = 1.0 if side == "left" else -1.0 - for index in range(1, len(points)): - start_d, start_z = points[index - 1] - end_d, end_z = points[index] - if cross is not None and start_d >= cross - 1e-9: - break # 지반과 만난 뒤는 절토가 없다 - if cross is not None and end_d > cross: - # 지반과 만나는 점에서 구간을 자른다. - end_z = berm_elevation_at(points, cross) - end_d = cross - run = end_d - start_d - rise = end_z - start_z - if run <= 1e-9 or rise <= 1e-6: - continue # 길이 0·역방향은 검사 대상이 아니다 - if self.berm is not None and abs(run - self.berm.width_m) < 1e-6: - # 소단(평탄부) — 폭이 딱 맞고 오름이 기울기(2°)만큼이면 그것이다. - berm_rise = math.tan(math.radians(self.berm.slope_deg)) * self.berm.width_m - if abs(rise - berm_rise) < 1e-9: - continue - # 재료는 **그 구간을 실제로 그린 경사비**로 가른다 — 경계선을 다시 재면 - # 안 된다. 무릎을 지난 뒤에도 경계선은 지반을 따라 계속 오르므로, 토사 - # 경사로 그린 구간이 경계 아래로 되돌아가 있는 일이 흔하다. 그것을 경계로 - # 재면 「경사비는 토사인데 재료는 암」인 구간이 생긴다(2026-09-07 다른 창 - # 실측: 용화 63측점에서 13구간). 그린 대로 적는 것이 맞다. - material: str | None = None - if self.two_stage and abs(self.soil_cut_ratio - self.cut_ratio) > 1e-9: - drawn = run / rise - material = ( - "soil" - if abs(drawn - self.soil_cut_ratio) < abs(drawn - self.cut_ratio) - else "rock" - ) - segments.append( - { - "side": side, - "ratio": round(run / rise, 4), - "rise_m": round(rise, 4), - "run_m": round(run, 4), - "start_offset_m": round(sign * start_d, 4), - "end_offset_m": round(sign * end_d, 4), - "material": material, - } - ) - return segments - - def cut_cross_dist(self, side: str) -> float | None: - """절토 사면이 지반선과 처음 만나는 거리(절대 오프셋). 이후는 절토 없음(N-2-4). - - 지면과 1회 교차하면 그다음 경사(2단계 전환 포함)는 의미가 없으므로 교차점에서 - 절토를 종료한다. 시작(노면 끝)부터 사면이 지반 위면 교차거리=시작(절토 없음), - 끝까지 못 만나면 None. - """ - if side in self._cut_cross: - return self._cut_cross[side] - result: float | None = None - if self._ground_at is not None: - start_dist, _start_z = self._slope_start(side) - step = 0.05 - dist = start_dist - max_dist = start_dist + 500.0 - while dist <= max_dist: - signed = dist if side == "left" else -dist - if self._cut_slope_z(side, dist) - self._ground_at(signed) >= 0: - result = dist - break - dist += step - self._cut_cross[side] = result - return result - - def fill_ground_slope(self) -> float | None: - """성토측 **자연 지반**의 평균 경사(rise/run, 무차원). 성토측이 없으면 None. - - 자연방토 판정에 쓴다 — 지반이 가파르면 부어 놓은 흙이 쌓이지 않고 흘러내린다. - 구간은 노면 끝(사면 시작)부터 성토 사면이 지반과 처음 만나는 곳까지이며, 끝까지 - 만나지 못하면 10m를 본다. 양쪽이 다 성토면 **완만한 쪽**을 택한다(보수적 판정). - """ - if self._ground_at is None: - return None - slopes: list[float] = [] - for side in ("left", "right"): - role = self.left_role if side == "left" else self.right_role - if role != "fill": - continue - start_dist, _start_z = self._slope_start(side) - end_dist = self.fill_cross_dist(side) or (start_dist + 10.0) - run = end_dist - start_dist - if run <= 1e-6: - continue - sign = 1.0 if side == "left" else -1.0 - rise = abs(self._ground_at(sign * end_dist) - self._ground_at(sign * start_dist)) - slopes.append(rise / run) - return min(slopes) if slopes else None - - def fill_cross_dist(self, side: str) -> float | None: - """성토 사면이 지반선과 **처음** 만나는 거리(절대 오프셋). 이후는 성토 없음. - - 절토(cut_cross_dist)와 같은 규칙이다. 실제 지면은 울퉁불퉁해서 성토 사면이 지반과 - 여러 번 만날 수 있는데, **첫 교차점이 성토사면의 끝**이고 그 바깥은 손대지 않은 - 지반이다(2026-08-02 사용자 지시). `max(fill_line, ground)`만 쓰면 지반이 다시 꺼졌을 때 - 성토 사면이 되살아나 사면이 끊겼다 이어지는 그림이 나온다. - - 시작(노면 끝)부터 사면이 지반 아래면 교차거리=시작(성토 없음), 끝까지 못 만나면 None. - """ - if side in self._fill_cross: - return self._fill_cross[side] - result: float | None = None - if self._ground_at is not None: - start_dist, start_z = self._slope_start(side) - step = 0.05 - dist = start_dist - max_dist = start_dist + 500.0 - while dist <= max_dist: - signed = dist if side == "left" else -dist - fill_line = self._fill_slope_z(side, dist) - if fill_line - self._ground_at(signed) <= 0: - result = dist - break - dist += step - self._fill_cross[side] = result - return result - - def design_z(self, offset_m: float, ground_m: float) -> float: - """offset 하나의 설계 표고(사면은 지반 교차점 이후 지반 추종).""" - side = "left" if offset_m >= 0 else "right" - extent = self.left_extent if side == "left" else self.right_extent - if abs(offset_m) <= extent + 1e-9: - return self.road_z(offset_m) - # 측구 구간: 꼭짓점 사이 선형 보간(지반 무관 강제 굴착). - if side == self.ditch_side and self.ditch_points: - inner = abs(self.ditch_points[0][0]) - outer = abs(self.ditch_points[-1][0]) - if inner - 1e-9 <= abs(offset_m) <= outer + 1e-9: - points = self.ditch_points - for index in range(1, len(points)): - x0, z0 = abs(points[index - 1][0]), points[index - 1][1] - x1, z1 = abs(points[index][0]), points[index][1] - if abs(offset_m) > x1 + 1e-9: - continue - span = x1 - x0 - if span <= 1e-9: - return z1 - ratio = (abs(offset_m) - x0) / span - return z0 + (z1 - z0) * ratio - return points[-1][1] - role = self.left_role if side == "left" else self.right_role - dist = abs(offset_m) - if role == "cut": - # 지반과 1회 교차하면 그 이후 절토는 의미 없음 → 지반 추종(N-2-4). - cross = self.cut_cross_dist(side) - if cross is not None and dist >= cross: - return ground_m - return min(self._cut_slope_z(side, dist), ground_m) - # 지반과 1회 교차하면 그 바깥은 성토가 아니라 원지반이다(절토와 같은 규칙). - cross = self.fill_cross_dist(side) - if cross is not None and dist >= cross: - return ground_m - return max(self._fill_slope_z(side, dist), ground_m) - - def breakpoints(self) -> list[float]: - """적분·설계선에 반드시 포함할 설계 꼭짓점 오프셋 목록(2단계 무릎·소단 포함).""" - points = [0.0, self.left_extent, -self.right_extent] - points.extend(offset for offset, _z in self.ditch_points) - # 절토 사면 꼭짓점(무릎·소단 모서리) — 빠뜨리면 계단이 설계선에 안 실린다. - if self.two_stage or self.berm is not None: - for side in ("left", "right"): - role = self.left_role if side == "left" else self.right_role - if role != "cut": - continue - cross = self.cut_cross_dist(side) - for offset, _z in self.cut_points(side): - if cross is not None and offset > cross + 1e-9: - break # 지반과 만난 뒤는 절토가 없다 - points.append(offset if side == "left" else -offset) - # 성토 사면 소단 모서리 — 절토와 같은 까닭으로 설계선에 실어야 계단이 그려진다. - if self.berm is not None: - for side in ("left", "right"): - role = self.left_role if side == "left" else self.right_role - if role != "fill": - continue - cross = self.fill_cross_dist(side) - for offset, _z in self.fill_points(side): - if cross is not None and offset > cross + 1e-9: - break - points.append(offset if side == "left" else -offset) - # 절·성토 사면과 지반의 **첫** 교차점을 꼭짓점에 넣어 면적 절단을 정확히 한다(N-2-4). - for side in ("left", "right"): - role = self.left_role if side == "left" else self.right_role - cross = self.cut_cross_dist(side) if role == "cut" else self.fill_cross_dist(side) - if cross is not None: - points.append(cross if side == "left" else -cross) - return points - - def curve_widening_args(section: dict[str, Any] | None) -> dict[str, Any]: """측점 기록에서 곡선부 확폭 입력을 뽑는다 — `compute_cross_design(**...)` 로 넘긴다. diff --git a/B06_Section/B06_Section_Engine_Design_Geometry.py b/B06_Section/B06_Section_Engine_Design_Geometry.py new file mode 100644 index 00000000..9caba13f --- /dev/null +++ b/B06_Section/B06_Section_Engine_Design_Geometry.py @@ -0,0 +1,430 @@ +"""횡단 설계선의 **기하** — 노면 → 측구 → 사면 순으로 offset 의 설계고를 낸다. + +⚠⚠ **TS 짝과 같은 선에서 갈랐다** — `common_util/common_util_cross_design_geometry.ts`. + TS 는 2026-09-04 에 이미 이 선으로 떨어져 있었고, 파이썬만 한 파일에 붙어 있어 + 924줄이 됐다(700줄 제한 초과). **새로 긋는 선이 아니라 TS 에 있던 선을 이쪽에도 그은 것** + 이라 거울 시험(`tmp/tests/test_b06_cross_design_mirror.py`)이 그대로 돈다. + + 같이 옮긴 것 — `_side_role`(TS `sideRole`). 클래스만 쓰는 도우미다. + 안 옮긴 것 — 측구 방향 해석·지반 보간·사면 폐합 허용오차는 TS 도 본체에 두었다. + +⚠ 계산은 **한 줄도 바꾸지 않았다.** 옮기기만 한 것이라, 값이 달라지면 옮기다 흘린 것이다. +""" + +from __future__ import annotations + +import math +from collections.abc import Callable +from typing import Any + +from common_util.common_util_cross_berm import ( + BermSpec, + cut_profile_points, + fill_profile_points, +) +from common_util.common_util_cross_berm import elevation_at as berm_elevation_at + + +def _side_role(section_mode: str) -> tuple[str, str]: + """단면유형 → (좌측 역할, 우측 역할). 역할은 'cut' 또는 'fill'.""" + if section_mode == "left_cut": + return "cut", "fill" + if section_mode == "right_cut": + return "fill", "cut" + if section_mode == "both_cut": + return "cut", "cut" + if section_mode == "both_fill": + return "fill", "fill" + raise ValueError(f"지원하지 않는 단면유형입니다: {section_mode}") + + +class _SectionGeometry: + """설계선 피스와이즈 평가기. 노면 → 측구 → 사면 순으로 offset의 설계고를 계산한다.""" + + def __init__( + self, + *, + design_elevation_m: float, + group: dict[str, float], + section_mode: str, + ditch_side: str, + ditch_type: str, + cross_slope_pct: float, + ground_at: Callable[[float], float] | None = None, + soil_cut_ratio: float | None = None, + rock_boundary_offset_m: float | None = None, + two_stage_slope: bool = False, + ditch_enabled: bool | None = None, + widening_left_m: float = 0.0, + widening_right_m: float = 0.0, + berm: BermSpec | None = None, + ) -> None: + half_road = group["road_width_m"] / 2.0 + # 곡선부 확폭은 **한쪽으로만** 붙는다(2026-09-06 사용자 확정: 곡선 바깥쪽). + # 그래서 반폭을 좌·우로 나눠 든다 — 확폭이 0이면 예전과 똑같은 대칭 단면이다. + self.half_road_left = half_road + max(widening_left_m, 0.0) + self.half_road_right = half_road + max(widening_right_m, 0.0) + self.half_road = half_road # 규격 차도 반폭(확폭 전) — 수량·표기 기준 + self.left_extent = self.half_road_left + group["shoulder_left_m"] # 좌(+) 노면 끝 + self.right_extent = self.half_road_right + group["shoulder_right_m"] # 우(-) 노면 끝 + self.z_center = design_elevation_m + self.cut_ratio = max(group["cut_slope_ratio"], 1e-6) # 암 구간(하단) 절토 경사 + self.fill_ratio = max(group["fill_slope_ratio"], 1e-6) + self.left_role, self.right_role = _side_role(section_mode) + self.ditch_side = ditch_side + # 2단계 절토: 암반 경계선(지반선 + rock_boundary_offset) 아래는 암 경사(cut_ratio), + # 위는 토사 경사(soil_cut_ratio)를 쓴다. 경계 아래→위 전환점(무릎)을 측별로 미리 구한다. + self.soil_cut_ratio = max(soil_cut_ratio or group["cut_slope_ratio"], 1e-6) + self.two_stage = bool( + two_stage_slope and ground_at is not None and rock_boundary_offset_m is not None + ) + self._ground_at = ground_at + self._rock_offset = rock_boundary_offset_m or 0.0 + # 소단 제원(없으면 None) — 절토 사면 꼭짓점 셈에 그대로 넘어간다. + self.berm = berm + self._cut_points_cache: dict[str, list[tuple[float, float]]] = {} + self._fill_points_cache: dict[str, list[tuple[float, float]]] = {} + # 절토 사면·지반 최초 교차거리(측별 캐시) — 교차 후 절토 종료용(N-2-4). + self._cut_cross: dict[str, float | None] = {} + self._fill_cross: dict[str, float | None] = {} + self.ditch_type = ditch_type + # 횡단경사: 측구 방향으로 내려가는 단일 사면 (좌=+offset 규약). + slope = cross_slope_pct / 100.0 + self.slope_per_offset = -slope if ditch_side == "left" else slope + + # 단면 유형 자동 판정(D-2): 각 측 절/성토 역할을 노면 끝 지반이 설계면보다 + # 높은지(절토)/낮은지(성토)로 결정한다. 좌절/우절/양절/양성이 모두 지형에서 + # 자연 도출된다. 사용자 입력 section_mode는 측구 방향(ditch_side) 기본값에만 쓰고 + # 절/성토 역할은 손대지 않는다. ground_at이 없으면 section_mode 기반 역할을 쓴다. + if ground_at is not None: + self.left_role = ( + "cut" + if ground_at(self.left_extent) > self.road_z(self.left_extent) + 1e-3 + else "fill" + ) + self.right_role = ( + "cut" + if ground_at(-self.right_extent) > self.road_z(-self.right_extent) + 1e-3 + else "fill" + ) + + # 측구 생성 여부(D-1): 양성은 항상 미생성. ditch_enabled가 오면 그 값을 따르고(수동 + # override), None이면 자동 판정 — 측구측 노면 끝에서 지반이 설계면보다 높으면(절토 + # 상황) 생성, 낮으면(성토 상황, 자연 배수) 미생성. ground_at 없으면 보수적으로 생성. + if section_mode == "both_fill": + self.has_ditch = False + elif ditch_enabled is not None: + self.has_ditch = ditch_enabled + elif ground_at is not None: + ditch_edge = self.left_extent if ditch_side == "left" else -self.right_extent + self.has_ditch = ground_at(ditch_edge) > self.road_z(ditch_edge) + 1e-3 + else: + self.has_ditch = True + + # 측구 꼭짓점(측구측 노면 끝 기준, 바깥 방향 부호 적용). + self.ditch_points: list[tuple[float, float]] = [] + edge_offset = self.left_extent if ditch_side == "left" else -self.right_extent + outward = 1.0 if ditch_side == "left" else -1.0 + edge_z = self.road_z(edge_offset) + if self.has_ditch: + if ditch_type == "l_type": + # L형: 노면 끝에서 폭 W 동안 깊이 D로 내려가는 경사 바닥 + 바깥 수직벽. + # 바깥(노견 반대측) 상단은 **노견과 같은 표고**로 닫는다 — 사면 시작점이 + # 측구 바닥 높이로 내려가면 안 된다(2026-08-23 사용자 지시). + width = group["l_ditch_width_m"] + depth = group["l_ditch_depth_m"] + self.ditch_points = [ + (edge_offset, edge_z), + (edge_offset + outward * width, edge_z - depth), + (edge_offset + outward * width, edge_z), + ] + else: + # 일반: 상단폭/저폭/깊이 사다리꼴. + top = group["ditch_top_width_m"] + bottom = min(group["ditch_bottom_width_m"], top) + depth = group["ditch_depth_m"] + inset = (top - bottom) / 2.0 + self.ditch_points = [ + (edge_offset, edge_z), + (edge_offset + outward * inset, edge_z - depth), + (edge_offset + outward * (inset + bottom), edge_z - depth), + (edge_offset + outward * top, edge_z), + ] + + def road_z(self, offset_m: float) -> float: + """노면(노견 포함) 설계고 — 중심 계획고에서 횡단경사로 기운 단일 평면.""" + return self.z_center + self.slope_per_offset * offset_m + + def _slope_start(self, side: str) -> tuple[float, float]: + """사면 시작점(오프셋 절대값 기준 거리, 표고)을 계산한다.""" + if side == "left": + edge_offset, edge_z = self.left_extent, self.road_z(self.left_extent) + else: + edge_offset, edge_z = self.right_extent, self.road_z(-self.right_extent) + if side == self.ditch_side and self.ditch_points: + outer = self.ditch_points[-1] + return abs(outer[0]), outer[1] + return edge_offset, edge_z + + def _rock_boundary_z(self, side: str, dist: float) -> float: + """측·거리(절대 오프셋)에서 암반 경계선 표고 = 지반선 + 오프셋(음수=하향).""" + signed = dist if side == "left" else -dist + assert self._ground_at is not None # two_stage일 때만 호출 + return self._ground_at(signed) + self._rock_offset + + def cut_points(self, side: str) -> list[tuple[float, float]]: + """절토 사면 꼭짓점 `[(거리, 표고), ...]` — 무릎과 소단이 모두 여기 들어 있다. + + 셈은 짝 모듈 `common_util_cross_berm` 한 벌이 한다(TS 도 같은 것을 부른다). + 소단이 없으면 종전 무릎 방식과 **같은 값**이다(동치 시험으로 지킨다). + """ + if side in self._cut_points_cache: + return self._cut_points_cache[side] + start_dist, start_z = self._slope_start(side) + boundary = (lambda dist: self._rock_boundary_z(side, dist)) if self.two_stage else None + points = cut_profile_points( + start_dist, + start_z, + self.cut_ratio, + self.soil_cut_ratio, + boundary, + self.berm, + # 소단이 있으면 경계를 오갈 때마다 꺾는다 — 소단은 평탄한데 경계선은 지반을 + # 따라 올라가서 되돌아 들어가는 일이 흔하다. 한 번만 꺾으면 그 구간을 암인데 + # 토사 경사로 그려 절토가 조용히 커진다(2026-09-07). + multi_knee=self.berm is not None, + ) + self._cut_points_cache[side] = points + return points + + def _cut_slope_z(self, side: str, dist: float) -> float: + """절토 사면선 표고(무릎·소단 반영). 지반 교차 클램프는 하지 않는다.""" + return berm_elevation_at(self.cut_points(side), dist) + + def fill_points(self, side: str) -> list[tuple[float, float]]: + """성토 사면 꼭짓점 — 소단이 들어 있다. 절토와 달리 무릎은 없다.""" + if side in self._fill_points_cache: + return self._fill_points_cache[side] + start_dist, start_z = self._slope_start(side) + points = fill_profile_points(start_dist, start_z, self.fill_ratio, self.berm) + self._fill_points_cache[side] = points + return points + + def _fill_slope_z(self, side: str, dist: float) -> float: + """성토 사면선 표고(소단 반영). 지반 교차 클램프는 하지 않는다.""" + return berm_elevation_at(self.fill_points(side), dist) + + def cut_slope_segments(self) -> list[dict[str, Any]]: + """절토 사면을 **경사 구간별로** 쪼갠 목록. + + ⚠ **지금 이 값을 읽는 곳은 없다**(2026-09-07). 임자였던 별표2 법정 경사 검사가 폐기됐고 + (암질을 횡단도에서 안 고르기로 사용자 확정), 저장분에도 안 들어간다. 소단 기하가 이 셈 + 위에 서 있어 남겨 둔다 — **되살릴 때는 저장분에서 읽지 말고 계산해서 쓸 것.** + + 원래 필요했던 까닭(되살릴 때 그대로 유효) — 소단이 서면 사면 전체를 하나로 재는 + 「실효 경사」가 완만해져 **위반이 사라진 것처럼** 보인다(폭 1.0·간격 2 이면 설계 1:1 이 + 실효 1:1.71). 검사는 소단을 뺀 **사면 구간 자체의 경사**를 봐야 한다. + + · 평탄부(소단)는 싣지 않는다 — 경사 구간이 아니고 경사비가 무한대가 된다. + · 지반과 만난 뒤 구간도 싣지 않는다 — 절토가 아니다. + · `material` 은 암반 경계 기준 `rock`/`soil`. 경계를 모르면(2단계 아님) None. + 암을 다시 가르는 값은 측점의 `cut_rock_kind` 를 읽는다(구간에 싣지 않는다). + """ + segments: list[dict[str, Any]] = [] + for side in ("left", "right"): + role = self.left_role if side == "left" else self.right_role + if role != "cut": + continue + cross = self.cut_cross_dist(side) + points = self.cut_points(side) + sign = 1.0 if side == "left" else -1.0 + for index in range(1, len(points)): + start_d, start_z = points[index - 1] + end_d, end_z = points[index] + if cross is not None and start_d >= cross - 1e-9: + break # 지반과 만난 뒤는 절토가 없다 + if cross is not None and end_d > cross: + # 지반과 만나는 점에서 구간을 자른다. + end_z = berm_elevation_at(points, cross) + end_d = cross + run = end_d - start_d + rise = end_z - start_z + if run <= 1e-9 or rise <= 1e-6: + continue # 길이 0·역방향은 검사 대상이 아니다 + if self.berm is not None and abs(run - self.berm.width_m) < 1e-6: + # 소단(평탄부) — 폭이 딱 맞고 오름이 기울기(2°)만큼이면 그것이다. + berm_rise = math.tan(math.radians(self.berm.slope_deg)) * self.berm.width_m + if abs(rise - berm_rise) < 1e-9: + continue + # 재료는 **그 구간을 실제로 그린 경사비**로 가른다 — 경계선을 다시 재면 + # 안 된다. 무릎을 지난 뒤에도 경계선은 지반을 따라 계속 오르므로, 토사 + # 경사로 그린 구간이 경계 아래로 되돌아가 있는 일이 흔하다. 그것을 경계로 + # 재면 「경사비는 토사인데 재료는 암」인 구간이 생긴다(2026-09-07 다른 창 + # 실측: 용화 63측점에서 13구간). 그린 대로 적는 것이 맞다. + material: str | None = None + if self.two_stage and abs(self.soil_cut_ratio - self.cut_ratio) > 1e-9: + drawn = run / rise + material = ( + "soil" + if abs(drawn - self.soil_cut_ratio) < abs(drawn - self.cut_ratio) + else "rock" + ) + segments.append( + { + "side": side, + "ratio": round(run / rise, 4), + "rise_m": round(rise, 4), + "run_m": round(run, 4), + "start_offset_m": round(sign * start_d, 4), + "end_offset_m": round(sign * end_d, 4), + "material": material, + } + ) + return segments + + def cut_cross_dist(self, side: str) -> float | None: + """절토 사면이 지반선과 처음 만나는 거리(절대 오프셋). 이후는 절토 없음(N-2-4). + + 지면과 1회 교차하면 그다음 경사(2단계 전환 포함)는 의미가 없으므로 교차점에서 + 절토를 종료한다. 시작(노면 끝)부터 사면이 지반 위면 교차거리=시작(절토 없음), + 끝까지 못 만나면 None. + """ + if side in self._cut_cross: + return self._cut_cross[side] + result: float | None = None + if self._ground_at is not None: + start_dist, _start_z = self._slope_start(side) + step = 0.05 + dist = start_dist + max_dist = start_dist + 500.0 + while dist <= max_dist: + signed = dist if side == "left" else -dist + if self._cut_slope_z(side, dist) - self._ground_at(signed) >= 0: + result = dist + break + dist += step + self._cut_cross[side] = result + return result + + def fill_ground_slope(self) -> float | None: + """성토측 **자연 지반**의 평균 경사(rise/run, 무차원). 성토측이 없으면 None. + + 자연방토 판정에 쓴다 — 지반이 가파르면 부어 놓은 흙이 쌓이지 않고 흘러내린다. + 구간은 노면 끝(사면 시작)부터 성토 사면이 지반과 처음 만나는 곳까지이며, 끝까지 + 만나지 못하면 10m를 본다. 양쪽이 다 성토면 **완만한 쪽**을 택한다(보수적 판정). + """ + if self._ground_at is None: + return None + slopes: list[float] = [] + for side in ("left", "right"): + role = self.left_role if side == "left" else self.right_role + if role != "fill": + continue + start_dist, _start_z = self._slope_start(side) + end_dist = self.fill_cross_dist(side) or (start_dist + 10.0) + run = end_dist - start_dist + if run <= 1e-6: + continue + sign = 1.0 if side == "left" else -1.0 + rise = abs(self._ground_at(sign * end_dist) - self._ground_at(sign * start_dist)) + slopes.append(rise / run) + return min(slopes) if slopes else None + + def fill_cross_dist(self, side: str) -> float | None: + """성토 사면이 지반선과 **처음** 만나는 거리(절대 오프셋). 이후는 성토 없음. + + 절토(cut_cross_dist)와 같은 규칙이다. 실제 지면은 울퉁불퉁해서 성토 사면이 지반과 + 여러 번 만날 수 있는데, **첫 교차점이 성토사면의 끝**이고 그 바깥은 손대지 않은 + 지반이다(2026-08-02 사용자 지시). `max(fill_line, ground)`만 쓰면 지반이 다시 꺼졌을 때 + 성토 사면이 되살아나 사면이 끊겼다 이어지는 그림이 나온다. + + 시작(노면 끝)부터 사면이 지반 아래면 교차거리=시작(성토 없음), 끝까지 못 만나면 None. + """ + if side in self._fill_cross: + return self._fill_cross[side] + result: float | None = None + if self._ground_at is not None: + start_dist, start_z = self._slope_start(side) + step = 0.05 + dist = start_dist + max_dist = start_dist + 500.0 + while dist <= max_dist: + signed = dist if side == "left" else -dist + fill_line = self._fill_slope_z(side, dist) + if fill_line - self._ground_at(signed) <= 0: + result = dist + break + dist += step + self._fill_cross[side] = result + return result + + def design_z(self, offset_m: float, ground_m: float) -> float: + """offset 하나의 설계 표고(사면은 지반 교차점 이후 지반 추종).""" + side = "left" if offset_m >= 0 else "right" + extent = self.left_extent if side == "left" else self.right_extent + if abs(offset_m) <= extent + 1e-9: + return self.road_z(offset_m) + # 측구 구간: 꼭짓점 사이 선형 보간(지반 무관 강제 굴착). + if side == self.ditch_side and self.ditch_points: + inner = abs(self.ditch_points[0][0]) + outer = abs(self.ditch_points[-1][0]) + if inner - 1e-9 <= abs(offset_m) <= outer + 1e-9: + points = self.ditch_points + for index in range(1, len(points)): + x0, z0 = abs(points[index - 1][0]), points[index - 1][1] + x1, z1 = abs(points[index][0]), points[index][1] + if abs(offset_m) > x1 + 1e-9: + continue + span = x1 - x0 + if span <= 1e-9: + return z1 + ratio = (abs(offset_m) - x0) / span + return z0 + (z1 - z0) * ratio + return points[-1][1] + role = self.left_role if side == "left" else self.right_role + dist = abs(offset_m) + if role == "cut": + # 지반과 1회 교차하면 그 이후 절토는 의미 없음 → 지반 추종(N-2-4). + cross = self.cut_cross_dist(side) + if cross is not None and dist >= cross: + return ground_m + return min(self._cut_slope_z(side, dist), ground_m) + # 지반과 1회 교차하면 그 바깥은 성토가 아니라 원지반이다(절토와 같은 규칙). + cross = self.fill_cross_dist(side) + if cross is not None and dist >= cross: + return ground_m + return max(self._fill_slope_z(side, dist), ground_m) + + def breakpoints(self) -> list[float]: + """적분·설계선에 반드시 포함할 설계 꼭짓점 오프셋 목록(2단계 무릎·소단 포함).""" + points = [0.0, self.left_extent, -self.right_extent] + points.extend(offset for offset, _z in self.ditch_points) + # 절토 사면 꼭짓점(무릎·소단 모서리) — 빠뜨리면 계단이 설계선에 안 실린다. + if self.two_stage or self.berm is not None: + for side in ("left", "right"): + role = self.left_role if side == "left" else self.right_role + if role != "cut": + continue + cross = self.cut_cross_dist(side) + for offset, _z in self.cut_points(side): + if cross is not None and offset > cross + 1e-9: + break # 지반과 만난 뒤는 절토가 없다 + points.append(offset if side == "left" else -offset) + # 성토 사면 소단 모서리 — 절토와 같은 까닭으로 설계선에 실어야 계단이 그려진다. + if self.berm is not None: + for side in ("left", "right"): + role = self.left_role if side == "left" else self.right_role + if role != "fill": + continue + cross = self.fill_cross_dist(side) + for offset, _z in self.fill_points(side): + if cross is not None and offset > cross + 1e-9: + break + points.append(offset if side == "left" else -offset) + # 절·성토 사면과 지반의 **첫** 교차점을 꼭짓점에 넣어 면적 절단을 정확히 한다(N-2-4). + for side in ("left", "right"): + role = self.left_role if side == "left" else self.right_role + cross = self.cut_cross_dist(side) if role == "cut" else self.fill_cross_dist(side) + if cross is not None: + points.append(cross if side == "left" else -cross) + return points From f4f7136357197c33aea48320a7d7db6cca8dd47e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 12:42:46 +0900 Subject: [PATCH 4/9] =?UTF-8?q?fix(B08):=20=EC=A4=80=EB=B9=84=EA=B3=B5=20?= =?UTF-8?q?=EC=B6=95=20=EA=B0=90=EC=82=AC=20=EC=85=8B=20=E2=80=94=20?= =?UTF-8?q?=EB=A9=B4=EC=A0=81=200=20=EB=A7=89=EC=9D=8C=20=C2=B7=20?= =?UTF-8?q?=ED=91=9C=ED=86=A0=20=EB=8C=80=EC=83=81=20=EC=96=B4=EA=B8=8B?= =?UTF-8?q?=EB=82=A8=20=EC=82=AC=EC=9C=A0=20=C2=B7=20=EA=B7=9C=EC=A4=80?= =?UTF-8?q?=ED=8B=80=20=EC=A4=80=EC=9A=A9=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 표토제거: 대상 면적 0 ㎡ 에 0 ㎥ 를 「값 있음」으로 내고 있었음. 0 은 「없음」과 구별이 안 되고 받는 쪽이 「표토가 없는 노선」으로 읽음. 두께 미입력은 이미 막고 있었는데 면적 0 만 통과하던 자리. 운반 줄은 제거를 밑수로 삼아 함께 막힘. - 표토 대상 면적이 별표2 문언과 두 방향으로 어긋남 — 법은 「노면·절토대상지」인데 우리는 절토·성토 사면적을 다 더하고 노면(연장 × 노폭)은 안 셈. 성토대상지는 별표2 (7) 이 「제거·정리」로만 두어 운반·적치 의무를 안 검. 값은 그대로 두고 사유로만 드러냄 — 실무 확인 사항이라 임의로 확정하지 않음. - 비탈 규준틀 재료량이 「수평 값 준용」인 사실이 상수 주석에만 있고 화면 근거에는 없었음 — 값이 서면 어디서 온 값인지 안 보이던 자리. Co-Authored-By: Claude Opus 5 (1M context) --- .../B08_Quantity_Engine_Preparation.py | 40 ++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/B08_Quantity/B08_Quantity_Engine_Preparation.py b/B08_Quantity/B08_Quantity_Engine_Preparation.py index 9c1a82c0..52642610 100644 --- a/B08_Quantity/B08_Quantity_Engine_Preparation.py +++ b/B08_Quantity/B08_Quantity_Engine_Preparation.py @@ -146,6 +146,22 @@ def _topsoil_row(slope: dict[str, float], thickness_m: float | None) -> dict[str 쓴다(사면 계열의 면고르기 면적과 같은 자리). """ area = float(slope.get("face_dressing_fill", 0.0)) + float(slope.get("face_dressing_cut", 0.0)) + # ⚠ 면적이 0 이면 **0 ㎥ 를 「값 있음」으로 내지 않는다** — 0 은 「없음」과 구별이 안 되고, + # 받는 쪽이 「표토가 없는 노선」으로 읽는다. 절·성토 사면적이 0 인 임도는 성립하지 + # 않으므로 이 자리는 사실상 **사면표가 아직 안 선 것**이다(2026-09-09 감사에서 잡음). + if area <= 0: + return { + "group": "준비공", + "item": "표토제거", + "unit": "㎥", + "amount": None, + "status": STATUS_PENDING, + "reason": ( + "대상 면적이 0 ㎡ 입니다 — 사면표(면고르기 면적)가 아직 서지 않았습니다. " + "0 ㎥ 로 내면 「표토가 없는 노선」으로 읽히므로 값을 세우지 않습니다" + ), + "work_item_code": "FP-09-15", + } if thickness_m is None or float(thickness_m) <= 0: return { "group": "준비공", @@ -168,11 +184,26 @@ def _topsoil_row(slope: dict[str, float], thickness_m: float | None) -> dict[str "unit": "㎥", "amount": area * thickness, "status": STATUS_READY, - "reason": f"사면적 {area:,.1f}㎡ × 두께 {thickness:g}m (품셈 9-15)", + "reason": (f"사면적 {area:,.1f}㎡ × 두께 {thickness:g}m (품셈 9-15) · {TOPSOIL_AREA_GAP}"), "work_item_code": "FP-09-15", } +#: ⚠⚠ **대상 면적이 법 문언과 어긋난다**(2026-09-09 준비공 축 감사에서 잡음). +#: 별표2 Ⅰ.2.차.(6) 은 대상을 **「노면·절토대상지」**로 못박고, 성토대상지는 (7) 에서 +#: 「표토 등은 **제거·정리**한다」로 따로 두어 **운반·적치 의무를 안 건다.** +#: 우리는 `face_dressing_cut + face_dressing_fill` 로 **절토·성토 사면적을 다 더하고** +#: **노면(연장 × 노폭)은 안 센다** — 두 방향으로 어긋난 셈이다. +#: ㉡ 계열(「또는」을 다 더함) — 성토 사면까지 운반·적치 물량에 실림 +#: 빠짐 — 노면 표토가 아예 안 섬(법이 첫째로 든 대상) +#: ⚠ **값을 임의로 고치지 않는다** — 실무가 사면적 한 덩이로 잡는지, 노면을 따로 잡는지가 +#: 사용자·실무자 확인 사항이다. 지금은 **사유로 드러내기만** 한다. +TOPSOIL_AREA_GAP = ( + "⚠ 대상 면적 확인 필요 — 별표2 는 「노면·절토대상지」로 두는데 우리는 절토·성토" + " **사면적을 다 더하고 노면은 안 셈**. 성토대상지는 별표2 (7) 이 「제거·정리」로만 두어" + " 운반·적치 의무를 안 검. 실무 확인 뒤 대상을 확정할 것" +) + #: 표토 운반·적치 — ⚠ **법이 요구하는데 우리가 제거만 세고 있던 자리**(2026-09-09). #: 시행규칙 별표2 Ⅰ.2.차.(6)·Ⅰ.3.카.(6): #: 「노면·절토대상지에 있는 입목…과 그 뿌리, **표토는 전량 제거한 후** 강우 시 유실되거나 @@ -434,6 +465,13 @@ def frame_material_rows( "basis": ( f"{frame.get('item')} {float(count):g}개소 × {per_ea:g}{unit}/개소" f" ({picked}) · {FRAME_MATERIAL_SOURCE}" + # ⚠ 준용이라는 사실이 상수 주석에만 있고 **화면 근거에는 없던** + # 자리다 — 값이 서면 어디서 온 값인지 안 보인다(2026-09-09 감사). + + ( + " · ⚠ 비탈 규준틀 재료량은 그 시트에 없어 **수평 값을 준용**함" + if str(frame.get("item")) == "비탈 규준틀" + else "" + ) + (f" · 손율 {loss}%(품셈 [주]③)" if loss else "") ), } From 5b97f280bad04b6fb77bf8650a1050b42ac7979e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 12:58:02 +0900 Subject: [PATCH 5/9] =?UTF-8?q?fix(B08):=20=ED=91=9C=ED=86=A0=20=EC=9B=90?= =?UTF-8?q?=EB=AC=B8=20=EB=8C=80=EC=A1=B0=20=EC=85=8B=20+=20=EC=A4=80?= =?UTF-8?q?=EB=B9=84=EA=B3=B5=20=ED=8C=8C=EC=9D=BC=20700=EC=A4=84=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 원문·실무 대조에서 나온 것: - 품셈 9-15-2(답외구간) 원문에 T(표토두께) 0.2m · L(운반거리) 20m 가 적용값으로 박혀 있음. 종전 사유 「품셈이 정하는 값이 아닙니다」가 반쪽이라 정정 — 값을 자동으로 넣지는 않되 원문에 있는 값을 없다고 말하지도 않음. - 그 품에 20m 압토가 이미 들어 있음. 실무 내역서(영월 2024)도 「표토제거 답외구간 / M2 · 도자 19Ton · D=20」 한 줄뿐이고 운반 줄이 따로 없음. 우리 운반 줄은 20m 를 넘는 몫일 때만 새 줄인데 그 가름을 아직 안 함 — 사유로 남김. - FP-10-12(덤프 운반)은 부모 코드라 품이 없음. 잎은 토사/암절취/발파암이고 갈래를 안 보내면 B09 가 후보만 보이고 금액이 안 섬. 표토는 토사이므로 variant_axis=ground_class · variant_value=토사 로 실어 보냄. 준비공 줄이 갈래를 넘길 통로도 함께 엶(종전에는 늘 None). 파일 분리(700줄 제한): - 부대시설 다섯 줄을 _Preparation_Ancillary 로, 상태 낱말을 _Preparation_Status 로 가름. 상태 문자열은 받는 쪽 판정에 쓰이므로 두 벌이 되지 않게 한 벌만 둠. 710줄 → 622 + 113 + 16. Co-Authored-By: Claude Opus 5 (1M context) --- .../B08_Quantity_Engine_Handoff_Rows.py | 6 +- .../B08_Quantity_Engine_Preparation.py | 151 ++++++------------ ...8_Quantity_Engine_Preparation_Ancillary.py | 112 +++++++++++++ .../B08_Quantity_Engine_Preparation_Status.py | 16 ++ 4 files changed, 177 insertions(+), 108 deletions(-) create mode 100644 B08_Quantity/B08_Quantity_Engine_Preparation_Ancillary.py create mode 100644 B08_Quantity/B08_Quantity_Engine_Preparation_Status.py diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py index 61303b3d..8aeafd70 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py @@ -603,8 +603,10 @@ def _preparation_rows(preparation_table: dict[str, Any]) -> list[dict[str, Any]] # ⚠ 못 서는 까닭을 그대로 넘긴다 — 받는 쪽이 「만들어야 할 것」 목록에 얹는다. "blocked_kind": None if ready else _prep_blocked_kind(status), "blocked_reason": "" if ready else str(row.get("reason") or status), - "variant_axis": None, - "variant_value": None, + # ⚠ 준비공 줄도 갈래를 실어 보낸다(2026-09-09) — 종전에는 늘 `None` 이라 + # 표토 운반처럼 **부모 공종코드**로 가는 줄이 B09 에서 「후보 N건」에 머물렀다. + "variant_axis": row.get("variant_axis"), + "variant_value": row.get("variant_value"), "secondary_axes": None, "spec_class": None, "spec_class_basis": str(row.get("reason") or ""), diff --git a/B08_Quantity/B08_Quantity_Engine_Preparation.py b/B08_Quantity/B08_Quantity_Engine_Preparation.py index 52642610..50635c05 100644 --- a/B08_Quantity/B08_Quantity_Engine_Preparation.py +++ b/B08_Quantity/B08_Quantity_Engine_Preparation.py @@ -44,10 +44,20 @@ BATTER_MIN_SLOPE_LENGTH_M = 10.0 BATTER_INTERVAL_M = 20.0 LEVEL_MIN_FILL_HEIGHT_M = 5.0 -STATUS_COUNTED_ELSEWHERE = "다른 표에서 이미 섬" -STATUS_PENDING = "값을 낼 근거가 없음" -STATUS_NOT_APPLICABLE = "해당 없음" -STATUS_READY = "값 있음" +# ⚠ 상태 낱말은 `_Preparation_Status` 한 벌에서 온다 — 700줄 제한으로 부대시설을 갈라 낼 때 +# 두 파일이 같은 문자열을 각자 적으면 조용히 갈릴 자리였다(2026-09-09). +from B08_Quantity.B08_Quantity_Engine_Preparation_Ancillary import ( # noqa: E402 + ANCILLARY_ITEMS, + ancillary_rows, +) +from B08_Quantity.B08_Quantity_Engine_Preparation_Status import ( # noqa: E402 + STATUS_COUNTED_ELSEWHERE, + STATUS_NOT_APPLICABLE, + STATUS_PENDING, + STATUS_READY, +) + +__all__ = ["ANCILLARY_ITEMS", "ancillary_rows"] # 갈라 나간 뒤에도 여기서 읽을 수 있게 def batter_frame_count(slope_rows: Iterable[dict[str, Any]]) -> tuple[int, list[str]]: @@ -125,7 +135,8 @@ def preparation_rows( "원문이 가르지 않고 실무도 한 덩이로 셈). " "토공집계의 「지장목제거」로 이미 섬 — 여기서 또 세우면 이중계상. " "⚠ 다만 **공종 미확정** — 품셈 4장이 벌목을 목적별로 갈라(수확베기·단목베기·" - "위험목 베기) 임도 지장목이 어디에 붙는지 원본이 말하지 않음. 지금은 공종코드 없이 감." + "위험목 베기) 임도 지장목이 어디에 붙는지 원본이 말하지 않음." + " 지금은 공종코드 없이 감." ), "work_item_code": None, }, @@ -170,8 +181,8 @@ def _topsoil_row(slope: dict[str, float], thickness_m: float | None) -> dict[str "amount": None, "status": STATUS_PENDING, "reason": ( - "표토 두께가 아직 입력되지 않았습니다 — 품셈 9-15 [주]② 가 두께를 " - "「공식의 입력 변수(T)」로 두어 **품셈이 정하는 값이 아닙니다**. " + "표토 두께가 아직 입력되지 않았습니다 — 두께는 설계가 정하는 값입니다. " + f"{TOPSOIL_ORIGINAL_APPLIED} " f"산출 조건에서 두께를 넣으면 값이 섭니다 (대상 면적 {area:,.1f}㎡)" ), "reference_amount": area, @@ -189,6 +200,20 @@ def _topsoil_row(slope: dict[str, float], thickness_m: float | None) -> dict[str } +#: ⚠⚠ **원문에 적용값이 있다**(2026-09-09 마스터 원문 대조에서 잡음). +#: 품셈 **9-15-2 답(畓)외구간** 표는 조건을 이렇게 박아 둔다 — +#: `T(표토두께) 0.2m` · `L(운반거리) 20m` · `E 0.4` · 도자(삽날 3.2㎥) +#: ⇒ 앞서 여기 「품셈이 정하는 값이 아니다」라고 적었는데 **그 말이 반쪽이었다.** +#: 두께를 바꾸면 품이 달라지는 것은 맞지만, **원문 적용값은 0.2m 로 적혀 있다.** +#: 지어내지 않되 **원문에 있는 값을 없다고 말하지도 않는다.** +#: ⚠⚠ **그리고 그 품에 20m 압토가 이미 들어 있다** — 실무 내역서(영월 2024)가 정확히 +#: 그 모양이다: 「표토제거 답외구간 / M2 · 도자 19Ton · D=20」 **한 줄뿐이고 운반 줄이 +#: 따로 없다.** 우리 「표토 운반·적치」 줄은 그래서 **20m 를 넘는 몫**일 때만 새 줄이다. +TOPSOIL_ORIGINAL_APPLIED = ( + "⚠ 품셈 9-15-2(답외구간) 원문 적용값은 **T=0.2m · L(운반거리)=20m** 입니다" + " — 값을 자동으로 넣지 않되, 원문에 있는 값이므로 참고할 것" +) + #: ⚠⚠ **대상 면적이 법 문언과 어긋난다**(2026-09-09 준비공 축 감사에서 잡음). #: 별표2 Ⅰ.2.차.(6) 은 대상을 **「노면·절토대상지」**로 못박고, 성토대상지는 (7) 에서 #: 「표토 등은 **제거·정리**한다」로 따로 두어 **운반·적치 의무를 안 건다.** @@ -213,6 +238,9 @@ TOPSOIL_AREA_GAP = ( TOPSOIL_HAUL_LAW = ( "⚠ 법정 의무 — 시행규칙 별표2 「표토는 전량 제거한 후 … 최고 홍수위보다 높은 장소로" " 운반하고 쌓아두어야 한다」. 제거만 세면 운반이 빠짐" + " · ⚠⚠ **다만 제거 품(9-15-2)에 이미 L=20m 압토가 들어 있음** — 실무 내역서도" + " 「표토제거 답외구간 / M2」 한 줄뿐이고 운반 줄이 따로 없음. 이 줄은 **20m 를 넘는" + " 몫**일 때만 새 줄이며, 그 가름을 아직 안 함(확인 필요)" ) @@ -405,9 +433,18 @@ def _topsoil_haul_row(topsoil: dict[str, Any], distance_m: float | None) -> dict "unit": "㎥", "amount": float(amount), "status": STATUS_READY, - "reason": f"{TOPSOIL_HAUL_LAW} · 제거 물량 그대로 · 운반거리 {distance:g}m(설계 입력)", + "reason": ( + f"{TOPSOIL_HAUL_LAW} · 제거 물량 그대로 · 운반거리 {distance:g}m(설계 입력)" + " · 갈래 「토사」로 보냄(표토는 토사임 — 덤프 운반 10-12 는 토사/암절취/발파암으로" + " 갈리고 부모 코드에는 품이 없음)" + ), "work_item_code": "FP-10-12", "haul_distance_m": distance, + # ⚠ `FP-10-12` 는 **부모**다 — 품이 붙은 것은 잎(10-12-1 토사 · -2 암절취 · -3 발파암) + # 뿐이라 갈래를 안 보내면 B09 가 「후보 3건」만 보이고 금액이 안 선다. 표토가 토사인 + # 것은 다툼이 없으므로 여기서 갈래를 실어 보낸다(2026-09-09 마스터 대조에서 잡음). + "variant_axis": "ground_class", + "variant_value": "토사", } @@ -561,104 +598,6 @@ def erosion_rows( ] -#: 부대시설·가설공사 — **법이 요구하는데 우리가 안 내던 다섯 줄**(2026-09-09 사용자 확정 ⑬). -#: `key` 는 설정의 `ancillary_counts` 칸 이름, `code` 는 품셈 공종(없으면 `None`). -#: ⚠ 다섯 중 **품셈에 공종이 있는 것은 가설창고 하나뿐**이다(마스터 전수 확인). -#: 나머지 넷은 **금액이 못 선다** — 줄은 세우되 「공종 자체가 품셈에 없음」이라고 적는다. -#: 「아직 안 만든 것」과 갈라 적어야 다음에 할 일이 달라진다. -ANCILLARY_ITEMS: tuple[dict[str, Any], ...] = ( - { - "key": "national_point_sign", - "item": "국가지점번호판", - "unit": "개소", - "code": None, - "legal": True, - "why": ( - "⚠ 법정 의무 — 임도규정 제26조제5항 「국가지점번호판을 제작하여 500미터 마다 " - "설치·관리하되, 필요시 거리를 조정할 수 있으며」. ⚠ 기점 포함·종점 잔여·갈림길 " - "중복을 원문이 정하지 않아 **연장÷500 을 산식으로 쓰지 않는다** — 개소를 넣으면 섬" - ), - }, - { - "key": "guide_sign", - "item": "임도 안내판", - "unit": "개소", - "code": None, - "legal": True, - "why": ( - "⚠ 법정 의무 — 임도규정 제26조제6항 「임도의 시점 및 종점에 안내판…을 설치하여야 " - "한다」. 노선을 이어 가면 최초 시점·최종 종점에 둘 수 있어 **개소는 설계 판단**임" - ), - }, - { - "key": "gate", - "item": "차단기", - "unit": "개소", - "code": None, - "legal": False, - "why": "임도기술교본 11장(실무 참고) — 법령·행정규칙에는 없음. 설치 개소는 설계 판단", - }, - { - "key": "site_container", - "item": "가설창고(컨테이너)", - "unit": "개소", - "code": "FP-11-01", - "legal": False, - "why": "품셈 11-1 콘테이너형 가설건축물 — ⚠ 개소는 현장 조건이라 설계가 정함", - }, - { - "key": "flood_supplies", - "item": "수방대책 자재", - "unit": "식", - "code": None, - "legal": False, - "why": ( - "임도기술교본 11장 — 비닐·말뚝·마대·삽 등을 현장 입구에 비치. " - "품목·수량이 원문에 없어 **한 벌(식)로 받는다**" - ), - }, -) - -#: 품셈에 그 이름의 공종이 아예 없는 줄에 적는 사유. 「아직 안 만든 것」과 갈라 쓴다. -REASON_NO_WORK_ITEM = "품셈에 그 이름의 공종이 없음 — 금액은 별도 단가로만 설 수 있음" - - -def ancillary_rows(counts: dict[str, Any] | None = None) -> list[dict[str, Any]]: - """부대시설·가설공사 줄. **개소를 안 넣어도 줄은 선다** — 빠진 것이 보이게. - - ⚠ 개소를 **지어내지 않는다**(확정 ⑬). 설정 `ancillary_counts` 에 넣은 값만 쓴다. - """ - given = {str(key): value for key, value in (counts or {}).items()} - rows: list[dict[str, Any]] = [] - for spec in ANCILLARY_ITEMS: - raw = given.get(spec["key"]) - try: - amount = float(raw) if raw is not None and str(raw).strip() != "" else None - except (TypeError, ValueError): - amount = None - reasons = [spec["why"]] - if spec["code"] is None: - reasons.append(REASON_NO_WORK_ITEM) - if amount is None: - reasons.append("개소가 아직 입력되지 않았습니다 — 넣으면 물량이 섭니다") - status = STATUS_PENDING - else: - status = STATUS_READY if spec["code"] else STATUS_PENDING - rows.append( - { - "group": "부대시설", - "item": spec["item"], - "unit": spec["unit"], - "amount": amount, - "status": status, - "work_item_code": spec["code"], - "legal_required": bool(spec["legal"]), - "reason": " · ".join(reasons), - } - ) - return rows - - def build_table( slope_totals: dict[str, float] | None = None, structures: Iterable[dict[str, Any]] = (), diff --git a/B08_Quantity/B08_Quantity_Engine_Preparation_Ancillary.py b/B08_Quantity/B08_Quantity_Engine_Preparation_Ancillary.py new file mode 100644 index 00000000..a30d9b55 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_Preparation_Ancillary.py @@ -0,0 +1,112 @@ +"""준비공 — 부대시설·가설공사 다섯 줄. + +⚠ `B08_Quantity_Engine_Preparation` 에서 갈라 나온 파일이다(2026-09-09, 700줄 제한). + **내용은 그대로 옮겼고 규칙도 그대로다** — 개소를 지어내지 않고, 「품셈에 공종이 없음」과 + 「개소 미입력」을 갈라 적으며, 값이 없어도 줄은 세운다. +""" + +from __future__ import annotations + +from typing import Any + +from B08_Quantity.B08_Quantity_Engine_Preparation_Status import ( + REASON_NO_WORK_ITEM, + STATUS_PENDING, + STATUS_READY, +) + +#: 부대시설·가설공사 — **법이 요구하는데 우리가 안 내던 다섯 줄**(2026-09-09 사용자 확정 ⑬). +#: `key` 는 설정의 `ancillary_counts` 칸 이름, `code` 는 품셈 공종(없으면 `None`). +#: ⚠ 다섯 중 **품셈에 공종이 있는 것은 가설창고 하나뿐**이다(마스터 전수 확인). +#: 나머지 넷은 **금액이 못 선다** — 줄은 세우되 「공종 자체가 품셈에 없음」이라고 적는다. +#: 「아직 안 만든 것」과 갈라 적어야 다음에 할 일이 달라진다. +ANCILLARY_ITEMS: tuple[dict[str, Any], ...] = ( + { + "key": "national_point_sign", + "item": "국가지점번호판", + "unit": "개소", + "code": None, + "legal": True, + "why": ( + "⚠ 법정 의무 — 임도규정 제26조제5항 「국가지점번호판을 제작하여 500미터 마다 " + "설치·관리하되, 필요시 거리를 조정할 수 있으며」. ⚠ 기점 포함·종점 잔여·갈림길 " + "중복을 원문이 정하지 않아 **연장÷500 을 산식으로 쓰지 않는다** — 개소를 넣으면 섬" + ), + }, + { + "key": "guide_sign", + "item": "임도 안내판", + "unit": "개소", + "code": None, + "legal": True, + "why": ( + "⚠ 법정 의무 — 임도규정 제26조제6항 「임도의 시점 및 종점에 안내판…을 설치하여야 " + "한다」. 노선을 이어 가면 최초 시점·최종 종점에 둘 수 있어 **개소는 설계 판단**임" + ), + }, + { + "key": "gate", + "item": "차단기", + "unit": "개소", + "code": None, + "legal": False, + "why": "임도기술교본 11장(실무 참고) — 법령·행정규칙에는 없음. 설치 개소는 설계 판단", + }, + { + "key": "site_container", + "item": "가설창고(컨테이너)", + "unit": "개소", + "code": "FP-11-01", + "legal": False, + "why": "품셈 11-1 콘테이너형 가설건축물 — ⚠ 개소는 현장 조건이라 설계가 정함", + }, + { + "key": "flood_supplies", + "item": "수방대책 자재", + "unit": "식", + "code": None, + "legal": False, + "why": ( + "임도기술교본 11장 — 비닐·말뚝·마대·삽 등을 현장 입구에 비치. " + "품목·수량이 원문에 없어 **한 벌(식)로 받는다**" + ), + }, +) + +#: 품셈에 그 이름의 공종이 아예 없는 줄에 적는 사유. 「아직 안 만든 것」과 갈라 쓴다. + + +def ancillary_rows(counts: dict[str, Any] | None = None) -> list[dict[str, Any]]: + """부대시설·가설공사 줄. **개소를 안 넣어도 줄은 선다** — 빠진 것이 보이게. + + ⚠ 개소를 **지어내지 않는다**(확정 ⑬). 설정 `ancillary_counts` 에 넣은 값만 쓴다. + """ + given = {str(key): value for key, value in (counts or {}).items()} + rows: list[dict[str, Any]] = [] + for spec in ANCILLARY_ITEMS: + raw = given.get(spec["key"]) + try: + amount = float(raw) if raw is not None and str(raw).strip() != "" else None + except (TypeError, ValueError): + amount = None + reasons = [spec["why"]] + if spec["code"] is None: + reasons.append(REASON_NO_WORK_ITEM) + if amount is None: + reasons.append("개소가 아직 입력되지 않았습니다 — 넣으면 물량이 섭니다") + status = STATUS_PENDING + else: + status = STATUS_READY if spec["code"] else STATUS_PENDING + rows.append( + { + "group": "부대시설", + "item": spec["item"], + "unit": spec["unit"], + "amount": amount, + "status": status, + "work_item_code": spec["code"], + "legal_required": bool(spec["legal"]), + "reason": " · ".join(reasons), + } + ) + return rows diff --git a/B08_Quantity/B08_Quantity_Engine_Preparation_Status.py b/B08_Quantity/B08_Quantity_Engine_Preparation_Status.py new file mode 100644 index 00000000..79c3f49a --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_Preparation_Status.py @@ -0,0 +1,16 @@ +"""준비공 줄의 **상태 낱말 한 벌**. + +⚠ `_Preparation` 과 `_Preparation_Ancillary` 가 같은 문자열을 각자 적으면 **조용히 갈린다** + (받는 쪽이 `status == "값 있음"` 으로 판정하므로 한 글자만 달라도 줄이 막힌 것으로 읽힌다). + 그래서 700줄 제한으로 파일을 가를 때 상수만 여기 한 벌 둔다(2026-09-09). +""" + +from __future__ import annotations + +STATUS_READY = "값 있음" +STATUS_PENDING = "값을 낼 근거가 없음" +STATUS_COUNTED_ELSEWHERE = "다른 표에서 이미 섬" +STATUS_NOT_APPLICABLE = "해당 없음" + +#: 품셈에 그 이름의 공종이 아예 없는 줄 — 「아직 안 만든 것」과 갈라 적는다. +REASON_NO_WORK_ITEM = "품셈에 그 이름의 공종이 없음 — 금액은 별도 단가로만 설 수 있음" From 036da3f9f914cd8ee95616a02de167809f0b305c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 13:00:21 +0900 Subject: [PATCH 6/9] =?UTF-8?q?refactor(B08):=20=EC=9D=B8=EA=B3=84=20?= =?UTF-8?q?=EC=A4=84=20=ED=8C=8C=EC=9D=BC=20700=EC=A4=84=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20=E2=80=94=20=EC=A4=80=EB=B9=84=EA=B3=B5=C2=B7?= =?UTF-8?q?=EB=B0=B0=EC=88=98=EA=B4=80=C2=B7=EC=97=B0=EC=9E=A5=C2=B7?= =?UTF-8?q?=EC=9E=90=EC=9E=AC=EB=A5=BC=20=5FRows=5FPrep=20=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 777줄이던 _Handoff_Rows 를 565 + 255 로 가름. 줄의 모양은 안 바뀜 — 갈라낸 넷을 본체에서 다시 내보내 부르는 쪽 import 도 그대로임. 상태 낱말은 _Preparation_Status 한 벌에서 읽음. Co-Authored-By: Claude Opus 5 (1M context) --- .../B08_Quantity_Engine_Handoff_Rows.py | 246 ++--------------- .../B08_Quantity_Engine_Handoff_Rows_Prep.py | 255 ++++++++++++++++++ 2 files changed, 271 insertions(+), 230 deletions(-) create mode 100644 B08_Quantity/B08_Quantity_Engine_Handoff_Rows_Prep.py diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py index 8aeafd70..a0ce263f 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py @@ -21,8 +21,6 @@ from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import ( NOTE_METHOD_MISSING, ORIGIN_EARTHWORK, ORIGIN_HAUL, - ORIGIN_PIPE, - ORIGIN_PREPARATION, ORIGIN_SLOPE, ORIGIN_STRUCTURE, SLOPE_GROUPS, @@ -33,14 +31,6 @@ from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import ( placing_code, structure_kind, ) -from B08_Quantity.B08_Quantity_Engine_Preparation import ( - STATUS_COUNTED_ELSEWHERE as PREP_COUNTED_ELSEWHERE, -) -from B08_Quantity.B08_Quantity_Engine_Preparation import ( - STATUS_NOT_APPLICABLE as PREP_NOT_APPLICABLE, -) -from B08_Quantity.B08_Quantity_Engine_Preparation import STATUS_PENDING as PREP_PENDING -from B08_Quantity.B08_Quantity_Engine_Preparation import STATUS_READY as PREP_READY from B08_Quantity.B08_Quantity_Wording import type_label as wording_type_label @@ -556,224 +546,20 @@ def _placing_rows( return rows, notes -def _preparation_rows(preparation_table: dict[str, Any]) -> list[dict[str, Any]]: - """준비공·사방공 줄 — **값이 서는 줄도, 못 서는 줄도** 함께 보낸다. +# ⚠ 준비공·배수관·연장·자재 줄은 700줄 제한으로 `_Handoff_Rows_Prep` 에 갈라 뒀다 +# (2026-09-09). 여기서 다시 내보내 부르는 쪽 import 는 그대로 둔다. +from B08_Quantity.B08_Quantity_Engine_Handoff_Rows_Prep import ( # noqa: E402 + _length_rows, + _material_rows, + _pipe_rows, + _prep_blocked_kind, + _preparation_rows, +) - ⚠ **줄을 빼면 「빠졌다는 사실조차 안 보인다」** (2026-09-08 보조 창 제보). - 받는 쪽 화면에서 「내역서에 원래 없는 것」과 「우리가 아직 못 내는 것」이 구별되지 않는다. - 그래서 못 내는 줄도 `in_bill: False` + `blocked_reason` 으로 실어 보낸다 — - **금액은 안 붙되 「무엇이 채워지면 풀리는지」가 함께 간다.** - - ⚠ 이 표가 통째로 안 가고 있었다 — 표토제거(값 있음·`FP-09-15`)·규준틀(개소·`FP-11-02`)이 - 화면에는 서는데 인계에는 없었다. 「사유를 실어 달라」는 요청을 보다 드러났다. - """ - rows: list[dict[str, Any]] = [] - for row in preparation_table.get("rows") or []: - status = str(row.get("status") or "") - amount = row.get("amount") - ready = status == PREP_READY and amount is not None - rows.append( - { - "work_item_code": row.get("work_item_code"), - "name": str(row.get("item") or ""), - "spec": str(row.get("group") or ""), - "unit": str(row.get("unit") or ""), - "quantity": float(amount or 0.0), - "quantity_gross": None, - "application_ratio_pct": None, - "application_ratio_breakdown": None, - "quantity_breakdown": None, - "ground_class": None, - "haul_distance_m": None, - "haul_equipment": None, - "station_from": None, - "station_to": None, - "excavation_method": None, - # ⚠ **값이 서는 줄에도 근거를 싣는다**(2026-09-09) — 종전에는 대분류 이름만 - # 실어, 값이 서는 순간 **왜 그 값인지가 사라졌다**(제근이 면적 축으로 확정돼 - # 값이 서자 「교차 참조: 건설품셈 3-9-2」가 화면에서 사라진 자리). - # 못 서는 줄은 종전대로 `blocked_reason` 이 따로 든다. - "spec_detail": " · ".join( - part - for part in (str(row.get("group") or ""), str(row.get("reason") or "")) - if part - ), - "composite_parts": None, - "structure_kind": None, - # ⚠ 못 서는 까닭을 그대로 넘긴다 — 받는 쪽이 「만들어야 할 것」 목록에 얹는다. - "blocked_kind": None if ready else _prep_blocked_kind(status), - "blocked_reason": "" if ready else str(row.get("reason") or status), - # ⚠ 준비공 줄도 갈래를 실어 보낸다(2026-09-09) — 종전에는 늘 `None` 이라 - # 표토 운반처럼 **부모 공종코드**로 가는 줄이 B09 에서 「후보 N건」에 머물렀다. - "variant_axis": row.get("variant_axis"), - "variant_value": row.get("variant_value"), - "secondary_axes": None, - "spec_class": None, - "spec_class_basis": str(row.get("reason") or ""), - "composite_not_ready": None, - # 값이 없는 줄은 **내역에 세우지 않는다** — 0 원 줄을 만들면 더 나쁘다. - "in_bill": ready, - "in_bill_reason": "" if ready else str(row.get("reason") or status), - "origin": ORIGIN_PREPARATION, - } - ) - return rows - - -def _prep_blocked_kind(status: str) -> str | None: - """준비공 줄의 상태를 **막힌 갈래 셋** 중 하나로 옮긴다. 모르는 상태는 `None`.""" - if status == PREP_PENDING: - return BLOCKED_INPUT_MISSING - if status == PREP_COUNTED_ELSEWHERE: - # 다른 표에서 이미 선 줄 — 막힌 것이 아니라 **여기서 세면 안 되는** 줄이다. - return None - if status == PREP_NOT_APPLICABLE: - return None - return BLOCKED_UNIT_DATA_MISSING - - -def _pipe_rows(pipe_table: dict[str, Any]) -> list[dict[str, Any]]: - """배수관 줄 — 값이 서는 줄도, 못 서는 줄도 함께 보낸다(준비공과 같은 규칙). - - ⚠ 터파기·되메우기를 붙이지 않는다 — 관 부설과 굴착이 각각 오면 **같은 굴착을 두 번** 센다 - (B09 ㉡ 가드와 같은 자리). - """ - rows: list[dict[str, Any]] = [] - for row in pipe_table.get("rows") or []: - ready = bool(row.get("in_bill")) - rows.append( - { - "work_item_code": row.get("work_item_code"), - "name": f"배수관({row.get('kind')})", - "spec": f"Ø{row.get('variant_value')}" if row.get("variant_value") else "", - "unit": str(row.get("unit") or "m"), - "quantity": float(row.get("quantity") or 0.0), - "quantity_gross": None, - "application_ratio_pct": None, - "application_ratio_breakdown": None, - "quantity_breakdown": None, - "ground_class": None, - "haul_distance_m": None, - "haul_equipment": None, - "station_from": row.get("chainage_m"), - "station_to": row.get("chainage_m"), - "excavation_method": None, - "spec_detail": f"Ø{row.get('variant_value')}" if row.get("variant_value") else "", - "composite_parts": None, - "structure_kind": None, - "blocked_kind": row.get("blocked_kind"), - "blocked_reason": str(row.get("blocked_reason") or ""), - # 갈래는 **저장 원본값**만 — 「…㎜ 이하」 구간 나누기는 원문을 읽는 쪽 몫. - "variant_axis": row.get("variant_axis"), - "variant_value": row.get("variant_value"), - "secondary_axes": None, - "spec_class": None, - "spec_class_basis": str(row.get("blocked_reason") or ""), - "composite_not_ready": None, - "in_bill": ready, - "in_bill_reason": "" if ready else str(row.get("blocked_reason") or ""), - "origin": ORIGIN_PIPE, - } - ) - return rows - - -def _length_rows( - length_table: list[dict[str, Any]], mapping: WorkItemMapping -) -> list[dict[str, Any]]: - """B군 종단배수 — **종류별 한 줄**로 낸다(연장이 곧 수량). - - ⚠ **왜 구조물별로 안 내나** — `common_util_structure_lengths` 가 **겹친 구간을 합쳐** - 준다. 같은 시설을 겹쳐 놓으면 구조물별로 세는 순간 그 구간을 **두 번** 센다. - 그 규칙(겹침 합치기 · 측구 제외 · 관 소관 제외)이 이미 그 함수에 있으므로 - **두 벌로 짜지 않는다**(2026-09-08 랩탑 창 제안, 두 창 합의). - - ⚠ **C군(돌쌓기·옹벽 등)은 여기로 오지 않는다** — 그 함수는 종류별로 뭉쳐 내는데, - C군은 **측점·규격이 줄마다 달라** 구조물별로 서야 하고 자재도 줄마다 나온다. - 실무 내역도 B군은 「산마루측구 40m」 한 줄, C군은 구조물별 줄이다. - - ⚠ 겹침이 있으면(`length_m != raw_length_m`) **숨기지 않고 비고에 적는다.** - """ - rows: list[dict[str, Any]] = [] - for entry in length_table or []: - type_id = str(entry.get("type_id") or "") - found = mapping.for_structure(type_id) - code = (found or {}).get("work_item_code") - length = float(entry.get("length_m") or 0.0) - raw = float(entry.get("raw_length_m") or length) - # 구간 목록 — **겹침을 지운 뒤**의 것이라 그 합이 곧 `length_m` 이다 - # (80~120 과 100~140 은 80~140 한 줄로 합쳐져 온다, 2026-09-08 랩탑 창). - # ⚠ 표기(`NO.4+0.0`)는 만들지 않는다 — 측점 간격을 아는 화면 몫이다. - spans = [ - span - for span in (entry.get("spans") or []) - if span.get("start_m") is not None and span.get("end_m") is not None - ] - span_note = " · ".join(f"{s['start_m']:g}~{s['end_m']:g}m" for s in spans) - note = f"구간 {span_note}" if span_note else "" - if abs(raw - length) > 1e-9: - 겹침 = f"입력 구간 합 {raw:g}m 에서 겹친 {raw - length:g}m 를 뺀 값" - note = f"{note} · {겹침}" if note else 겹침 - # ⚠ 겹침 설명은 **비고**이지 막힌 사유가 아니다 — `blocked_reason` 에 넣으면 - # 받는 쪽이 「막힌 줄」로 읽어 금액을 안 붙인다(2026-09-08 실측에서 그랬다). - reason = "" - if code is None: - reason = f"{entry.get('name') or type_id} — 품셈 공종을 아직 못 이었습니다" - elif length <= 0: - reason = f"{entry.get('name') or type_id} — 연장이 0 이라 값이 서지 않습니다" - rows.append( - { - "work_item_code": code, - "name": str(entry.get("name") or type_id), - "spec": f"{entry.get('count')}개소", - "unit": "m", - "quantity": length, - "quantity_gross": raw if note else None, - "application_ratio_pct": None, - "application_ratio_breakdown": None, - "quantity_breakdown": None, - "ground_class": None, - "haul_distance_m": None, - "haul_equipment": None, - # 여러 구간이면 **처음과 끝**만 싣는다 — 사이 구간은 비고에 다 적혀 있다. - "station_from": spans[0]["start_m"] if spans else None, - "station_to": spans[-1]["end_m"] if spans else None, - "excavation_method": None, - "spec_detail": f"{entry.get('count')}개소", - "composite_parts": None, - "structure_kind": None, - "blocked_kind": None if (code and length > 0) else BLOCKED_FORMULA_MISSING, - "blocked_reason": reason, - "variant_axis": None, - "variant_value": None, - "secondary_axes": None, - "spec_class": None, - "spec_class_basis": note, - "composite_not_ready": None, - "in_bill": bool(code and length > 0), - "in_bill_reason": "" if (code and length > 0) else reason, - "origin": ORIGIN_STRUCTURE, - } - ) - return rows - - -def _material_rows(material_table: dict[str, Any]) -> list[dict[str, Any]]: - """자재 줄 — **공종코드를 붙이지 않는다.** 자재 축은 B09 카탈로그가 잇는다(8-7).""" - rows: list[dict[str, Any]] = [] - for row in material_table.get("rows") or []: - rows.append( - { - "material_name": row.get("name"), - "spec": row.get("spec") or "", - "unit": row.get("unit"), - "net_amount": row.get("net_amount"), - "total_amount": row.get("total_amount"), - "surcharge_pct": row.get("surcharge_pct"), - "surcharge_note": row.get("note") or "", - "supply_type": row.get("supply"), - "install_by": row.get("install_by"), - "source_structure": row.get("sources") or [], - } - ) - return rows +__all__ = [ + "_length_rows", + "_material_rows", + "_pipe_rows", + "_prep_blocked_kind", + "_preparation_rows", +] diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows_Prep.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows_Prep.py new file mode 100644 index 00000000..8a65dd2c --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows_Prep.py @@ -0,0 +1,255 @@ +"""인계 줄 — **준비공·배수관·연장·자재** 네 갈래 (`Engine_Handoff_Rows` 에서 갈라냄). + +⚠ **왜 갈랐나** — 700줄 제한(2026-09-09). **줄의 모양은 하나도 안 바뀐다.** +⚠ 줄 빌더가 여럿이라 칸을 하나 늘리면 **여기와 저기를 함께** 고쳐야 한다 — 계약 시험 + (`tmp/tests/test_b08_handoff_contract.py`)이 「모든 줄이 같은 칸을 갖는가」로 그것을 지킨다. +""" + +from __future__ import annotations + +from typing import Any + +from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import ( + BLOCKED_FORMULA_MISSING, + BLOCKED_INPUT_MISSING, + BLOCKED_UNIT_DATA_MISSING, + ORIGIN_PIPE, + ORIGIN_PREPARATION, + ORIGIN_STRUCTURE, + WorkItemMapping, +) +from B08_Quantity.B08_Quantity_Engine_Preparation_Status import ( + STATUS_COUNTED_ELSEWHERE as PREP_COUNTED_ELSEWHERE, +) +from B08_Quantity.B08_Quantity_Engine_Preparation_Status import ( + STATUS_NOT_APPLICABLE as PREP_NOT_APPLICABLE, +) +from B08_Quantity.B08_Quantity_Engine_Preparation_Status import ( + STATUS_PENDING as PREP_PENDING, +) +from B08_Quantity.B08_Quantity_Engine_Preparation_Status import ( + STATUS_READY as PREP_READY, +) + + +def _preparation_rows(preparation_table: dict[str, Any]) -> list[dict[str, Any]]: + """준비공·사방공 줄 — **값이 서는 줄도, 못 서는 줄도** 함께 보낸다. + + ⚠ **줄을 빼면 「빠졌다는 사실조차 안 보인다」** (2026-09-08 보조 창 제보). + 받는 쪽 화면에서 「내역서에 원래 없는 것」과 「우리가 아직 못 내는 것」이 구별되지 않는다. + 그래서 못 내는 줄도 `in_bill: False` + `blocked_reason` 으로 실어 보낸다 — + **금액은 안 붙되 「무엇이 채워지면 풀리는지」가 함께 간다.** + + ⚠ 이 표가 통째로 안 가고 있었다 — 표토제거(값 있음·`FP-09-15`)·규준틀(개소·`FP-11-02`)이 + 화면에는 서는데 인계에는 없었다. 「사유를 실어 달라」는 요청을 보다 드러났다. + """ + rows: list[dict[str, Any]] = [] + for row in preparation_table.get("rows") or []: + status = str(row.get("status") or "") + amount = row.get("amount") + ready = status == PREP_READY and amount is not None + rows.append( + { + "work_item_code": row.get("work_item_code"), + "name": str(row.get("item") or ""), + "spec": str(row.get("group") or ""), + "unit": str(row.get("unit") or ""), + "quantity": float(amount or 0.0), + "quantity_gross": None, + "application_ratio_pct": None, + "application_ratio_breakdown": None, + "quantity_breakdown": None, + "ground_class": None, + "haul_distance_m": None, + "haul_equipment": None, + "station_from": None, + "station_to": None, + "excavation_method": None, + # ⚠ **값이 서는 줄에도 근거를 싣는다**(2026-09-09) — 종전에는 대분류 이름만 + # 실어, 값이 서는 순간 **왜 그 값인지가 사라졌다**(제근이 면적 축으로 확정돼 + # 값이 서자 「교차 참조: 건설품셈 3-9-2」가 화면에서 사라진 자리). + # 못 서는 줄은 종전대로 `blocked_reason` 이 따로 든다. + "spec_detail": " · ".join( + part + for part in (str(row.get("group") or ""), str(row.get("reason") or "")) + if part + ), + "composite_parts": None, + "structure_kind": None, + # ⚠ 못 서는 까닭을 그대로 넘긴다 — 받는 쪽이 「만들어야 할 것」 목록에 얹는다. + "blocked_kind": None if ready else _prep_blocked_kind(status), + "blocked_reason": "" if ready else str(row.get("reason") or status), + # ⚠ 준비공 줄도 갈래를 실어 보낸다(2026-09-09) — 종전에는 늘 `None` 이라 + # 표토 운반처럼 **부모 공종코드**로 가는 줄이 B09 에서 「후보 N건」에 머물렀다. + "variant_axis": row.get("variant_axis"), + "variant_value": row.get("variant_value"), + "secondary_axes": None, + "spec_class": None, + "spec_class_basis": str(row.get("reason") or ""), + "composite_not_ready": None, + # 값이 없는 줄은 **내역에 세우지 않는다** — 0 원 줄을 만들면 더 나쁘다. + "in_bill": ready, + "in_bill_reason": "" if ready else str(row.get("reason") or status), + "origin": ORIGIN_PREPARATION, + } + ) + return rows + + +def _prep_blocked_kind(status: str) -> str | None: + """준비공 줄의 상태를 **막힌 갈래 셋** 중 하나로 옮긴다. 모르는 상태는 `None`.""" + if status == PREP_PENDING: + return BLOCKED_INPUT_MISSING + if status == PREP_COUNTED_ELSEWHERE: + # 다른 표에서 이미 선 줄 — 막힌 것이 아니라 **여기서 세면 안 되는** 줄이다. + return None + if status == PREP_NOT_APPLICABLE: + return None + return BLOCKED_UNIT_DATA_MISSING + + +def _pipe_rows(pipe_table: dict[str, Any]) -> list[dict[str, Any]]: + """배수관 줄 — 값이 서는 줄도, 못 서는 줄도 함께 보낸다(준비공과 같은 규칙). + + ⚠ 터파기·되메우기를 붙이지 않는다 — 관 부설과 굴착이 각각 오면 **같은 굴착을 두 번** 센다 + (B09 ㉡ 가드와 같은 자리). + """ + rows: list[dict[str, Any]] = [] + for row in pipe_table.get("rows") or []: + ready = bool(row.get("in_bill")) + rows.append( + { + "work_item_code": row.get("work_item_code"), + "name": f"배수관({row.get('kind')})", + "spec": f"Ø{row.get('variant_value')}" if row.get("variant_value") else "", + "unit": str(row.get("unit") or "m"), + "quantity": float(row.get("quantity") or 0.0), + "quantity_gross": None, + "application_ratio_pct": None, + "application_ratio_breakdown": None, + "quantity_breakdown": None, + "ground_class": None, + "haul_distance_m": None, + "haul_equipment": None, + "station_from": row.get("chainage_m"), + "station_to": row.get("chainage_m"), + "excavation_method": None, + "spec_detail": f"Ø{row.get('variant_value')}" if row.get("variant_value") else "", + "composite_parts": None, + "structure_kind": None, + "blocked_kind": row.get("blocked_kind"), + "blocked_reason": str(row.get("blocked_reason") or ""), + # 갈래는 **저장 원본값**만 — 「…㎜ 이하」 구간 나누기는 원문을 읽는 쪽 몫. + "variant_axis": row.get("variant_axis"), + "variant_value": row.get("variant_value"), + "secondary_axes": None, + "spec_class": None, + "spec_class_basis": str(row.get("blocked_reason") or ""), + "composite_not_ready": None, + "in_bill": ready, + "in_bill_reason": "" if ready else str(row.get("blocked_reason") or ""), + "origin": ORIGIN_PIPE, + } + ) + return rows + + +def _length_rows( + length_table: list[dict[str, Any]], mapping: WorkItemMapping +) -> list[dict[str, Any]]: + """B군 종단배수 — **종류별 한 줄**로 낸다(연장이 곧 수량). + + ⚠ **왜 구조물별로 안 내나** — `common_util_structure_lengths` 가 **겹친 구간을 합쳐** + 준다. 같은 시설을 겹쳐 놓으면 구조물별로 세는 순간 그 구간을 **두 번** 센다. + 그 규칙(겹침 합치기 · 측구 제외 · 관 소관 제외)이 이미 그 함수에 있으므로 + **두 벌로 짜지 않는다**(2026-09-08 랩탑 창 제안, 두 창 합의). + + ⚠ **C군(돌쌓기·옹벽 등)은 여기로 오지 않는다** — 그 함수는 종류별로 뭉쳐 내는데, + C군은 **측점·규격이 줄마다 달라** 구조물별로 서야 하고 자재도 줄마다 나온다. + 실무 내역도 B군은 「산마루측구 40m」 한 줄, C군은 구조물별 줄이다. + + ⚠ 겹침이 있으면(`length_m != raw_length_m`) **숨기지 않고 비고에 적는다.** + """ + rows: list[dict[str, Any]] = [] + for entry in length_table or []: + type_id = str(entry.get("type_id") or "") + found = mapping.for_structure(type_id) + code = (found or {}).get("work_item_code") + length = float(entry.get("length_m") or 0.0) + raw = float(entry.get("raw_length_m") or length) + # 구간 목록 — **겹침을 지운 뒤**의 것이라 그 합이 곧 `length_m` 이다 + # (80~120 과 100~140 은 80~140 한 줄로 합쳐져 온다, 2026-09-08 랩탑 창). + # ⚠ 표기(`NO.4+0.0`)는 만들지 않는다 — 측점 간격을 아는 화면 몫이다. + spans = [ + span + for span in (entry.get("spans") or []) + if span.get("start_m") is not None and span.get("end_m") is not None + ] + span_note = " · ".join(f"{s['start_m']:g}~{s['end_m']:g}m" for s in spans) + note = f"구간 {span_note}" if span_note else "" + if abs(raw - length) > 1e-9: + 겹침 = f"입력 구간 합 {raw:g}m 에서 겹친 {raw - length:g}m 를 뺀 값" + note = f"{note} · {겹침}" if note else 겹침 + # ⚠ 겹침 설명은 **비고**이지 막힌 사유가 아니다 — `blocked_reason` 에 넣으면 + # 받는 쪽이 「막힌 줄」로 읽어 금액을 안 붙인다(2026-09-08 실측에서 그랬다). + reason = "" + if code is None: + reason = f"{entry.get('name') or type_id} — 품셈 공종을 아직 못 이었습니다" + elif length <= 0: + reason = f"{entry.get('name') or type_id} — 연장이 0 이라 값이 서지 않습니다" + rows.append( + { + "work_item_code": code, + "name": str(entry.get("name") or type_id), + "spec": f"{entry.get('count')}개소", + "unit": "m", + "quantity": length, + "quantity_gross": raw if note else None, + "application_ratio_pct": None, + "application_ratio_breakdown": None, + "quantity_breakdown": None, + "ground_class": None, + "haul_distance_m": None, + "haul_equipment": None, + # 여러 구간이면 **처음과 끝**만 싣는다 — 사이 구간은 비고에 다 적혀 있다. + "station_from": spans[0]["start_m"] if spans else None, + "station_to": spans[-1]["end_m"] if spans else None, + "excavation_method": None, + "spec_detail": f"{entry.get('count')}개소", + "composite_parts": None, + "structure_kind": None, + "blocked_kind": None if (code and length > 0) else BLOCKED_FORMULA_MISSING, + "blocked_reason": reason, + "variant_axis": None, + "variant_value": None, + "secondary_axes": None, + "spec_class": None, + "spec_class_basis": note, + "composite_not_ready": None, + "in_bill": bool(code and length > 0), + "in_bill_reason": "" if (code and length > 0) else reason, + "origin": ORIGIN_STRUCTURE, + } + ) + return rows + + +def _material_rows(material_table: dict[str, Any]) -> list[dict[str, Any]]: + """자재 줄 — **공종코드를 붙이지 않는다.** 자재 축은 B09 카탈로그가 잇는다(8-7).""" + rows: list[dict[str, Any]] = [] + for row in material_table.get("rows") or []: + rows.append( + { + "material_name": row.get("name"), + "spec": row.get("spec") or "", + "unit": row.get("unit"), + "net_amount": row.get("net_amount"), + "total_amount": row.get("total_amount"), + "surcharge_pct": row.get("surcharge_pct"), + "surcharge_note": row.get("note") or "", + "supply_type": row.get("supply"), + "install_by": row.get("install_by"), + "source_structure": row.get("sources") or [], + } + ) + return rows From 072f560a59f4c157717db68e4b50b95f0fc43ce9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 13:06:06 +0900 Subject: [PATCH 7/9] =?UTF-8?q?fix(B08):=20=EC=98=B9=EB=B2=BD=20=EB=AC=B6?= =?UTF-8?q?=EC=9D=8C=20=EA=B0=90=EC=82=AC=20=EB=91=98=20=E2=80=94=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=9D=B4=EB=A6=84=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=EA=B0=92=20=EB=85=B8=EC=B6=9C=20=C2=B7=20=ED=84=B0?= =?UTF-8?q?=ED=8C=8C=EA=B8=B0=20=EB=B9=A0=EC=A7=84=20=EC=A4=84=EC=9D=B4=20?= =?UTF-8?q?=EC=95=88=20=EB=B3=B4=EC=9D=B4=EB=8D=98=20=EA=B2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 레지스트리 이름이 없으면 내역 줄 이름에 코드값이 그대로 떴음(`retaining_wall`). _Wording.type_label 이 이미 대비표를 들고 있으므로 그것을 씀 — 이제 「옹벽」으로 섬. - 관측 원단위로 가는 구조물은 표에 터파기 줄이 없으면 그 구조물만 조용히 빠졌음. 옹벽이 터파기 0 줄인데 사유도 안 났음(돌쌓기는 세 줄이 섬). 값을 지어내지 않고 「터파기가 안 선 구조물」 줄을 in_bill False · blocked_kind unit_data_missing 로 세워 빠졌다는 사실이 보이게 함. Co-Authored-By: Claude Opus 5 (1M context) --- .../B08_Quantity_Engine_Handoff_Trench.py | 34 +++++++++++++++++++ .../B08_Quantity_Engine_UnitQuantity.py | 6 +++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Trench.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Trench.py index d2c8e917..1c1f3b2f 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Trench.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Trench.py @@ -24,6 +24,7 @@ from typing import Any from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import ( BLOCKED_INPUT_MISSING, + BLOCKED_UNIT_DATA_MISSING, ORIGIN_EARTHWORK, ORIGIN_STRUCTURE, WorkItemMapping, @@ -203,6 +204,10 @@ def structure_earthwork_rows( bases: dict[tuple[str, str | None], list[str]] = {} backfill = 0.0 spoil = 0.0 + # ⚠ 터파기 성분이 아예 없는 구조물을 적어 둔다 — **줄이 안 서는 것보다 나쁜 것이 + # 「빠진 줄조차 안 보이는 것」**이다(2026-09-09 감사: 옹벽이 터파기 0 줄이었고 + # 사유도 안 났다). 관측 원단위로 가는 종류는 표에 터파기 줄이 없으면 이렇게 된다. + without_trench: list[str] = [] for structure in unit_quantity_table.get("structures") or []: options = structure.get("options") or {} height = float(structure.get("height_m") or options.get("height_m") or 0.0) @@ -226,6 +231,14 @@ def structure_earthwork_rows( backfill += amount elif name == "잔토처리": spoil += amount + if not any( + str(component.get("name") or "") == "터파기" + and float(component.get("amount") or 0.0) > 0 + for component in structure.get("components") or [] + ): + without_trench.append( + str(structure.get("name") or structure.get("type_id") or "이름 없는 구조물") + ) rows: list[dict[str, Any]] = [] unmatched: list[str] = [] @@ -302,4 +315,25 @@ def structure_earthwork_rows( in_bill_reason=SPOIL_REASON, ) ) + if without_trench: + # 값을 지어내지 않고 **빠졌다는 사실만** 낸다 — 관측 원단위(옹벽·집수정 등)는 표에 + # 터파기 줄이 있는 것도 있고 없는 것도 있어, 없으면 그 구조물만 조용히 빠진다. + rows.append( + _row( + name=TRENCH_GROUP, + spec="구조물", + spec_detail=( + "⚠ 터파기가 안 선 구조물: " + + " · ".join(sorted(set(without_trench))) + + " — 관측 원단위 표에 터파기 줄이 없음(전개식으로 가는 종류는 높이·두께에서" + " 나오지만 관측표는 표에 있는 줄만 냄). **값을 지어내지 않음** —" + " 실무 내역서에서 그 구조물의 터파기를 어떻게 잡는지 확인할 것" + ), + quantity=0.0, + in_bill=False, + in_bill_reason="터파기 물량을 못 세움 — 관측 원단위에 그 줄이 없음", + blocked_kind=BLOCKED_UNIT_DATA_MISSING, + blocked_reason="관측 원단위 표에 터파기 줄이 없음", + ) + ) return rows, unmatched diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py index 3881e481..5878331e 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -1241,7 +1241,11 @@ def expand( end = _num(structure.get("end_m")) length = _num(options.get("length_m")) or abs(end - start) height = _num(options.get("height_m")) - label = (names or {}).get(type_id, type_id) + # ⚠ 레지스트리 이름이 없으면 **코드값(`retaining_wall`)이 그대로 내역에 뜬다** — + # `_Wording.type_label` 이 이미 대비표를 들고 있으므로 그것을 쓴다(2026-09-09 감사). + from B08_Quantity.B08_Quantity_Wording import type_label as _type_label + + label = _type_label(type_id, names) if structure.get("attachment_label"): # 「배수관 · 유입부 집수정」처럼 어디에 딸린 줄인지 이름에 남긴다. parent = (names or {}).get(str(structure.get("attachment_parent_type") or ""), "") From e71147c1b205a1c154ff10c04d05aa3bd5483ee2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 13:10:22 +0900 Subject: [PATCH 8/9] =?UTF-8?q?fix(B08):=20=EC=BD=94=EB=93=9C=20=EC=97=86?= =?UTF-8?q?=EB=8A=94=20=EC=A4=84=EC=97=90=20=EB=A7=89=ED=9E=98=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=EB=A5=BC=20=EB=B6=99=EC=9E=84=20=E2=80=94=20=EA=B8=88?= =?UTF-8?q?=EC=95=A1=EC=9D=B4=20=EC=A1=B0=EC=9A=A9=ED=9E=88=20=EB=B9=A0?= =?UTF-8?q?=EC=A7=80=EB=8D=98=20=EC=9E=90=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 랩탑 메인이 화면에서 잡음: 「측구터파기 · 굴삭기+브레카」가 work_item_code null 인데 blocked_kind 도 null 이라 아무 말 없이 금액만 빠졌음. 인계본의 bill_flag_warnings·unmatched_work_items 에는 이미 실려 있었음 — 목록은 있는데 줄에 표시가 없었던 것. 줄 단위로 보는 쪽(B09·화면)은 목록을 안 봄. 같은 모양이 여덟 줄이었음(흙깎기·측구터파기·도자운반 둘·덤프운반 둘·지장목제거·큰돌쌓기). - 토공 줄: 코드가 없으면 시공법 미지정이면 input_missing, 매핑을 못 이었으면 unit_data_missing 으로 갈라 답. 다음에 할 일이 다름(사용자 입력 vs 매핑 작업). - 운반 줄: 같은 규칙. 무대(free_haul)는 품에 포함이라 안 세우는 것이지 막힌 게 아니므로 표시를 안 답. - 불변식 시험을 세움 — 코드도 막힘 표시도 없이 내역에 서는 줄이 하나라도 있으면 깨짐. Co-Authored-By: Claude Opus 5 (1M context) --- .../B08_Quantity_Engine_Handoff_Rows.py | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py index a0ce263f..eaa403f0 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py @@ -131,9 +131,22 @@ def _earthwork_rows( elif mismatch is not None: spec_detail = f"집계 {amount:,.2f} {unit} (품셈 밑수 {mismatch[0]})" unit, amount = mismatch[0], 0.0 + # ⚠⚠ **코드가 없으면 반드시 막힘 표시를 단다**(2026-09-09 랩탑 메인 제보). + # 종전에는 `unmatched_work_items` 목록과 `bill_flag_warnings` 에만 실려, + # **줄 단위로 보는 쪽**(B09·화면)이 「코드도 없고 막힘 표시도 없는 멀쩡한 줄」로 + # 읽었다 — 금액이 조용히 빠졌다(측구터파기·흙깎기 「굴삭기+브레카」). + # ⇒ 오늘 세운 규칙 「막혔다고 말하기 전에 `blocked_kind` 를 볼 것」의 **뒤집힌 얼굴**: + # 보는 쪽을 고쳤으면 **다는 쪽도** 빠짐없이 달아야 한다. + blocked_kind = mismatch[1] if mismatch else None + blocked_reason = mismatch[2] if mismatch else "" if code is None and not is_subtotal: label = f"{group}({ground})" if ground else group unmatched.append(f"{label} — {method_note}" if method_note else label) + if blocked_kind is None: + # 시공법을 고르면 풀리는 자리와, 품셈을 아직 못 이은 자리를 **갈라 적는다** — + # 다음에 할 일이 다르다(하나는 사용자 입력, 하나는 매핑 작업). + blocked_kind = BLOCKED_INPUT_MISSING if method_note else BLOCKED_UNIT_DATA_MISSING + blocked_reason = method_note or f"{label} 의 품셈 공종을 아직 못 이었습니다" rows.append( { "work_item_code": code, @@ -162,9 +175,9 @@ def _earthwork_rows( "secondary_axes": None, "spec_class": None, "spec_class_basis": "", - # 토공 줄도 막힐 수 있다 — 품셈 밑수와 단위가 다르면 그 사유가 실린다. - "blocked_kind": mismatch[1] if mismatch else None, - "blocked_reason": mismatch[2] if mismatch else "", + # 토공 줄도 막힐 수 있다 — 밑수 어긋남과 **코드 없음** 둘 다 여기 실린다. + "blocked_kind": blocked_kind, + "blocked_reason": blocked_reason, "composite_not_ready": None, # 합계 줄과 무대 줄은 값은 내되 내역에 안 선다. "in_bill": bool(row.get("in_bill", True)) and not is_subtotal and mismatch is None, @@ -191,6 +204,13 @@ def _haul_rows( in_bill = bool(row.get("in_bill", True)) and entry.get("in_bill", True) if code is None and in_bill: unmatched.append(f"운반({equipment})") + # ⚠ 코드가 없으면 **줄에 막힘 표시를 단다**(2026-09-09) — 목록에만 실으면 줄 단위로 + # 보는 쪽이 「멀쩡한 줄」로 읽어 금액이 조용히 빠진다(도자운반·덤프운반이 그랬다). + # ⚠ `in_bill` 이 False 인 무대 줄은 **막힌 것이 아니다** — 품에 포함이라 안 세우는 것. + haul_blocked = BLOCKED_UNIT_DATA_MISSING if (code is None and in_bill) else None + haul_blocked_reason = ( + f"운반({equipment})의 품셈 공종을 아직 못 이었습니다" if haul_blocked else "" + ) # ⚠⚠ **내역서 수량은 자연상태다** — 유토곡선은 다짐상태로 쌓고(운반거리를 그 기준으로 # 재야 맞는다) 내역에 오르는 수량은 되돌린 값이다(`config_system_design` 5-4-3 # 「운반거리 산정 시 모든 수량은 다짐상태로 환산해 계산하고, **내역서에 적용하는 @@ -242,8 +262,8 @@ def _haul_rows( "secondary_axes": None, "spec_class": None, "spec_class_basis": "", - "blocked_kind": None, - "blocked_reason": "", + "blocked_kind": haul_blocked, + "blocked_reason": haul_blocked_reason, "composite_not_ready": None, "in_bill": in_bill, "in_bill_reason": str(entry.get("reason") or ""), From ac8e8f1e592151a88fb106da18237a1cbda1efda Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 13:13:54 +0900 Subject: [PATCH 9/9] =?UTF-8?q?fix(B08):=20=EB=AC=BC=EB=9F=89=200=20?= =?UTF-8?q?=EC=9D=B8=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=EC=9D=84=20=EB=82=B4?= =?UTF-8?q?=EC=97=AD=EC=97=90=EC=84=9C=20=EB=BA=8C=20=E2=80=94=200=20?= =?UTF-8?q?=EC=9B=90=20=EC=A4=84=EC=9D=B4=20=EB=A7=8C=EB=93=A4=EC=96=B4?= =?UTF-8?q?=EC=A7=80=EB=8D=98=20=EC=9E=90=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 물넘이포장이 면적을 못 받아 「0.0 ㎡」로 내역에 서고 있었음. 코드가 붙어 있어 0 원 줄이 만들어지고 화면에는 값이 있는 줄로 보임 — 0 은 「없음」과 구별이 안 됨. 줄은 그대로 넘기되 in_bill False + 까닭(원단위 사유 그대로) + input_missing 로 둠. Co-Authored-By: Claude Opus 5 (1M context) --- .../B08_Quantity_Engine_Handoff_Rows.py | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py index eaa403f0..c12f967f 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py @@ -408,6 +408,19 @@ def _structure_rows( bill_quantity = float(structure.get("billing_quantity") or 0.0) else: bill_unit, bill_quantity = "m", length + # ⚠⚠ **물량 0 을 내역에 세우지 않는다**(2026-09-09 감사). 물넘이포장이 면적을 안 받아 + # `0.0 ㎡` 로 서고 있었다 — 코드가 붙어 있어 **0 원 줄**이 만들어지고, 화면에는 + # 「값이 있는 줄」로 보인다. 0 은 「없음」과 구별이 안 된다(오늘 표토에서 겪은 자리). + # ⇒ 줄은 그대로 넘기되 **내역에서 빼고 까닭을 적는다.** + in_bill = bill_quantity > 0 + zero_reason = "" + if not in_bill: + notes = "; ".join(str(note) for note in (structure.get("notes") or [])) + zero_reason = ( + f"물량이 0 이라 내역에 안 세움 — {notes}" + if notes + else "물량이 0 이라 내역에 안 세움 — 저장 제원에서 치수·면적을 넣으면 값이 섭니다" + ) rows.append( { "work_item_code": code, @@ -431,8 +444,8 @@ def _structure_rows( # 철근이 있나 없나로 자동 판정 — 사람이 고르는 값이 아니다. "structure_kind": kind, # ⚠ 줄마다 **왜 막혔는지**를 싣는다 — 안 실으면 받는 쪽 화면이 빈다. - "blocked_kind": blocked_kind, - "blocked_reason": blocked_reason, + "blocked_kind": blocked_kind or (BLOCKED_INPUT_MISSING if not in_bill else None), + "blocked_reason": blocked_reason or zero_reason, # 규격 갈래(뒷길이 …㎝ 이하) — 못 고르면 사유가 남는다. # ⚠ **갈래 키 문자열을 우리가 조립하지 않는다** (2026-09-07 계약 변경). # 품셈 원문이 물결표를 섞어 쓴다(`∼` U+223C / `~` U+FF5E). 두 창이 각자 @@ -447,8 +460,8 @@ def _structure_rows( "spec_class_basis": class_basis, # ⚠ 물량을 못 채운 조각 — 0 으로 적지 않고 사유와 함께 드러낸다. "composite_not_ready": parts_missing or None, - "in_bill": True, - "in_bill_reason": (composite or {}).get("why", ""), + "in_bill": in_bill, + "in_bill_reason": zero_reason or (composite or {}).get("why", ""), "origin": ORIGIN_STRUCTURE, } )