From d366970e1d408981ab358abc6a2dfd85750882a5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 06:44:15 +0900 Subject: [PATCH 01/36] =?UTF-8?q?feat(B08):=20=EA=B8=B0=EC=8A=AD=EB=A7=89?= =?UTF-8?q?=EC=9D=B4=20=EC=A0=84=EA=B0=9C=20=E2=80=94=20=ED=98=95=ED=83=9C?= =?UTF-8?q?=EA=B0=80=20=EB=8F=8C=EC=8C=93=EA=B8=B0=EB=A9=B4=20=EB=8F=8C?= =?UTF-8?q?=EC=8C=93=EA=B8=B0=20=EC=8B=9D=20=ED=95=9C=20=EB=B2=8C=EB=A1=9C?= =?UTF-8?q?=20(3=EB=8B=A8=EA=B3=84=20=EC=B2=AB=20=EC=A2=85=EB=A5=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실무 정본이 근거임 — `구조물도/기슭막이/04.구조도(기슭막이).xls` 탭 제목이 「돌기슭막이(H=2.0m, **찰쌓기**, 기초유)」이고 그 안의 계산이 돌쌓기와 **같은 식**임. ⇒ 기슭막이는 새 식이 아니라 **「형태가 돌쌓기면 돌쌓기 식」**. 여기서 다시 짜면 같은 계산이 두 벌이 되어 돌쌓기와 갈림(CLAUDE.md 5장). 형태별 갈래 돌쌓기(찰) → `stone_masonry(..., wet=True)` 12줄 돌쌓기(메) → `stone_masonry(..., wet=False)` 11줄 콘크리트 · 돌망태 · 통나무/목재틀 · 바자 → **식 없음 + 왜 없는지 한 줄** 콘크리트 돌쌓기(13-4)가 아니라 콘크리트 구조물 — 벽 두께·저판이 정본에 없음 돌망태 품셈 13-8, 규격 축이 망태 치수라 돌쌓기 표를 못 씀 — 원단위 미확보 통나무틀 품셈 13-13, 밑수가 ㎥당 목공 품 — 각재·판재가 카탈로그에 없음 바자 바자얽기(5-15) 별도 일위대가로 잇기로 확정(2026-09-09 ⑧-4) 형태 미정 → 「형태를 골라야 물량이 섭니다」 ⚠ **뒷길이·돌 종류 칸이 기슭막이 등록부에 없음** — 돌쌓기 식은 그 둘로 계수가 갈리는데 없으면 `_back_length` 가 **조용히 45㎝ 로 돎**. 값이 나오므로 아무도 못 알아채는 자리라 **「돌쌓기 기본값으로 섰음」을 줄로 드러냄.** 칸 신설은 등록부 담당 창 몫. ⚠ 확정 대기가 남은 자리(막자갈 식 · 돌 중량 · 체적/석적 줄)는 **지금 값 그대로** 둠 — `stone_masonry` 를 부르므로 그쪽이 갈리면 기슭막이도 같이 따라감. 자체검증 — 여섯 형태를 다 돌려 봄(찰 12줄 · 메 11줄 · 나머지 0줄 + 사유). 회귀 586 통과 · 0 실패. Co-Authored-By: Claude Opus 5 (1M context) --- .../B08_Quantity_Engine_UnitQuantity.py | 12 +++ ..._Quantity_Engine_UnitQuantity_Revetment.py | 81 +++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py index d9e20814..90212d20 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -932,6 +932,10 @@ EXPANDERS = { "masonry_dry": lambda h, l, o, f=None, r="": stone_masonry(h, l, o, False, f, r), # 큰돌쌓기는 표준경사 표 대상이 아니라 성절토를 안 쓴다(교본 「1:0.3 이상」). "boulder_masonry": lambda h, l, o, f=None, r="": boulder_masonry(h, l, o), + # 기슭막이는 **형태가 돌쌓기면 돌쌓기 식**이다 — 실무 정본 탭 제목이 「돌기슭막이(…찰쌓기…)」 + # 이고 그 안의 계산이 같다. 갈래·사유는 `_UnitQuantity_Revetment` 가 든다(늦게 부른다 — + # 그쪽이 이 모듈의 `stone_masonry` 를 쓰므로 위에서 부르면 맞물린다). + "revetment": lambda h, l, o, f=None, r="": _revetment()(h, l, o, f, r), # ⚠⚠ **큰돌쌓기(`boulder_masonry`)를 여기에 두지 않는다** (2026-09-07 발견). # 큰돌쌓기는 품셈 **13-6** 이고 돌쌓기는 **13-4** 다 — **규격 축이 다르다.** # 돌쌓기는 **뒷길이**(35·45·55·60㎝), 큰돌쌓기는 **직경**(40~60·60~80·80~100㎝). @@ -941,6 +945,14 @@ EXPANDERS = { # 전개식·관측 원단위가 설 때까지 **미확보로 드러낸다.** } + +def _revetment(): + """기슭막이 전개 함수를 늦게 가져온다 — 서로 부르는 것을 풀기 위한 자리.""" + from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import revetment + + return revetment + + #: 전개식을 일부러 안 두는 종류 — 왜 안 두는지 사람이 읽게 적는다. EXPANDER_WITHHELD: dict[str, str] = {} diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py new file mode 100644 index 00000000..3c7bf94a --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py @@ -0,0 +1,81 @@ +"""기슭막이 전개 — **형태로 갈라 돌쌓기 식을 그대로 쓴다**(3단계, 2026-09-09). + +실무 정본이 근거다 — `구조물도/기슭막이/04.구조도(기슭막이).xls` 의 탭 제목이 +**「돌기슭막이(H=2.0m, 찰쌓기, 기초유)」** 이고, 그 안의 계산이 돌쌓기와 **같은 식**이다. + + 정면적 → 비탈면적(×√(1+n²)) → 평균두께 → 입적 → 돌 · 막자갈 · 고임돌 → + 터파기(기초 + 비탈) → 되메우기 → 잔토 + +⇒ **기슭막이는 새 식이 아니라 「형태가 돌쌓기면 돌쌓기 식」**이다. 여기서 식을 다시 짜면 + 같은 계산이 두 벌이 되어 돌쌓기와 갈린다(CLAUDE.md 5장). + +⚠ **형태가 돌쌓기가 아닌 갈래는 식이 없다** — 콘크리트·돌망태·통나무/목재틀·바자. + 지어내지 않고 **왜 없는지**를 줄로 남긴다. + +⚠ **뒷길이·돌 종류 칸이 기슭막이 등록부에 아직 없다**(2026-09-09 실측). 돌쌓기 식은 그 둘로 + 계수가 갈리는데, 없으면 `_back_length` 가 **조용히 45㎝ 로 돈다**. 값이 나오므로 아무도 + 못 알아채는 자리라 **그 사실을 줄로 드러낸다** — 「값이 있기는 하니 안 보이는」 그것. +""" + +from __future__ import annotations + +from typing import Any + +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import Component, stone_masonry + +#: 형태 → 돌쌓기 갈래(찰이면 True). 여기 없는 형태는 전개식이 없다. +STONE_FORMS: dict[str, bool] = { + "돌쌓기(찰)": True, + "돌쌓기(메)": False, +} + +#: 식이 없는 형태와 **왜 없는지**. 품셈 장이 다르거나 원단위가 원문에 없다. +WITHHELD_FORMS: dict[str, str] = { + "콘크리트": ( + "콘크리트 기슭막이는 돌쌓기(품셈 13-4)가 아니라 콘크리트 구조물이라 전개식이 다름 " + "— 벽 두께·저판이 정본에 없어 물량이 서지 않음" + ), + "돌망태": ("돌망태는 품셈 13-8 이고 규격 축이 망태 치수라 돌쌓기 표를 못 씀 — 원단위 미확보"), + "통나무·목재틀": ( + "목재틀은 품셈 13-13 이고 밑수가 ㎥당 목공 품이라 돌쌓기 표를 못 씀 " + "— 각재·판재 자재가 카탈로그에 없어 값이 모자람" + ), + "바자": "바자얽기(품셈 5-15)는 별도 일위대가를 만들어 잇기로 확정(2026-09-09 ⑧-4)", +} + +#: 돌쌓기 식이 계수를 가르는 데 쓰는 칸 — 기슭막이 등록부에 아직 없는 것을 알린다. +STONE_SPEC_KEYS: tuple[tuple[str, str], ...] = ( + ("back_len_cm", "뒷길이"), + ("stone_kind", "돌 종류"), +) + + +def revetment( + height_m: float, + length_m: float, + options: dict[str, Any], + face: str | None = None, + face_reason: str = "", +) -> tuple[list[Component], list[str]]: + """기슭막이 1구간 전개 — 형태가 돌쌓기면 그 식, 아니면 사유만.""" + form = str(options.get("form") or "").strip() + if form not in STONE_FORMS: + why = WITHHELD_FORMS.get(form) + if why: + return [], [f"기슭막이 형태 「{form}」 — {why}"] + return [], [ + f"기슭막이 형태가 정해지지 않았습니다(지금 「{form or '빈 값'}」) " + "— 형태를 골라야 물량이 섭니다" + ] + + components, notes = stone_masonry( + height_m, length_m, options, STONE_FORMS[form], face, face_reason + ) + # ⚠ 계수를 가르는 칸이 없으면 **기본값으로 조용히 돈다** — 그 사실을 드러낸다. + missing = [label for key, label in STONE_SPEC_KEYS if not options.get(key)] + if missing and components: + notes.append( + f"⚠ 기슭막이 제원에 {' · '.join(missing)} 칸이 없어 **돌쌓기 기본값으로 섰음** " + "— 그 값이 고임돌·야면석·채움콘크리트 계수를 가름" + ) + return components, notes From 659eba7faa79eb6cfccc74e505fe3a2aa6155812 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 07:47:34 +0900 Subject: [PATCH 02/36] =?UTF-8?q?docs(B08):=20=ED=97=88=EC=9A=A9=EC=98=A4?= =?UTF-8?q?=EC=B0=A8=20=EB=91=98=EC=9D=84=20=ED=95=9C=20=EC=9E=90=EB=A6=AC?= =?UTF-8?q?=EC=97=90=20=EB=AA=A8=EC=9C=BC=EA=B3=A0=20=EC=99=9C=20=EB=8B=A4?= =?UTF-8?q?=EB=A5=B8=EC=A7=80=20=EB=82=A8=EA=B9=80=20=C2=B7=20=EC=B6=95=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=A4=20=EA=B2=80=EC=82=B0=20=EB=AC=B8=EC=9E=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 관 허용오차 두 값이 우연히 다른 것처럼 보였음. `LENGTH_MATCH_TOLERANCE_M`(0.5m, 「길이를 어디서 가져오나」)과 `SECTION_MATCH_TOLERANCE_M`(0.05m, 「횡단이 있나」)을 한 자리에 이름 붙여 모으고 **맞추면 둘 다 나빠지는 까닭**을 적음 ⚠ 두 값의 차이는 증상이고 병은 「관 자리에 측점이 없는 것」(계획서 3-14, B06 자리) - 채집석 축 맞춤 검산을 값과 함께 보냄 — 더하는 쪽·빼는 쪽에 같은 계수가 걸려 사토 총량이 그대로임(61.88 → 61.887) Co-Authored-By: Claude Opus 5 (1M context) --- .../B08_Quantity_Engine_HaulInputs.py | 12 ++++++++++- B08_Quantity/B08_Quantity_Engine_Pipe.py | 21 ++++++++++++++++--- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Engine_HaulInputs.py b/B08_Quantity/B08_Quantity_Engine_HaulInputs.py index 03e03d35..cc9b4aa3 100644 --- a/B08_Quantity/B08_Quantity_Engine_HaulInputs.py +++ b/B08_Quantity/B08_Quantity_Engine_HaulInputs.py @@ -53,6 +53,16 @@ COLLECTED_STONE_COMPONENT = "채집석" #: ⚠ 갈래를 모르는 구조물 몫은 **환산하지 않는다**(계수가 없다) — 따로 낸다. COLLECTED_STONE_BY_GROUND_KEY = "collected_stone_by_ground_m3" COLLECTED_STONE_UNKNOWN_KEY = "collected_stone_ground_unknown_m3" +#: ⭐ **축 맞춤의 검산** — 더하는 쪽(구조물 잔토)과 빼는 쪽(채집석 공제)에 **같은 계수**가 +#: 걸리므로 사토 총량은 안 흔들린다. 실측: 잔토 126.625 ×1.15 = 145.62 담기고 공제 +#: 64.75 ×1.15 = 74.46 빠져 사토(다짐) 71.16 → ÷1.15 = **61.887**, 축 맞추기 전 61.88 과 같다. +#: ⇒ **값을 안 흔들면서 축만 바로잡은 것**이다. 갈래가 갈리거나 모르는 몫이 섞이는 +#: 자리에서만 값이 달라진다. +COLLECTED_STONE_AXIS_CHECK = ( + "축 맞춤 검산 — 더하는 쪽과 빼는 쪽에 같은 계수가 걸려 사토 총량은 그대로" + "(잔토 ×C 담김 · 공제 ×C 빠짐 ⇒ ÷C 하면 같은 값)" +) + COLLECTED_STONE_BASIS_NOTE = ( "벽 입적(제자리 부피) — 자연 축으로 보고 받는 쪽이 ×C 로 다짐 축에 맞춰 뺌." " ⚠ 벽 입적과 원바닥 암 부피의 관계를 정한 원문이 없음(캐면 부풀고 쌓으면 공극이 생기나" @@ -128,7 +138,7 @@ def haul_inputs(unit_quantity_table: dict[str, Any] | None) -> dict[str, Any]: key: round(value, 3) for key, value in sorted(stone_by_ground.items()) }, COLLECTED_STONE_UNKNOWN_KEY: round(stone_unknown, 3) if stone_unknown else 0.0, - "collected_stone_basis": COLLECTED_STONE_BASIS_NOTE, + "collected_stone_basis": f"{COLLECTED_STONE_BASIS_NOTE} · {COLLECTED_STONE_AXIS_CHECK}", STRUCTURE_SPOIL_KEY: round(total, 3) if points or total else None, STRUCTURE_SPOIL_POINTS_KEY: sorted(points, key=lambda row: row["chainage_m"]), # 총량에도 상태를 적는다 — 측점값을 안 쓰는 쪽도 상태를 알아야 한다. diff --git a/B08_Quantity/B08_Quantity_Engine_Pipe.py b/B08_Quantity/B08_Quantity_Engine_Pipe.py index 9261086c..4c254080 100644 --- a/B08_Quantity/B08_Quantity_Engine_Pipe.py +++ b/B08_Quantity/B08_Quantity_Engine_Pipe.py @@ -45,8 +45,17 @@ NOTE_SECTION_MISSING = ( "그 측점의 횡단 자체가 없습니다 — 관은 놓였는데 횡단이 안 만들어진 자리라 " "[저장]으로는 안 풀립니다. 횡단설계에서 그 측점이 서야 합니다" ) -#: 관 자리에 횡단이 있는지 볼 때의 허용 오차. **아주 좁게** — 옆 측점을 「있다」로 세면 -#: 거짓 안내가 된다. 길이 찾기(0.5m)보다 좁은 것은 뜻이 다르기 때문이다. +# ── 허용 오차 둘 — **묻는 것이 다르다.** 한 자리에 모아 둔다 ──────────────── +# ⚠ **맞추지 말 것.** 하나로 맞추면 둘 다 나빠진다(2026-09-09 세 창 확인). +# · `LENGTH_MATCH_TOLERANCE_M` (0.5m) — 「이 관의 **길이를 어디서 가져오나**」. +# 넉넉해도 된다. 값을 못 찾는 것보다 옆 측점 길이를 쓰는 편이 낫다. +# · `SECTION_MATCH_TOLERANCE_M` (0.05m) — 「이 측점에 **횡단 설계가 있나**」. +# 좁아야 한다. 없는 설계를 「있다」고 말하면 사용자가 [저장]을 눌러 보고 헤맨다. +# ⚠ 두 값의 차이는 **증상**이지 병이 아니다. 병은 **관 자리에 측점이 없는 것**이고 +# (측점을 만드는 자리는 B05 노선 [확정] 한 곳뿐 — 관을 나중에 놓거나 옮기면 안 생긴다, +# 계획서 3-14), 그것이 고쳐지면 두 값의 차이가 **아무 관도 안 건드린다** +# (자기 자리에 측점이 있는 관 다섯이 이미 그 상태다). +LENGTH_MATCH_TOLERANCE_M = 0.5 SECTION_MATCH_TOLERANCE_M = 0.05 NOTE_KIND_DEFAULT = "관종을 안 정해 기본값({kind})으로 섰습니다 — 정하면 공종이 갈립니다" @@ -72,10 +81,16 @@ def _length_by_chainage(designs: list[dict[str, Any]], key: str) -> dict[float, return found -def _nearest(lengths: dict[float, float], chainage: float, tolerance: float = 0.5) -> float | None: +def _nearest( + lengths: dict[float, float], + chainage: float, + tolerance: float = LENGTH_MATCH_TOLERANCE_M, +) -> float | None: """관 측점과 단면 측점이 소수점에서 어긋날 수 있어 **가까운 것**을 본다. ⚠ 좁게 본다(기본 0.5m) — 넓히면 옆 측점의 길이를 물어 와 조용히 틀린다. + ⚠ 「횡단이 있나」를 재는 `SECTION_MATCH_TOLERANCE_M`(0.05m)와 **다른 물음**이다 — + 위 주석을 볼 것. 맞추면 둘 다 나빠진다. """ if not lengths: return None From de8f0f15f2982d47cef52b1d5b19f50ae41fe088 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 07:50:39 +0900 Subject: [PATCH 03/36] =?UTF-8?q?fix(B09):=20=E3=80=8C=EB=B6=99=EC=9D=80?= =?UTF-8?q?=20=EB=AA=AB=200%=E3=80=8D=EB=A7=8C=20=EC=A0=81=ED=9E=88?= =?UTF-8?q?=EB=8D=98=20=EC=82=AC=EC=9C=A0=EC=97=90=20**=EB=AC=B4=EC=97=87?= =?UTF-8?q?=EC=9D=B4=20=EC=95=88=20=EB=B6=99=EC=97=88=EB=8A=94=EC=A7=80**?= =?UTF-8?q?=EB=A5=BC=20=ED=95=A8=EA=BB=98=20=EC=A0=81=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「단가가 일부만 섰습니다 — 붙은 몫 0%」 만으로는 어디를 손볼지 알 수 없었음. 구조물터파기가 그 자리였는데, 참조는 이미 풀렸고 실제로 막는 것은 깨기(대형브레이커)· 치즐이었음. - 사유에 `component_gaps`·`unattached` 를 이어 붙임 「붙은 몫 0% · 깨기, 치즐소모량(본/hr) 줄이 아직 안 붙었습니다 (계수 참조는 풀렸습니다).」 검증: pytest 288 통과 Co-Authored-By: Claude Opus 5 (1M context) --- .../B09_Estimation_BillOfQuantities_Rows.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py index fb2d04fe..19ec51ac 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py @@ -250,14 +250,21 @@ def _leaf_row( if covered is not None: # ⚠ **일부 몫만 선 단가는 안 붙인다.** 「인력(10%)·장비(90%)」 표에서 인력만 # 붙은 값을 전량에 곱하면 내역서가 조용히 틀린다 — 0 으로 때우는 것과 같은 사고다. - row.note = f"단가가 일부만 섰습니다 — 붙은 몫 {covered}% (나머지는 시공능력 공식 몫)." + # 무엇이 없어서 못 붙었는지까지 적는다 — 「붙은 몫 0%」만으로는 어디를 손볼지 모른다. + why = unit_prices.component_gaps.get(node.code) or "" + missing_rows = unit_prices.unattached.get(node.code) or [] + if not why and missing_rows: + why = f"{', '.join(missing_rows[:3])} 줄이 아직 안 붙었습니다" + row.note = ( + f"단가가 일부만 섰습니다 — 붙은 몫 {covered}%" + (f" · {why}" if why else "") + "." + ) result.missing.append( { "name": row.name, "code": node.code, "unit": row.unit, "quantity": str(item.quantity), - "reason": f"단가 일부만 섬(붙은 몫 {covered}%)", + "reason": f"단가 일부만 섬(붙은 몫 {covered}%)" + (f" — {why}" if why else ""), } ) return row From ec94a1fca73002d0c50351662b834acc23f91ef8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 07:53:02 +0900 Subject: [PATCH 04/36] =?UTF-8?q?feat(B08):=20=ED=91=9C=ED=86=A0=20?= =?UTF-8?q?=EC=9A=B4=EB=B0=98=C2=B7=EC=A0=81=EC=B9=98=20=EC=A4=84=20?= =?UTF-8?q?=EC=8B=A0=EC=84=A4=20+=20=EB=B6=80=EB=8C=80=EC=8B=9C=EC=84=A4?= =?UTF-8?q?=C2=B7=ED=91=9C=ED=86=A0=EA=B1=B0=EB=A6=AC=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=20=EC=B9=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 표토 운반(법이 요구하는데 제거만 세고 있던 자리) - 시행규칙 별표2 「표토는 전량 제거한 후 … 최고 홍수위보다 높은 장소로 운반하고 쌓아두어야 한다」 — 제거(9-15)만 세면 운반이 빠짐 - 물량은 제거 물량 그대로(다시 안 셈), 거리는 설계 입력 `topsoil_haul_distance_m` - 거리가 없으면 막고 사유 · 제거가 안 서면 운반도 안 섬(밑수가 그 줄) · 법 문구를 사유에 화면 칸(사용자 지시 「대신 페이지에 남길 것」) - 부대시설 개소 다섯(국가지점번호판·안내판·차단기·가설창고·수방자재) — 서버가 받고 있었는데 넣을 칸이 없던 자리. 산식으로 만들지 않는다는 사유를 칸 밑에 적음 - 표토 운반거리 칸 + 별표2 근거 한 줄 - tmp/tests/test_b08_topsoil_haul.py 신설(5건) Co-Authored-By: Claude Opus 5 (1M context) --- .../B08_Quantity_Engine_Preparation.py | 70 ++++++++++++++++++- B08_Quantity/B08_Quantity_Router_Earthwork.py | 5 ++ B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts | 4 ++ B08_Quantity/B08_Quantity_UI_Page.ts | 49 +++++++++++++ common_util/common_util_project_settings.py | 4 ++ ui_template/ui_template_locale_b2.ts | 15 ++++ 6 files changed, 145 insertions(+), 2 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Engine_Preparation.py b/B08_Quantity/B08_Quantity_Engine_Preparation.py index 0973ab9f..83b42fde 100644 --- a/B08_Quantity/B08_Quantity_Engine_Preparation.py +++ b/B08_Quantity/B08_Quantity_Engine_Preparation.py @@ -98,12 +98,14 @@ def preparation_rows( slope_totals: dict[str, float] | None = None, slope_rows: Iterable[dict[str, Any]] = (), topsoil_thickness_m: float | None = None, + topsoil_haul_distance_m: float | None = None, ) -> list[dict[str, Any]]: """준비공 줄 — 값이 서는 것과 안 서는 것을 **한 목록에** 낸다.""" slope = slope_totals or {} tree_area = float(slope.get("tree_removal_fill", 0.0)) + float( slope.get("tree_removal_cut", 0.0) ) + topsoil = _topsoil_row(slope, topsoil_thickness_m) return [ { "group": "준비공", @@ -120,7 +122,9 @@ def preparation_rows( ), "work_item_code": None, }, - _topsoil_row(slope, topsoil_thickness_m), + topsoil, + # ⚠ 법이 요구하는 **운반·적치** — 제거 물량이 곧 밑수다(별표2). + _topsoil_haul_row(topsoil, topsoil_haul_distance_m), { "group": "준비공", "item": "제근·뿌리다듬기", @@ -169,6 +173,67 @@ def _topsoil_row(slope: dict[str, float], thickness_m: float | None) -> dict[str } +#: 표토 운반·적치 — ⚠ **법이 요구하는데 우리가 제거만 세고 있던 자리**(2026-09-09). +#: 시행규칙 별표2 Ⅰ.2.차.(6)·Ⅰ.3.카.(6): +#: 「노면·절토대상지에 있는 입목…과 그 뿌리, **표토는 전량 제거한 후** 강우 시 유실되거나 +#: 경관에 저해되지 않도록 **최고 홍수위보다 높은 장소로 운반하고 쌓아두어야 한다**」 +#: ⇒ 제거(9-15)만 세면 **운반이 빠진다.** 물량은 제거 물량 그대로이고 **거리가 설계 입력**이다. +#: ⚠ 거리를 지어내지 않는다 — 비면 막고 사유를 낸다. +TOPSOIL_HAUL_LAW = ( + "⚠ 법정 의무 — 시행규칙 별표2 「표토는 전량 제거한 후 … 최고 홍수위보다 높은 장소로" + " 운반하고 쌓아두어야 한다」. 제거만 세면 운반이 빠짐" +) + + +def _topsoil_haul_row(topsoil: dict[str, Any], distance_m: float | None) -> dict[str, Any]: + """표토 운반 — 물량은 제거 물량 그대로, 거리는 설계 입력. + + ⚠ 제거 줄이 안 서면(두께 미입력) 운반도 안 선다 — 밑수가 그 줄이기 때문이다. + """ + amount = topsoil.get("amount") + distance = None + if distance_m is not None: + try: + distance = float(distance_m) + except (TypeError, ValueError): + distance = None + if amount is None: + return { + "group": "준비공", + "item": "표토 운반·적치", + "unit": "㎥", + "amount": None, + "status": STATUS_PENDING, + "reason": f"{TOPSOIL_HAUL_LAW} · 제거 물량이 아직 안 서서 운반도 못 셈(두께 먼저)", + "work_item_code": None, + } + if distance is None or distance <= 0: + return { + "group": "준비공", + "item": "표토 운반·적치", + "unit": "㎥", + "amount": None, + "status": STATUS_PENDING, + "reason": ( + f"{TOPSOIL_HAUL_LAW} · 운반거리가 아직 입력되지 않았습니다 — 「최고 홍수위보다" + f" 높은 장소」는 현장에서 정하는 자리라 품셈이 거리를 주지 않습니다" + f" (운반할 물량 {float(amount):,.2f}㎥)" + ), + "reference_amount": float(amount), + "work_item_code": None, + } + return { + "group": "준비공", + "item": "표토 운반·적치", + "unit": "㎥", + "amount": float(amount), + "status": STATUS_READY, + "reason": f"{TOPSOIL_HAUL_LAW} · 제거 물량 그대로 · 운반거리 {distance:g}m(설계 입력)", + "work_item_code": "FP-10-12", + "haul_distance_m": distance, + } + + def _batter_frame_row(slope_rows: list[dict[str, Any]]) -> dict[str, Any]: """비탈 규준틀 한 줄. **개소는 원문 기준으로 서고 재료는 미확보**다.""" count, notes = batter_frame_count(slope_rows) @@ -351,10 +416,11 @@ def build_table( topsoil_thickness_m: float | None = None, names: dict[str, str] | None = None, ancillary_counts: dict[str, Any] | None = None, + topsoil_haul_distance_m: float | None = None, ) -> dict[str, Any]: """화면·인계가 그대로 쓰는 모양. **못 서는 줄도 목록에 남는다.**""" rows = ( - preparation_rows(slope_totals, slope_rows, topsoil_thickness_m) + preparation_rows(slope_totals, slope_rows, topsoil_thickness_m, topsoil_haul_distance_m) + erosion_rows(structures, names) + ancillary_rows(ancillary_counts) ) diff --git a/B08_Quantity/B08_Quantity_Router_Earthwork.py b/B08_Quantity/B08_Quantity_Router_Earthwork.py index f928aa37..9879da54 100644 --- a/B08_Quantity/B08_Quantity_Router_Earthwork.py +++ b/B08_Quantity/B08_Quantity_Router_Earthwork.py @@ -138,6 +138,8 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse: {type_id: definition.name for type_id, definition in structure_type_map().items()}, # 부대시설 개소 — 산식으로 만들지 않고 **설계자가 넣은 값**만 쓴다(확정 ⑬). settings.get("ancillary_counts") or {}, + # 표토 운반거리 — 별표2 가 요구하는 운반·적치의 밑수(거리는 현장값). + settings.get("topsoil_haul_distance_m"), ) method, method_is_default = concrete_placing_method(settings) # ⚠ 금액에 바로 걸리는 값이라 「기본값으로 돌고 있음」을 응답에 실어 화면이 띄우게 한다. @@ -316,6 +318,8 @@ class QuantitySettingsBody(BaseModel): rubble_base_thickness_m: float | None = None # 구조물터파기 용수 유무 — "육상"·"용수". ⚠ 기본 육상은 **통상값**이지 사용자 확정이 아니다. structure_trench_water: str | None = None + # 표토 운반거리(m) — 별표2 가 요구하는 운반·적치의 밑수. 비면 그 줄이 막힌다. + topsoil_haul_distance_m: float | None = None #: `None` 이 「안 정함」을 뜻하는 칸 — 저장에서 **버리지 않고 그대로 덮어쓴다**. @@ -325,6 +329,7 @@ NULLABLE_SETTING_KEYS = ( "bench_cut_depth_m", "spoil_site_distance_m", "rubble_base_thickness_m", + "topsoil_haul_distance_m", ) diff --git a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts index 49a8b023..09f24d24 100644 --- a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts +++ b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts @@ -75,6 +75,10 @@ export interface QuantitySettings { spoil_site_distance_m?: number | null; /** 구조물터파기 용수 — `"육상"`·`"용수"`. ⚠ 「육상」은 통상값이지 사용자 확정이 아니다. */ structure_trench_water?: string | null; + /** 표토 운반거리(m) — 별표2 가 요구하는 운반·적치의 밑수. 비면 그 줄이 막힌다. */ + topsoil_haul_distance_m?: number | null; + /** 부대시설 개소 — `{항목키: 개소}`. ⚠ 산식으로 만들지 않는다(확정 13). */ + ancillary_counts?: Record; } export interface EarthworkTable { diff --git a/B08_Quantity/B08_Quantity_UI_Page.ts b/B08_Quantity/B08_Quantity_UI_Page.ts index a86d81f4..39193713 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.ts +++ b/B08_Quantity/B08_Quantity_UI_Page.ts @@ -91,6 +91,9 @@ async function saveQuantitySettings(projectId: string, draft: DraftSettings): Pr rubble_base_thickness_m: draft.rubble_base_thickness_m, spoil_site_distance_m: draft.spoil_site_distance_m, structure_trench_water: draft.structure_trench_water, + topsoil_haul_distance_m: draft.topsoil_haul_distance_m, + // 개소는 **통째로** 보낸다 — 지운 항목까지 그대로 가야 되돌릴 길이 있다. + ancillary_counts: draft.ancillary_counts, }), }, ); @@ -110,6 +113,15 @@ function field(label: string, value: string): HTMLElement { return row; } +/** 부대시설 항목 키 — 서버 `ANCILLARY_ITEMS` 와 **같은 차례·같은 낱말**이라야 한다. */ +const ANCILLARY_KEYS = [ + "national_point_sign", + "guide_sign", + "gate", + "site_container", + "flood_supplies", +] as const; + /** 칸 밑에 붙는 **근거 한 줄** — 왜 그 값인지 화면에서 보이게 한다(2026-09-09 사용자 지시). */ function hintRow(text: string): HTMLElement { const row = document.createElement("p"); @@ -249,6 +261,10 @@ interface DraftSettings { spoil_site_distance_m: number | null; // 구조물터파기 용수 — "육상"·"용수". ⚠ 「육상」은 **통상값**이지 사용자 확정이 아니다. structure_trench_water: string; + // 표토 운반거리(m) — 별표2 가 요구하는 운반·적치의 밑수. `null` 은 「안 정함」. + topsoil_haul_distance_m: number | null; + // 부대시설 개소 — `{항목키: 개소}`. ⚠ 산식으로 만들지 않는다(확정 13). + ancillary_counts: Record; // 자재별 관급/사급 — 표 안에서 줄마다 고른 값. material_supply: Record; dirty: boolean; @@ -486,6 +502,35 @@ function buildQuantitySidePanel( ), ); panel.append(hintRow(L("B08_Quantity_Side_Water_Hint"))); + panel.append( + optionalNumberField( + L("B08_Quantity_Side_TopsoilHaul_Label"), + draft.topsoil_haul_distance_m, + "10", + (value) => { + draft.topsoil_haul_distance_m = value; + draft.dirty = true; + }, + ), + ); + panel.append(hintRow(L("B08_Quantity_Side_TopsoilHaul_Hint"))); + + // ── 부대시설 개소 — ⚠ **산식으로 만들지 않는다**(확정 13). 넣어야 줄이 선다 ── + panel.append(field(L("B08_Quantity_Side_Ancillary"), "")); + for (const key of ANCILLARY_KEYS) { + panel.append( + optionalNumberField( + L(`B08_Quantity_Ancillary_${key}` as keyof typeof ui_locales), + draft.ancillary_counts[key] ?? null, + "1", + (value) => { + draft.ancillary_counts[key] = value; + draft.dirty = true; + }, + ), + ); + } + panel.append(hintRow(L("B08_Quantity_Side_Ancillary_Hint"))); // ── 콘크리트 타설 방식 — ⚠ **금액에 바로 걸리는 값**이라 잠정임을 조용히 두지 않는다 ── panel.append(field(L("B08_Quantity_Side_Placing"), "")); @@ -742,6 +787,10 @@ export async function renderB08Quantity(root: HTMLElement): Promise { rubble_base_thickness_m: (stored.rubble_base_thickness_m as number | null) ?? null, spoil_site_distance_m: (stored.spoil_site_distance_m as number | null) ?? null, structure_trench_water: (stored.structure_trench_water as string) ?? "", + topsoil_haul_distance_m: (stored.topsoil_haul_distance_m as number | null) ?? null, + ancillary_counts: { + ...((stored.ancillary_counts ?? {}) as Record), + }, material_supply: { ...((stored.material_supply ?? {}) as Record) }, dirty: false, }; diff --git a/common_util/common_util_project_settings.py b/common_util/common_util_project_settings.py index 880b2537..2a0f6499 100644 --- a/common_util/common_util_project_settings.py +++ b/common_util/common_util_project_settings.py @@ -127,6 +127,10 @@ def default_settings() -> dict[str, Any]: # ⚠ **사용자 확정이 아니라 통상값**이다(확정 3차 ④). 화면·사유에 그 사실을 # 적어 두고 사용자가 뒤집을 수 있게 둔다. "structure_trench_water": "육상", + # 표토 운반거리(m) — ⚠ 별표2 Ⅰ.2.차.(6) 「표토는 전량 제거한 후 … 최고 홍수위보다 + # 높은 장소로 **운반하고 쌓아두어야** 한다」. 제거만 세면 법이 요구하는 운반이 빠진다. + # 거리는 현장값이라 품셈이 정하지 않는다 — 비면 운반 줄이 막힌 채로 선다. + "topsoil_haul_distance_m": None, "dataset_versions": {}, }, "estimation": { diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 0f8e0e8e..fa400c21 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -674,6 +674,21 @@ export const ui_locales_b2 = { "⚠ 「육상」은 통상값이고 사용자 확정이 아닙니다(확정 3차 ④) — 품셈 9-13 의 18구분이 이 값으로 갈립니다", "⚠ “Dry” is a customary default, not a user decision — it selects one of the 18 sub-items", ], + B08_Quantity_Side_Ancillary: ["부대시설 개소", "Ancillary Facilities"], + B08_Quantity_Side_Ancillary_Hint: [ + "개소를 넣어야 줄이 섭니다 — 원문이 배치 간격을 정하지 않아 연장÷500 같은 산식을 쓰지 않습니다(확정 13)", + "Counts are design input — the source sets no spacing rule, so no formula is used", + ], + B08_Quantity_Ancillary_national_point_sign: ["국가지점번호판(개소)", "National point signs"], + B08_Quantity_Ancillary_guide_sign: ["임도 안내판(개소)", "Guide signs"], + B08_Quantity_Ancillary_gate: ["차단기(개소)", "Gates"], + B08_Quantity_Ancillary_site_container: ["가설창고(개소)", "Site containers"], + B08_Quantity_Ancillary_flood_supplies: ["수방대책 자재(식)", "Flood supplies (set)"], + B08_Quantity_Side_TopsoilHaul_Label: ["표토 운반거리(m)", "Topsoil haul distance (m)"], + B08_Quantity_Side_TopsoilHaul_Hint: [ + "⚠ 별표2 는 표토를 「전량 제거한 후 최고 홍수위보다 높은 장소로 운반하고 쌓아두어야」로 정합니다 — 거리를 넣으면 운반 줄이 섭니다", + "The law requires hauling and stockpiling removed topsoil — enter the distance to raise that row", + ], B08_Quantity_Side_Placing: ["콘크리트 타설", "Concrete Placing"], B08_Quantity_Side_Placing_Label: ["타설 방식", "Method"], B08_Quantity_Placing_Unset: ["안 정함(기본값 사용)", "Not set (default)"], From e4172fd13d430b1d81edeee0cd5592428e2afba7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 07:58:47 +0900 Subject: [PATCH 05/36] =?UTF-8?q?fix(B08):=20=EC=A0=9C=EA=B7=BC=20?= =?UTF-8?q?=EC=82=AC=EC=9C=A0=EA=B0=80=20=ED=8B=80=EB=A6=B0=20=EB=A7=90?= =?UTF-8?q?=EC=9D=B4=EC=97=88=EC=9D=8C=20=E2=80=94=20=EB=B3=B8=EC=88=98?= =?UTF-8?q?=EA=B0=80=20=EC=95=84=EB=8B=88=EB=9D=BC=20=EC=9E=84=EB=AA=A9?= =?UTF-8?q?=EC=B6=95=EC=A0=81=EC=9C=BC=EB=A1=9C=20=EA=B0=88=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 「입목 본수를 들고 있지 않음」이 원문과 달랐음. 품셈 9-21 [주]① 은 **소림(30~60㎥/㏊)·중림(60~90)·밀림(90 이상)** 으로 가름 — 본수가 아니라 **축적** - ⚠⚠ 그리고 **9-21 표에 밑수 단위 열이 없음**(9-20 은 「10주당」이라 적혀 있음). 등급을 넣어도 「무엇당 값」인지 모르면 못 셈 — 원문 확인이 필요한 자리로 사유에 적음 - 임목축적 등급 설정·화면 칸 신설(`stand_volume_class`) + 근거 한 줄 (산림조사부·영림계획에서 옮겨 적는 값이라 프로그램이 만들 수 없음) - 대상 면적(벌개제근 연동, 지장목제거와 같은 자리)을 참고값으로 실음 Co-Authored-By: Claude Opus 5 (1M context) --- .../B08_Quantity_Engine_Preparation.py | 62 ++++++++++++++++--- B08_Quantity/B08_Quantity_Router_Earthwork.py | 4 ++ B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts | 2 + B08_Quantity/B08_Quantity_UI_Page.ts | 21 +++++++ common_util/common_util_project_settings.py | 4 ++ ui_template/ui_template_locale_b2.ts | 9 +++ 6 files changed, 92 insertions(+), 10 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Engine_Preparation.py b/B08_Quantity/B08_Quantity_Engine_Preparation.py index 83b42fde..b8a75805 100644 --- a/B08_Quantity/B08_Quantity_Engine_Preparation.py +++ b/B08_Quantity/B08_Quantity_Engine_Preparation.py @@ -99,6 +99,7 @@ def preparation_rows( slope_rows: Iterable[dict[str, Any]] = (), topsoil_thickness_m: float | None = None, topsoil_haul_distance_m: float | None = None, + stand_volume_class: str | None = None, ) -> list[dict[str, Any]]: """준비공 줄 — 값이 서는 것과 안 서는 것을 **한 목록에** 낸다.""" slope = slope_totals or {} @@ -125,15 +126,7 @@ def preparation_rows( topsoil, # ⚠ 법이 요구하는 **운반·적치** — 제거 물량이 곧 밑수다(별표2). _topsoil_haul_row(topsoil, topsoil_haul_distance_m), - { - "group": "준비공", - "item": "제근·뿌리다듬기", - "unit": "개", - "amount": None, - "status": STATUS_PENDING, - "reason": "단위가 「개」(그루 수)인데 입목 본수를 들고 있지 않음 (품셈 9-20~21)", - "work_item_code": "FP-09-21", - }, + _root_removal_row(slope, stand_volume_class), _batter_frame_row(list(slope_rows)), _level_frame_row(list(slope_rows)), ] @@ -185,6 +178,48 @@ TOPSOIL_HAUL_LAW = ( ) +#: 제근 — ⚠ **본수가 아니라 임목축적으로 갈린다**(2026-09-09 원문 확인). +#: 품셈 9-21 [주]① 「소림 : 임목축적이 30㎥/㏊ 이상 60 미만 · 중림 : 60 이상 90 미만 · +#: 밀림 : 90 이상」. ⇒ 물어야 할 값은 **본/ha 밀도가 아니라 축적 등급**이다. +#: ⚠⚠ **그런데 원문 표에 밑수 단위 열이 없다** — 9-20(뿌리다듬기·적재)은 「10주당」이라 +#: 적혀 있는데 9-21 표에는 그 표기가 없다. 등급을 넣어도 **무엇당 값인지 모르면 못 센다.** +#: 지어내지 않고 그 사실을 사유로 낸다(원문 확인이 필요한 자리). +STAND_VOLUME_CLASSES = ("소림", "중림", "밀림") +ROOT_REMOVAL_BASIS_UNKNOWN = ( + "⚠ 품셈 9-21 표에 **밑수 단위가 없음** — 9-20 은 「10주당」이라 적혀 있으나 9-21 에는" + " 그 표기가 없어 「무엇당 값」인지 원문으로 확인해야 함. 등급을 넣어도 그 전에는 못 셈" +) +ROOT_REMOVAL_CLASS_MISSING = ( + "임목축적 등급이 아직 입력되지 않았습니다 — 품셈 9-21 [주]① 이 소림(30~60㎥/㏊)·" + "중림(60~90)·밀림(90 이상)으로 가름. ⚠ 본수가 아니라 **축적**이고, 산림조사부·영림계획에서" + " 옮겨 적는 값이라 프로그램이 만들 수 없음" +) + + +def _root_removal_row(slope: dict[str, float], stand_volume_class: str | None) -> dict[str, Any]: + """제근·뿌리다듬기 — 등급과 밑수 단위가 다 서야 값이 난다. + + ⚠ 대상 면적은 지장목제거와 같은 자리(벌개제근 연동)라 이미 서 있다 — 참고로 보인다. + """ + area = float(slope.get("tree_removal_fill", 0.0)) + float(slope.get("tree_removal_cut", 0.0)) + picked = str(stand_volume_class or "").strip() + reasons = [ROOT_REMOVAL_BASIS_UNKNOWN] + if picked in STAND_VOLUME_CLASSES: + reasons.append(f"등급은 「{picked}」로 정해져 있음") + else: + reasons.append(ROOT_REMOVAL_CLASS_MISSING) + return { + "group": "준비공", + "item": "제근·뿌리다듬기", + "unit": "개", + "amount": None, + "status": STATUS_PENDING, + "reason": " · ".join(reasons) + f" (대상 면적 {area:,.1f}㎡ — 벌개제근 연동)", + "reference_amount": area, + "work_item_code": "FP-09-21", + } + + def _topsoil_haul_row(topsoil: dict[str, Any], distance_m: float | None) -> dict[str, Any]: """표토 운반 — 물량은 제거 물량 그대로, 거리는 설계 입력. @@ -417,10 +452,17 @@ def build_table( names: dict[str, str] | None = None, ancillary_counts: dict[str, Any] | None = None, topsoil_haul_distance_m: float | None = None, + stand_volume_class: str | None = None, ) -> dict[str, Any]: """화면·인계가 그대로 쓰는 모양. **못 서는 줄도 목록에 남는다.**""" rows = ( - preparation_rows(slope_totals, slope_rows, topsoil_thickness_m, topsoil_haul_distance_m) + preparation_rows( + slope_totals, + slope_rows, + topsoil_thickness_m, + topsoil_haul_distance_m, + stand_volume_class, + ) + erosion_rows(structures, names) + ancillary_rows(ancillary_counts) ) diff --git a/B08_Quantity/B08_Quantity_Router_Earthwork.py b/B08_Quantity/B08_Quantity_Router_Earthwork.py index 9879da54..2ebb4d30 100644 --- a/B08_Quantity/B08_Quantity_Router_Earthwork.py +++ b/B08_Quantity/B08_Quantity_Router_Earthwork.py @@ -140,6 +140,8 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse: settings.get("ancillary_counts") or {}, # 표토 운반거리 — 별표2 가 요구하는 운반·적치의 밑수(거리는 현장값). settings.get("topsoil_haul_distance_m"), + # 임목축적 등급 — 품셈 9-21 제근이 소·중·밀로 갈리는 축(본수가 아니다). + settings.get("stand_volume_class"), ) method, method_is_default = concrete_placing_method(settings) # ⚠ 금액에 바로 걸리는 값이라 「기본값으로 돌고 있음」을 응답에 실어 화면이 띄우게 한다. @@ -320,6 +322,8 @@ class QuantitySettingsBody(BaseModel): structure_trench_water: str | None = None # 표토 운반거리(m) — 별표2 가 요구하는 운반·적치의 밑수. 비면 그 줄이 막힌다. topsoil_haul_distance_m: float | None = None + # 임목축적 등급 — "소림"·"중림"·"밀림"(품셈 9-21 [주]①). `""` 는 「안 정함」이다. + stand_volume_class: str | None = None #: `None` 이 「안 정함」을 뜻하는 칸 — 저장에서 **버리지 않고 그대로 덮어쓴다**. diff --git a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts index 09f24d24..681fb0b4 100644 --- a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts +++ b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts @@ -79,6 +79,8 @@ export interface QuantitySettings { topsoil_haul_distance_m?: number | null; /** 부대시설 개소 — `{항목키: 개소}`. ⚠ 산식으로 만들지 않는다(확정 13). */ ancillary_counts?: Record; + /** 임목축적 등급 — `"소림"`·`"중림"`·`"밀림"`(품셈 9-21 [주]①). 본수가 아니라 축적이다. */ + stand_volume_class?: string | null; } export interface EarthworkTable { diff --git a/B08_Quantity/B08_Quantity_UI_Page.ts b/B08_Quantity/B08_Quantity_UI_Page.ts index 39193713..72fb1ada 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.ts +++ b/B08_Quantity/B08_Quantity_UI_Page.ts @@ -92,6 +92,7 @@ async function saveQuantitySettings(projectId: string, draft: DraftSettings): Pr spoil_site_distance_m: draft.spoil_site_distance_m, structure_trench_water: draft.structure_trench_water, topsoil_haul_distance_m: draft.topsoil_haul_distance_m, + stand_volume_class: draft.stand_volume_class, // 개소는 **통째로** 보낸다 — 지운 항목까지 그대로 가야 되돌릴 길이 있다. ancillary_counts: draft.ancillary_counts, }), @@ -265,6 +266,8 @@ interface DraftSettings { topsoil_haul_distance_m: number | null; // 부대시설 개소 — `{항목키: 개소}`. ⚠ 산식으로 만들지 않는다(확정 13). ancillary_counts: Record; + // 임목축적 등급 — "소림"·"중림"·"밀림". ⚠ 본수가 아니라 축적이다(품셈 9-21 [주]①). + stand_volume_class: string; // 자재별 관급/사급 — 표 안에서 줄마다 고른 값. material_supply: Record; dirty: boolean; @@ -514,6 +517,23 @@ function buildQuantitySidePanel( ), ); panel.append(hintRow(L("B08_Quantity_Side_TopsoilHaul_Hint"))); + panel.append( + selectField( + L("B08_Quantity_Side_StandVolume_Label"), + draft.stand_volume_class, + [ + { value: "", label: L("B08_Quantity_StandVolume_Unset") }, + { value: "소림", label: L("B08_Quantity_StandVolume_Low") }, + { value: "중림", label: L("B08_Quantity_StandVolume_Mid") }, + { value: "밀림", label: L("B08_Quantity_StandVolume_High") }, + ], + (value) => { + draft.stand_volume_class = value; + draft.dirty = true; + }, + ), + ); + panel.append(hintRow(L("B08_Quantity_Side_StandVolume_Hint"))); // ── 부대시설 개소 — ⚠ **산식으로 만들지 않는다**(확정 13). 넣어야 줄이 선다 ── panel.append(field(L("B08_Quantity_Side_Ancillary"), "")); @@ -788,6 +808,7 @@ export async function renderB08Quantity(root: HTMLElement): Promise { spoil_site_distance_m: (stored.spoil_site_distance_m as number | null) ?? null, structure_trench_water: (stored.structure_trench_water as string) ?? "", topsoil_haul_distance_m: (stored.topsoil_haul_distance_m as number | null) ?? null, + stand_volume_class: (stored.stand_volume_class as string) ?? "", ancillary_counts: { ...((stored.ancillary_counts ?? {}) as Record), }, diff --git a/common_util/common_util_project_settings.py b/common_util/common_util_project_settings.py index 2a0f6499..3503cb28 100644 --- a/common_util/common_util_project_settings.py +++ b/common_util/common_util_project_settings.py @@ -131,6 +131,10 @@ def default_settings() -> dict[str, Any]: # 높은 장소로 **운반하고 쌓아두어야** 한다」. 제거만 세면 법이 요구하는 운반이 빠진다. # 거리는 현장값이라 품셈이 정하지 않는다 — 비면 운반 줄이 막힌 채로 선다. "topsoil_haul_distance_m": None, + # 임목축적 등급 — 품셈 9-21 제근이 **소·중·밀** 셋으로 갈리는 축. + # [주]① 「소림 30~60㎥/㏊ · 중림 60~90 · 밀림 90 이상」 — 본수가 아니라 **축적**이다. + # ⚠ 산림조사부·영림계획에서 옮겨 적는 값이라 프로그램이 만들 수 없다. 비면 막는다. + "stand_volume_class": None, "dataset_versions": {}, }, "estimation": { diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index fa400c21..ea2bb4c8 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -674,6 +674,15 @@ export const ui_locales_b2 = { "⚠ 「육상」은 통상값이고 사용자 확정이 아닙니다(확정 3차 ④) — 품셈 9-13 의 18구분이 이 값으로 갈립니다", "⚠ “Dry” is a customary default, not a user decision — it selects one of the 18 sub-items", ], + B08_Quantity_Side_StandVolume_Label: ["임목축적 등급", "Stand volume class"], + B08_Quantity_StandVolume_Unset: ["안 정함(줄이 막힘)", "Not set (row blocked)"], + B08_Quantity_StandVolume_Low: ["소림(30~60㎥/㏊)", "Low (30–60 ㎥/ha)"], + B08_Quantity_StandVolume_Mid: ["중림(60~90㎥/㏊)", "Mid (60–90 ㎥/ha)"], + B08_Quantity_StandVolume_High: ["밀림(90㎥/㏊ 이상)", "High (90+ ㎥/ha)"], + B08_Quantity_Side_StandVolume_Hint: [ + "제근(품셈 9-21)이 이 등급으로 갈립니다 — ⚠ 본수가 아니라 축적이고, 산림조사부·영림계획에서 옮겨 적는 값입니다", + "Root removal (9-21) splits by this class — stand volume, not tree count; copied from the forest survey", + ], B08_Quantity_Side_Ancillary: ["부대시설 개소", "Ancillary Facilities"], B08_Quantity_Side_Ancillary_Hint: [ "개소를 넣어야 줄이 섭니다 — 원문이 배치 간격을 정하지 않아 연장÷500 같은 산식을 쓰지 않습니다(확정 13)", From b1de2c0d33033a61be80bf51258a270116c170b1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 08:04:17 +0900 Subject: [PATCH 06/36] =?UTF-8?q?feat(B08):=20=EB=BF=8C=EB=A6=AC=20?= =?UTF-8?q?=EC=A0=81=EC=9E=AC=C2=B7=EC=9A=B4=EB=B0=98=20=EB=91=90=20?= =?UTF-8?q?=EC=A4=84=20=EC=8B=A0=EC=84=A4=20=E2=80=94=20=ED=92=88=EC=85=88?= =?UTF-8?q?=EC=9D=B4=20=EB=84=A4=20=EB=8B=A8=EA=B3=84=EB=A1=9C=20=EB=91=94?= =?UTF-8?q?=20=EC=9E=90=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 9-20 가. 「벌개·제근 → 뿌리다듬기 → 적재 → 운반」이 시공 과정 표준이고 [주] 가 「제근 후 뿌리다듬기와 적재항목에 적용」이라 두 항목을 줌. 별표2 도 「그 뿌리…를 전량 제거한 후 운반하고 쌓아두어야」로 뒤 단계를 요구 — 우리는 제거 한 줄만 세고 있었음 (표토와 같은 병: 법·품셈이 두 동작을 묶는데 앞 동작만 셈) - 적재는 FP-09-20-02(「10주당」) · ⚠ 운반은 그 장에 공종이 없어 코드를 못 붙임 — 지어내지 않고 사유로 냄 - 둘 다 제근과 같은 밑수(본수)가 서야 값이 남. 대상 면적은 참고값으로 실음 - tmp/tests 3건 추가 Co-Authored-By: Claude Opus 5 (1M context) --- .../B08_Quantity_Engine_Preparation.py | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/B08_Quantity/B08_Quantity_Engine_Preparation.py b/B08_Quantity/B08_Quantity_Engine_Preparation.py index b8a75805..91ca4e08 100644 --- a/B08_Quantity/B08_Quantity_Engine_Preparation.py +++ b/B08_Quantity/B08_Quantity_Engine_Preparation.py @@ -127,6 +127,7 @@ def preparation_rows( # ⚠ 법이 요구하는 **운반·적치** — 제거 물량이 곧 밑수다(별표2). _topsoil_haul_row(topsoil, topsoil_haul_distance_m), _root_removal_row(slope, stand_volume_class), + *_root_steps_rows(slope, stand_volume_class), _batter_frame_row(list(slope_rows)), _level_frame_row(list(slope_rows)), ] @@ -220,6 +221,54 @@ def _root_removal_row(slope: dict[str, float], stand_volume_class: str | None) - } +#: ⚠⚠ **품셈이 네 단계로 두었는데 우리가 한 줄만 세고 있었다**(2026-09-09). +#: 9-20 가. 「벌개·제근 → **뿌리다듬기** → **적재** → **운반**」이 시공 과정 표준이고 +#: [주] 「본 품에서는 제근 후 **뿌리다듬기와 적재항목**에 적용한다」로 두 항목을 준다. +#: 법도 같은 말을 한다 — 별표2 「입목…과 그 뿌리, 표토는 전량 제거한 후 … **운반하고 +#: 쌓아두어야** 한다」. ⇒ 제거만 세면 **적재·운반이 빠진다**(표토에서 이미 겪은 자리). +#: ⚠ 적재는 9-20-2 로 코드가 있고, **운반은 그 장에 공종이 없다** — 10장 계열 어디에 붙는지 +#: 원문이 말하지 않는다. 지어내지 않고 사유로 낸다. +ROOT_STEPS_NOTE = ( + "품셈 9-20 가. 「벌개·제근 → 뿌리다듬기 → 적재 → 운반」 · 별표2 「그 뿌리…를 전량 제거한 후" + " 운반하고 쌓아두어야 한다」 — 제거만 세면 뒤 단계가 빠짐" +) + + +def _root_steps_rows( + slope: dict[str, float], stand_volume_class: str | None +) -> list[dict[str, Any]]: + """뿌리 적재·운반 — **제근과 한 벌로 가는 뒤 단계**. 밑수가 서면 함께 선다.""" + area = float(slope.get("tree_removal_fill", 0.0)) + float(slope.get("tree_removal_cut", 0.0)) + return [ + { + "group": "준비공", + "item": "뿌리 적재", + "unit": "개", + "amount": None, + "status": STATUS_PENDING, + "reason": ( + f"{ROOT_STEPS_NOTE} · 품셈 9-20-2 「(단위: 10주당)」 — **본수가 서야 셈**" + f" (대상 면적 {area:,.1f}㎡ · 제근과 같은 밑수)" + ), + "reference_amount": area, + "work_item_code": "FP-09-20-02", + }, + { + "group": "준비공", + "item": "뿌리 운반", + "unit": "개", + "amount": None, + "status": STATUS_PENDING, + "reason": ( + f"{ROOT_STEPS_NOTE} · ⚠ **9-20 장에 운반 공종이 없음** — 10장 계열 어디에" + " 붙는지 원문이 말하지 않아 공종코드를 못 붙임" + ), + "reference_amount": area, + "work_item_code": None, + }, + ] + + def _topsoil_haul_row(topsoil: dict[str, Any], distance_m: float | None) -> dict[str, Any]: """표토 운반 — 물량은 제거 물량 그대로, 거리는 설계 입력. From 1dceeb5befaac6a5da454a518fd450328af1e0cb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 08:06:02 +0900 Subject: [PATCH 07/36] =?UTF-8?q?feat(B09):=20=EA=B9=A8=EA=B8=B0(=EB=8C=80?= =?UTF-8?q?=ED=98=95=EB=B8=8C=EB=A0=88=EC=9D=B4=EC=BB=A4)=EA=B0=80=20?= =?UTF-8?q?=EB=B6=99=EC=96=B4=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=ED=84=B0?= =?UTF-8?q?=ED=8C=8C=EA=B8=B0=EA=B0=80=20=EC=84=AC=20=E2=80=94=20=EB=B3=B8?= =?UTF-8?q?=EC=B2=B4=203,089=EB=A7=8C=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 품셈 제8장 [주]⑤ 「불도저 및 굴착기에 리퍼, 브레이커, 부착용집게를 조합하여 사용할 때는 …잡재료비율을 16%로 계상하고, 리퍼, 브레이커, 부착용 집게의 손료 및 치즐 소모율을 추가하는 것이다」 를 따라감. 세 자리를 이었음 1. **뭉개진 카탈로그 줄을 원문으로 되살림** — `mach_base_2026.json` 의 대형 브레이커 여섯 줄이 이름 칸에 표 전체가 뭉쳐 규격·손료계수가 없었음. 건설품셈 제8장 (0230) 표에서 읽어 채움. ⚠ **두 번 검증** — 계수 합 3,000+2,833+768=6,601 이 표의 「계」와 같고, 같은 방식으로 읽은 굴착기 「계 2,085」가 카탈로그 0.0002085 와 정확히 일치 2. **부착용 장비는 손료만으로 층을 세움** — 제 엔진이 없어 운전경비표에 줄이 없음. 없다고 버리면 깨기 몫이 영영 안 붙음. ⚠ 리퍼·브레이커·집게에만 여는 길 3. **작업량을 직접 준 기계 줄을 읽음** — 「대형브레이커(㎥/hr) 3.5」. 공식이 아니라 시간당 작업량을 표가 바로 주는 모양. 단위가 붙은 칸만 읽음(짐작 금지) 결과 - 구조물터파기(암절취·육상·2~3m) 155.625㎥ × 72,378.4 = **11,263,899원** — 처음 섬 - 돌쌓기 단가에 **부착용 집게 손료**가 더해짐(메 76,607.9 → 78,864.5) — [주]⑤ 그대로 - 본체 19,444,626 → **30,892,279원** - 남은 것은 **치즐(자재) 하나** — 기계가 빠지면 막고 자재 소모품이 빠지면 드러내기만 하는 규칙대로, 금액을 세우고 본표가 「치즐소모량 줄이 아직 안 붙었습니다」를 말함 ⚠ 아직 안 한 것 — 같은 [주]⑤ 의 **「잡재료비율 16%」**. 조합 사용 시 굴착기 잡재료가 22% 가 아니라 16% 임. 그 자리는 운전경비 층이라 따로 손봐야 함. 검증: pytest 290 통과(신규 3) Co-Authored-By: Claude Opus 5 (1M context) --- B09_Estimation/B09_Estimation_MachineCost.py | 31 +++++ ...stimation_MachineProductivity_Reference.py | 107 ++++++++++++++++++ B09_Estimation/B09_Estimation_UnitPrice.py | 99 +++++++++++++++- 3 files changed, 234 insertions(+), 3 deletions(-) diff --git a/B09_Estimation/B09_Estimation_MachineCost.py b/B09_Estimation/B09_Estimation_MachineCost.py index a571234b..3c65e5a2 100644 --- a/B09_Estimation/B09_Estimation_MachineCost.py +++ b/B09_Estimation/B09_Estimation_MachineCost.py @@ -82,6 +82,32 @@ OPERATOR_ALLOWANCE_NOTICE = ( ) +#: ⚠ **원천이 뭉개 놓은 줄을 원문으로 되살린다** (2026-09-09). +#: +#: `mach_base_2026.json` 의 대형 브레이커 여섯 줄은 **이름 칸에 표 전체가 뭉쳐** 들어가 +#: 규격이 비고 손료계수가 없다. 그래서 「대형브레이커」로 찾아지지도, 시간당 사용료가 +#: 서지도 않았다 — 구조물터파기(암절취)가 그 때문에 통째로 막혀 있었다. +#: +#: 값은 **건설공사 표준품셈 제8장 (0230) 대형 브레이커** 표에서 읽었고, **두 번 검증**했다. +#: ① 계수 합이 맞는다 — 상각 3,000 + 정비 2,833 + 관리 768 = **6,601** (표의 「계」와 같다) +#: ② 같은 방식으로 읽은 굴착기(0201) 표의 「계 2,085」가 카탈로그의 손료계수 +#: **0.0002085 와 정확히 일치**한다 — 열 배치를 잘못 읽지 않았다는 증거다. +#: +#: ⚠ **원천이 이 줄을 제대로 싣게 되면 이 표는 지운다.** 두 곳에 같은 값을 두면 +#: 나중에 한쪽만 고쳐진다. 취득가는 원천 값을 그대로 쓴다 — 여기서는 **이름·규격·손료계수**만 채운다. +MASHED_MACHINE_FIXES: dict[str, tuple[str, str]] = { + "0230-0002": ("대형 브레이커", "0.2"), + "0230-0004": ("대형 브레이커", "0.4"), + "0230-0006": ("대형 브레이커", "0.6"), + "0230-0007": ("대형 브레이커", "0.7"), + "0230-0008": ("대형 브레이커", "0.8"), + "0230-0010": ("대형 브레이커", "1.0"), +} + +#: (0230) 표의 「시간당 계」 — 규격이 달라도 같은 값이다(원문 여섯 줄 모두 6,601). +MASHED_LOSS_COEFFICIENT = Decimal("0.0006601") + + class MachineCostError(LookupError): """기계경비를 세울 수 없는 경우. 0 으로 때우지 않고 멈춘다.""" @@ -149,6 +175,11 @@ def load_machine_catalog(file_name: str = "mach_base_2026.json") -> MachineCatal for row in variables.get("mach_price", {}).get("records", []): code = row["machine_code"] coefficient = coefficients.get(code, {}) + fixed = MASHED_MACHINE_FIXES.get(code) + if fixed: + # 뭉개진 줄 — 원문으로 이름·규격을 되살리고 손료계수를 채운다. + row = {**row, "machine_name": fixed[0], "specification": fixed[1]} + coefficient = {**coefficient, "loss_coefficient_per_hour": MASHED_LOSS_COEFFICIENT} catalog.machines[code] = MachineSpec( machine_code=code, name=row["machine_name"], diff --git a/B09_Estimation/B09_Estimation_MachineProductivity_Reference.py b/B09_Estimation/B09_Estimation_MachineProductivity_Reference.py index 95250c35..6bfce469 100644 --- a/B09_Estimation/B09_Estimation_MachineProductivity_Reference.py +++ b/B09_Estimation/B09_Estimation_MachineProductivity_Reference.py @@ -193,3 +193,110 @@ def reference_factor_values( raw_texts[code] = own_ref[1] return values, provenance, failures, raw_texts + + +# --------------------------------------------------------------------------- +# 작업량을 **직접 준** 기계 줄 (2026-09-09) +# --------------------------------------------------------------------------- +# +# 품셈은 기계 몫을 늘 공식으로만 주지 않는다. **시간당 작업량을 바로 적는** 줄이 있다. +# +# ['장비 (90%)', '깨기', '대형브레이커(㎥/hr)', '3.5', 'Q=(3.2+3.8)/2 (연암평균치 적용)'] +# +# 이 줄을 못 읽으면 암·발파암 갈래의 **깨기 몫이 통째로 빠진다** — 들어내기(백호우)만 +# 붙어 「일부만 선 단가」로 남는다. +# +# ⚠ **단위가 붙어 있을 때만 읽는다.** 「(㎥/hr)」·「(m/hr)」처럼 시간당 작업량임을 +# 표가 스스로 밝힌 줄만 본다. 숫자만 있는 칸을 작업량으로 넘겨짚지 않는다. + +_CAPACITY_UNIT = re.compile(r"[((]\s*(㎥|m3|㎡|m2|m|ton|t)\s*/\s*(?:hr|시간)\s*[))]") + + +def _paired_machine_spec(node: dict[str, Any]) -> str: + """그 표에 함께 나오는 기종의 규격(「유압식백호우 (무한궤도,0.7㎥)」 → 0.7).""" + from B09_Estimation.B09_Estimation_MachineProductivity import resolve_machine + + from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog + + catalog = load_machine_catalog() + for table in node.get("tables", []): + for row in table.get("raw_row") or []: + for cell in row: + found = resolve_machine(_clean(cell)) + if found is not None: + machine = catalog.machines.get(found[0]) + if machine is not None and machine.specification: + return str(machine.specification) + return "" + + +def _machine_by_name(text: str, preferred_spec: str) -> tuple[str, str] | None: + """이름만으로 기종을 고른다 — 규격이 여럿이면 **짝의 규격**을 따른다. + + ⚠ 「대형브레이커(㎥/hr)」는 괄호가 **규격이 아니라 단위**라 보통 길로는 안 풀린다. + """ + from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog + + wanted = re.sub(r"\s", "", text) + if not wanted: + return None + catalog = load_machine_catalog() + hits = [ + (code, machine) + for code, machine in catalog.machines.items() + if wanted and wanted in re.sub(r"\s", "", machine.name) + ] + if not hits: + return None + if preferred_spec: + narrowed = [item for item in hits if str(item[1].specification) == str(preferred_spec)] + if len(narrowed) == 1: + return narrowed[0][0], narrowed[0][1].name + return (hits[0][0], hits[0][1].name) if len(hits) == 1 else None + + +def direct_capacity_rows(node: dict[str, Any]) -> list[dict[str, Any]]: + """그 공종에서 **시간당 작업량을 직접 준 기계 줄**들. + + 돌려주는 것 — 기계 이름 칸 · 기종 코드/이름 · 시간당 작업량 · 묶음 배분율(%) · 원문 문구. + """ + from B09_Estimation.B09_Estimation_MachineProductivity import parse_measure + + found: list[dict[str, Any]] = [] + ratio: Decimal | None = None + # 같은 표에 짝이 되는 기종이 있으면 **그 규격**을 따른다 — 「대형브레이커」는 규격을 + # 안 적고, 실무도 「대형브레이커 + B/H 0.7」처럼 붙는 굴착기 규격으로 잡는다. + paired_spec = _paired_machine_spec(node) + for table in node.get("tables", []): + for row in table.get("raw_row") or []: + cells = [_clean(cell) for cell in row] + if not cells: + continue + seen_ratio = re.search(r"[((]\s*(\d+(?:\.\d+)?)\s*%\s*[))]", cells[0]) + if seen_ratio: + ratio = Decimal(seen_ratio.group(1)) + for index, cell in enumerate(cells): + if not _CAPACITY_UNIT.search(cell): + continue + machine = _machine_by_name(_CAPACITY_UNIT.sub("", cell).strip(), paired_spec) + if machine is None: + continue + capacity = next( + (parse_measure(token) for token in cells[index + 1 :] if parse_measure(token)), + None, + ) + if capacity is None or capacity <= 0: + continue + found.append( + { + "cell": cell, + "machine_code": machine[0], + "machine_name": machine[1], + "capacity_per_hour": capacity, + "ratio_pct": ratio, + "table_id": str(table.get("pum_table_id", "")), + # 그 줄의 칸들 — 「못 붙은 줄」 목록에서 이 줄을 걷어내는 데 쓴다. + "row_cells": [c for c in cells if c], + } + ) + return found diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index f8c64e04..35b78bd5 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -132,6 +132,11 @@ def _add_labor_titles(book: PriceBook, wages: dict[str, Decimal]) -> None: ) +#: 부착용 장비 — 제 엔진이 없어 **손료만** 붙는다(품셈 제8장 [주]⑤). +#: ⚠ 이 목록을 넓히지 말 것 — 넓히면 연료가 빠진 기계가 조용히 싸게 선다. +_ATTACHMENT_WORDS = ("브레이커", "리퍼", "부착용집게", "집게") + + def _add_machine_layers(book: PriceBook, machine_codes: set[str]) -> list[str]: """`S`(취득가) · `L`(운전사) · `M`(연료) 을 세우고 그 위에 `X` 를 올린다. @@ -159,7 +164,21 @@ def _add_machine_layers(book: PriceBook, machine_codes: set[str]) -> list[str]: for code in sorted(machine_codes): machine = catalog.machines.get(code) record = operating.get(code) - if machine is None or machine.loss_coefficient_per_hour is None or record is None: + # ⚠ **부착용 장비는 운전경비표에 줄이 없다** — 제 엔진이 없어 연료·조종원이 + # 본체(굴착기·불도저)에 든다. 품셈 제8장 [주]⑤ 가 그 자리를 밝힌다: + # 「불도저 및 굴착기에 **리퍼, 브레이커, 부착용집게를 조합하여 사용**할 때는 + # …잡재료비율을 16%로 계상하고, **리퍼, 브레이커, 부착용 집게의 손료 및 + # 치즐 소모율을 추가**하는 것이다.」 + # ⇒ 그 셋은 **손료만으로** 층을 세운다. 운전경비 줄이 없다고 통째로 버리면 + # 깨기 몫이 영영 안 붙는다(구조물터파기 암 갈래가 그 자리였다). + # ⚠ **다른 기계에는 이 길을 열지 않는다** — 연료가 빠진 채 조용히 싼 값이 선다. + attachment = machine is not None and any( + word in re.sub(r"\s", "", machine.name) for word in _ATTACHMENT_WORDS + ) + if machine is None or machine.loss_coefficient_per_hour is None: + incomplete.append(code) + continue + if record is None and not attachment: incomplete.append(code) continue @@ -192,6 +211,21 @@ def _add_machine_layers(book: PriceBook, machine_codes: set[str]) -> list[str]: ) book.add_detail(PriceDetail(hourly_code, base_code, Decimal(1), note="시간당 손료")) + if record is None: + # 부착용 장비 — 여기서 끝난다. 연료·조종원은 본체 줄에 이미 들어 있다. + book.add_detail( + PriceDetail( + hourly_code, + base_code, + Decimal(0), + note=( + "부착용 장비 — 연료·조종원은 본체(굴착기·불도저)에 듭니다" + " (품셈 제8장 [주]⑤)." + ), + ) + ) + continue + liters = record.fuel_liters_per_hour if liters is not None: if record.misc_material_percent is not None: @@ -353,6 +387,7 @@ def build_unit_prices( ) from B09_Estimation.B09_Estimation_MachineProductivity_Reference import ( + direct_capacity_rows, reference_factor_values, ) @@ -374,9 +409,22 @@ def build_unit_prices( for failed_code, why in borrow_fail.items(): build.factor_sources.setdefault(failed_code, f"⚠ {why}") build.component_gaps = dict(axis.partial_items) + # 「작업량을 직접 준」 기계(깨기 대형브레이커)도 사용료 층을 세운다 — 안 세우면 + # 그 줄이 붙을 데가 없어 암·발파암 갈래의 깨기 몫이 통째로 빠진다. + capacity_rows = { + str(node.get("work_item_code", "")): direct_capacity_rows(node) + for node in master.get("work_items", []) + } + capacity_rows = {code: rows for code, rows in capacity_rows.items() if rows} + # ⚠ **참조로 이미 푼 줄은 「못 붙은 줄」이 아니다.** 안 걷어 내면 다 풀린 공종이 # 계속 반쪽으로 보이고, 그 표시를 믿고 막아 둔 금액이 영영 안 선다. resolved_rows = {code: text for code, text in borrow_text.items() if code in borrow_note} + # 작업량을 직접 준 줄도 이제 붙었다 — 그 줄의 칸들은 「못 붙은 줄」이 아니다. + capacity_cells: dict[str, set[str]] = {} + for capacity_code, capacity_list in capacity_rows.items(): + for entry in capacity_list: + capacity_cells.setdefault(capacity_code, set()).update(entry.get("row_cells") or []) for unmatched_row in axis.unmatched: # ⚠ 지역 이름을 조심할 것 — 바로 위 `names` 는 **공종 이름표**다. 같은 이름을 쓰면 # 그 표가 리스트로 덮여 조립이 통째로 터진다(2026-09-09 실측). @@ -385,16 +433,35 @@ def build_unit_prices( reference_text = resolved_rows.get(unmatched_row.work_item_code) if reference_text and reference_text in label: continue # 그 줄은 참조를 따라가 값을 얻었다 + if label in capacity_cells.get(unmatched_row.work_item_code, set()): + continue # 그 줄은 표가 작업량을 직접 줘 붙었다 if label and label not in labels: labels.append(label) # ⚠ **거두는 자리는 「못 붙은 줄」을 다 모은 뒤다.** 앞에서 거두면 목록이 비어 있어 # **전부 거둬지고**, 깨기(대형브레이커)가 빠진 암 계열까지 「다 찼다」로 선다 # (2026-09-09 실측). 남은 줄이 하나도 없을 때만 거둔다. + def _names_a_machine(label: str) -> bool: + """그 줄이 **기계를 가리키나** — 기계가 빠지면 막고, 자재가 빠지면 드러내기만 한다. + + ⚠ 두 가지는 무게가 다르다. 기계 몫은 단가의 대부분이라 빠지면 금액이 통째로 + 틀리고, 자재 소모품(치즐 0.006본/hr)은 카탈로그가 서면 채워지는 알려진 미결이다 + (자원 축이 이미 그 규칙으로 가른다). + """ + from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog + + flat = re.sub(r"\s", "", label) + return any( + re.sub(r"\s", "", machine.name) in flat + for machine in load_machine_catalog().machines.values() + if len(re.sub(r"\s", "", machine.name)) >= 3 + ) + solved_codes = { code for code in borrow_note - if code in axis.partial_items and not build.unattached.get(code) + if code in axis.partial_items + and not any(_names_a_machine(label) for label in build.unattached.get(code, [])) } for code in solved_codes: build.component_gaps.pop(code, None) @@ -414,6 +481,7 @@ def build_unit_prices( machine_codes = {r.resource_code for r in axis.rows if r.resource_kind == "machine"} # 공식표에만 나오는 기종도 사용료 층을 세운다 — 안 세우면 공식이 붙을 데가 없다. machine_codes |= formula_machine_codes(master) + machine_codes |= {row["machine_code"] for rows in capacity_rows.values() for row in rows} build.incomplete_machines = _add_machine_layers(build.book, machine_codes) # 규격 갈래(`variant`)가 있으면 **갈래마다 따로 세운다** — 무근·철근·소형구조물은 @@ -533,6 +601,31 @@ def build_unit_prices( build.factor_sources, ) + # 작업량을 직접 준 기계 줄(깨기) — 공식 몫과 **자리를 나눠 쓴다**. 같은 묶음(장비 + # 90%) 안에서 깨기와 들어내기가 차례로 붙는다. + attached_capacity = False + for capacity in capacity_rows.get(work_item_code, []): + hourly_code = f"X-{capacity['machine_code']}" + if hourly_code not in build.book.titles: + continue + group_share = ( + Decimal(1) + if capacity["ratio_pct"] is None + else Decimal(str(capacity["ratio_pct"])) / Decimal(100) + ) + build.book.add_detail( + PriceDetail( + title_code, + hourly_code, + (Decimal(1) / capacity["capacity_per_hour"]) * group_share, + note=( + f"작업량을 표가 직접 줌 — {capacity['cell']} {capacity['capacity_per_hour']}" + f" (품셈 원문 표기 그대로)" + ), + ) + ) + attached_capacity = True + # ⚠ **배분율이 있는 표는 「몇 %가 실제로 붙었나」를 세어 둔다.** # 「인력(10%)·장비(90%)」 표에서 인력만 붙으면 단가가 10 % 몫만인데, 그 값이 # 조용히 서면 내역서가 틀린 줄 모른다(2026-09-08 실측: 측구터파기 39,575.6원/㎥ @@ -543,7 +636,7 @@ def build_unit_prices( # ⚠ 공식은 있는데 **아무것도 안 붙은** 제목은 남기지 않는다 — 「상세 줄이 없어 # 조립 불가」로 화면에서 터진다. 기계 층이 못 선 경우가 그 자리다. - if not attachable and not build.book.details.get(title_code): + if not attachable and not attached_capacity and not build.book.details.get(title_code): build.book.titles.pop(title_code, None) if variant in build.variants.get(work_item_code, []): build.variants[work_item_code].remove(variant) From 083766fd1902be983a2dc2f8ca300a3a5b46b616 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 08:07:08 +0900 Subject: [PATCH 08/36] =?UTF-8?q?docs(B08):=20=EC=82=AC=EB=A9=B4=EC=A0=81?= =?UTF-8?q?=EC=97=90=20=EC=86=8C=EB=8B=A8=EB=A9=B4=EC=9D=B4=20=EB=93=A0?= =?UTF-8?q?=EB=8B=A4=EB=8A=94=20=EA=B2=83=EC=9D=84=20=EC=82=AC=EC=9C=A0?= =?UTF-8?q?=EC=97=90=20=EC=A0=81=EC=9D=8C=20=E2=80=94=20=EA=B0=80=EB=A5=B4?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EA=B3=A0=20=EB=B3=B4=EC=9D=B4=EA=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 갈라 내라는 원문이 없고 실무도 한 덩이로 셈. 별표2 타.(1) 이 「노출되는 면은 전체면적 녹화」라 소단도 녹화 대상이므로 섞여 있는 것이 틀린 것이 아님 - 가르면 「그 몫으로 무엇을 세나」를 우리가 정하게 되어 근거 없는 줄이 섬 — 대신 근거 문구에 「소단면 포함」을 적어 가르지 않고 보이게 함 Co-Authored-By: Claude Opus 5 (1M context) --- B08_Quantity/B08_Quantity_Engine_Preparation.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/B08_Quantity/B08_Quantity_Engine_Preparation.py b/B08_Quantity/B08_Quantity_Engine_Preparation.py index 91ca4e08..cfe56495 100644 --- a/B08_Quantity/B08_Quantity_Engine_Preparation.py +++ b/B08_Quantity/B08_Quantity_Engine_Preparation.py @@ -117,6 +117,12 @@ def preparation_rows( # ⚠ 값을 여기서 또 내면 같은 나무를 두 번 벤다. 참고로 면적만 보인다. "reference_amount": tree_area, "reason": ( + # ⚠ **소단면이 이 면적에 들어 있다** — 갈라 내지 않는다(2026-09-09 세 창 확인). + # 별표2 타.(1) 이 「노출되는 절·성토면은 **전체면적**을 녹화」라 소단도 녹화 + # 대상이고, 갈라 내라는 원문이 없으며 실무도 한 덩이로 센다. 가르면 「그 몫으로 + # 무엇을 세나」를 우리가 정하게 되어 **근거 없는 줄**이 선다. 대신 여기 적어 둔다. + "대상 면적에 **소단면이 포함**됨(별표2 타.(1) 「노출되는 면은 전체면적 녹화」 — " + "원문이 가르지 않고 실무도 한 덩이로 셈). " "토공집계의 「지장목제거」로 이미 섬 — 여기서 또 세우면 이중계상. " "⚠ 다만 **공종 미확정** — 품셈 4장이 벌목을 목적별로 갈라(수확베기·단목베기·" "위험목 베기) 임도 지장목이 어디에 붙는지 원본이 말하지 않음. 지금은 공종코드 없이 감." From 1d5145eaa515fda94949c0745abeb8e97f2dbe82 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 08:11:49 +0900 Subject: [PATCH 09/36] =?UTF-8?q?feat(B09):=20=EB=AF=B8=ED=99=95=EC=A0=95?= =?UTF-8?q?=20=EC=82=B0=EC=8B=9D=20=EC=A4=84=EC=97=90=20=EC=82=AC=EC=9C=A0?= =?UTF-8?q?=EB=A5=BC=20=EC=8B=A4=EC=9D=8C=20+=20=EA=B0=88=EB=9E=98=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EB=8B=A4=EB=A6=AC(=EB=A6=AC=ED=95=91?= =?UTF-8?q?=EC=95=94=E2=86=94=ED=8C=8C=EC=87=84=EC=95=94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ① 수량 산식이 확정 대기인 줄에 그 사실을 함께 실음 (계획서 4-12 3단계) 구조물터파기가 내역서에서 가장 큰 줄(11,263,899원)이 됐는데 그 밑수가 사용자 확정을 기다리는 식 위에 서 있음. 값은 그대로 두고 **드러내기만** 함. 「⚠ 터파기 수량 산식이 확정 대기입니다 — 실무 시트는 기초만(기초폭 × 깊이 0.3) 세고 우리는 벽 전체를 셉니다(m당 실무 0.35 vs 우리 2.30, 약 6.6배). 실무 식으로 확정되면 이 줄 금액이 약 1/6.6 로 줄어듭니다.」 되메우기·잔토처리도 같은 뿌리라 함께 표시. ⚠ 측구터파기는 다른 뿌리라 대상 아님 ② `VARIANT_ALIASES` — 축이 달라 다르게 불리는 갈래를 **이름을 갈지 않고** 이음 우리 「리핑암」은 B05·B06 지반유형(캘 수 있는가), 품셈 「파쇄암」은 운반 상태 (부서졌는가)라 일반적으로 같은 말이 아님 — 발파암도 캐고 나면 파쇄암 상태임. ⭐ 다만 산림품셈 10-11 f 표가 토사·파쇄암·발파암을 따로 두어 그 표의 파쇄암은 발파를 뺀 나머지이고, 10-12 [주]③ 「토사 1.3, **암절취 1.35**, 발파암 1.625」가 10-11 의 파쇄암 1/1.35 와 **같은 값**이라 그 표 안에서는 암절취(리핑) = 파쇄암임. 그 문장을 주석에 그대로 남김 검증: pytest 290 통과 · `find_variant_code("FP-10-11","리핑암")` → `B-FP-10-11#파쇄암` Co-Authored-By: Claude Opus 5 (1M context) --- .../B09_Estimation_BillOfQuantities_Rows.py | 42 +++++++++++++++++++ B09_Estimation/B09_Estimation_UnitPrice.py | 20 +++++++++ 2 files changed, 62 insertions(+) diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py index 19ec51ac..bcf9bb38 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py @@ -307,6 +307,13 @@ def _leaf_row( ) return row + # ⚠ **수량이 미확정 산식 위에 서 있는 줄**은 금액과 함께 그 사실을 싣는다. + # 금액이 커질수록 더 그렇다 — 지금 구조물터파기가 내역서에서 가장 큰 줄인데 + # 그 밑수가 사용자 확정을 기다리고 있다(계획서 4-12 3단계). + pending = pending_formula_note(node.code) + if pending: + row.note = " / ".join(part for part in (row.note, pending) if part) + # 쓰인 차례를 기억한다 — 실무 참조번호(「단산 46」)가 그 차례다. if price_code not in result.used_unit_prices: result.used_unit_prices.append(price_code) @@ -323,6 +330,41 @@ def _leaf_row( return row +#: 수량 산식이 **사용자 확정을 기다리는** 공종 — 금액은 세우되 그 사실을 함께 싣는다. +#: +#: ⚠ **값을 우리가 바꾸지 않는다.** 실무 시트와 어긋나는 것이 확인됐을 뿐이고, 어느 쪽으로 +#: 갈지는 설계 판단이다(계획서 4-12 3단계, 확정 대기). +#: 돌쌓기(찰) H=2.0·ℓ3=45 · m당 기준으로 실무 대비 +#: 터파기 실무 0.35 우리 2.30 (약 6.6배) — 실무는 기초만(기초폭 × 깊이 0.3), +#: 우리는 벽 전체를 센다 +#: 되메우기 실무 0.185 우리 0.40 · 잔토정리 실무 0.165 우리 1.90 (위 둘의 차) +_PENDING_FORMULA: dict[str, str] = { + "FP-09-13": ( + "⚠ 터파기 수량 산식이 확정 대기입니다 — 실무 시트는 기초만(기초폭 × 깊이 0.3) 세고 " + "우리는 벽 전체를 셉니다(m당 실무 0.35 vs 우리 2.30, 약 6.6배). 실무 식으로 확정되면 " + "이 줄 금액이 약 1/6.6 로 줄어듭니다." + ), + "FP-09-14": ( + "⚠ 되메우기 수량 산식이 확정 대기입니다 — 터파기와 같은 뿌리입니다" + "(m당 실무 0.185 vs 우리 0.40)." + ), + "FP-09-15": ( + "⚠ 잔토처리 수량 산식이 확정 대기입니다 — 터파기·되메우기의 차라 같은 뿌리입니다" + "(m당 실무 0.165 vs 우리 1.90)." + ), +} + + +def pending_formula_note(code: str | None) -> str: + """그 공종의 수량 산식이 확정 대기인가 — 맞으면 실을 문구.""" + if not code: + return "" + for prefix, note in _PENDING_FORMULA.items(): + if str(code).startswith(prefix): + return note + return "" + + def _material_row(material: HandoffMaterial, result: BillResult) -> BillRow: """자재 한 줄. 공급 구분이 안 갈렸으면 **어느 쪽에도 안 넣는다**.""" row = BillRow( diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index 35b78bd5..79921d94 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -306,6 +306,25 @@ def normalize_variant_key(text: str) -> str: return "".join("~" if ch in _TILDE_CHARS else ch for ch in tight) +#: 갈래 이름이 **축이 달라 다르게 불리는** 자리 — 이름을 갈지 않고 여기서 잇는다. +#: +#: ⚠ **우리 축과 품셈 축이 다르다.** 우리 「리핑암」은 B05·B06 의 **지반유형**(캘 수 있는가)이 +#: 낳은 이름이고, 품셈 운반표의 「파쇄암」은 **운반할 때의 상태**(부서졌는가)를 가리킨다. +#: 그래서 일반적으로는 「리핑암 = 파쇄암」이 아니다 — **발파암도 캐고 나면 파쇄암 상태**다 +#: (건설품셈 8장 「발파 또는 리퍼작업 등에 의하여 얻어진 암과 파쇄암…」). +#: +#: ⭐ **다만 이 표 안에서는 성립한다.** 산림품셈 10-11 f 표가 **토사·파쇄암·발파암을 따로** +#: 두었으므로 그 표의 「파쇄암」은 **발파를 뺀 나머지 = 리퍼로 얻은 것**이다. 결정적 증거는 +#: 10-12 [주]③ 이다 — 「적재 재료의 토량환산계수(L)는 토사 1.3, **암절취 1.35**, 발파암 +#: 1.625 적용한다」. 10-11 f 표의 **파쇄암이 1/1.35** 라 **암절취(리핑)과 같은 값**이다. +#: +#: ⚠ **어느 쪽 이름도 갈지 않는다** — 우리 이름을 갈면 B05·B06 이 깨지고, 품셈 이름을 갈면 +#: 원문과 어긋난다. 잇는 자리는 여기 한 곳뿐이다. +VARIANT_ALIASES: dict[str, str] = { + "리핑암": "파쇄암", +} + + def find_variant_code( work_item_code: str, variant_value: str, @@ -320,6 +339,7 @@ def find_variant_code( wanted = normalize_variant_key(variant_value) if not wanted: return None + wanted = VARIANT_ALIASES.get(wanted, wanted) prefix = f"B-{work_item_code}#" candidates = [code for code in prices.book.titles if code.startswith(prefix)] From b3d1fa4a9b771fb8a95fe8b6d72f32eab6df095b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 08:14:23 +0900 Subject: [PATCH 10/36] =?UTF-8?q?fix(B08):=20=EC=9A=B4=EB=B0=98=20?= =?UTF-8?q?=EC=A4=84=EC=97=90=20=EA=B0=88=EB=9E=98=EB=A5=BC=20=ED=86=B5?= =?UTF-8?q?=EB=A1=9C=EB=A1=9C=20=EC=8B=A4=EC=9D=8C=20=E2=80=94=20spec=20?= =?UTF-8?q?=EB=AC=B8=EC=9E=90=EC=97=B4=EB=A7=8C=20=EA=B0=80=EA=B3=A0=20?= =?UTF-8?q?=EC=9E=88=EC=97=88=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 운반 단가가 지반 갈래로 갈리는데 `variant_value` 가 비어 있어 받는 쪽이 갈래를 못 골랐음 (「버림」에서 275,584원이 사라졌던 자리와 같은 병) - `variant_axis: "ground_class"` · `variant_value: 리핑암` 으로 실음. 갈래가 없는 줄은 None - ⚠ 이름은 우리 갈래 그대로 — 일위대가의 「파쇄암」과는 인계본 `ground_class_aliases` 가 이음 - tmp/tests 2건 추가 Co-Authored-By: Claude Opus 5 (1M context) --- B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py index ea2b12ef..d650a009 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py @@ -232,9 +232,13 @@ def _haul_rows( "spec_detail": state_note, "composite_parts": None, "structure_kind": None, - # 토공·운반 줄에는 갈래 축이 없다 — 그래도 **칸은 둔다**(계약이 한 모양). - "variant_axis": None, - "variant_value": None, + # ⚠⚠ **갈래를 통로로 보낸다**(2026-09-09). 운반 단가가 지반 갈래로 갈리는데 + # `spec` 문자열만 보내고 있어 받는 쪽이 갈래를 못 골랐다 — 「버림」에서 + # 275,584원이 사라졌던 그 자리와 같다. 갈래는 `variant_value` 한 통로로만. + # ⚠ 이름은 **우리 갈래 그대로**(리핑암) 보낸다 — 일위대가의 「파쇄암」과는 + # 인계본의 `ground_class_aliases` 가 이어 준다(이름을 갈면 흙깎기가 어긋난다). + "variant_axis": "ground_class" if row.get("ground") else None, + "variant_value": str(row.get("ground")) if row.get("ground") else None, "secondary_axes": None, "spec_class": None, "spec_class_basis": "", From d6a64700fc32b22ddcd85f757d0d4634d5a61d73 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 08:25:58 +0900 Subject: [PATCH 11/36] =?UTF-8?q?feat(B08):=20=EA=B3=A8=EB=A7=89=EC=9D=B4?= =?UTF-8?q?=C2=B7=EB=B0=94=EB=8B=A5=EB=A7=89=EC=9D=B4=20=E2=80=94=20?= =?UTF-8?q?=EC=8B=9D=EC=9D=84=20=EC=95=88=20=EB=91=90=EA=B3=A0=20**?= =?UTF-8?q?=EC=99=9C=20=EC=97=86=EB=8A=94=EC=A7=80**=EB=A5=BC=20=EB=83=84?= =?UTF-8?q?=20(3=EB=8B=A8=EA=B3=84=20=EB=91=98=EC=A7=B8=C2=B7=EC=85=8B?= =?UTF-8?q?=EC=A7=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚠⚠ 조사 중 큰 것이 나옴 — **우리가 「틀렸다」고 버린 옛 두께식이 실은 「골막이 식」이었음.** 소광리 숨김탭 「골막이(찰)(치수조서연결)」 원문: ``` C26 평 균 두 께 = {( 0.45 + 0.1 × H ) + ( 0.45 + 0.4 × H )} ÷ 2 ``` 우리 옛 식과 글자까지 같음. ⇒ **실무는 구조물 종류마다 두께식이 다름.** 확정 ②(뒷길이 기반)는 **기슭막이에 맞고**, 골막이에는 이 식이 맞음. ⇒ 골막이에 `stone_masonry` 를 그대로 부르면 **틀어짐.** 그래서 안 부름. 골막이가 돌쌓기와 다른 자리 셋 (정본 실측) · 두께식 위와 같음 · 밑수가 **「돌쌓기 + 돌붙임」** (19.62 + 2.625 = 22.245) — 기슭막이는 돌쌓기만 · 단위가 **개소당** — 기슭막이는 m당 · 정면적이 사다리꼴 ((상장+하장)÷2×높이) − 파형강관(0.4×0.4×3.14) ⚠ **상장·하장 칸이 등록부에 없어** 정면적을 못 냄 — 칸 신설이 선행 바닥막이는 형태가 **돌붙임**(품셈 13-5)이라 돌쌓기(13-4) 표를 못 씀. 밑수도 면적(㎡)이라 축이 다름 — 돌붙임 원단위 미확보. ⚠ **일부러 안 부름** — 돌 계열이라 `stone_masonry` 를 부르면 **값이 나오므로 아무 시험도 안 잡음.** 오늘 큰돌쌓기에서 같은 일이 있었음(직경 60~80㎝ 가 뒷길이 45㎝ 계수로 돌던 자리). 자체검증 — 골막이 0줄 + 사유 · 바닥막이 0줄 + 사유 · 기슭막이 13줄(그대로). 회귀 586 통과 · 0 실패. Co-Authored-By: Claude Opus 5 (1M context) --- .../B08_Quantity_Engine_UnitQuantity.py | 10 ++++++++ ..._Quantity_Engine_UnitQuantity_Revetment.py | 25 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py index 90212d20..f0c9b6ab 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -936,6 +936,9 @@ EXPANDERS = { # 이고 그 안의 계산이 같다. 갈래·사유는 `_UnitQuantity_Revetment` 가 든다(늦게 부른다 — # 그쪽이 이 모듈의 `stone_masonry` 를 쓰므로 위에서 부르면 맞물린다). "revetment": lambda h, l, o, f=None, r="": _revetment()(h, l, o, f, r), + # 골막이·바닥막이는 돌 계열이나 **돌쌓기 식을 그대로 못 쓴다** — 까닭은 그 모듈에 적었다. + "erosion_check": lambda h, l, o, f=None, r="": _stone_kin()("erosion_check"), + "bed_sill": lambda h, l, o, f=None, r="": _stone_kin()("bed_sill"), # ⚠⚠ **큰돌쌓기(`boulder_masonry`)를 여기에 두지 않는다** (2026-09-07 발견). # 큰돌쌓기는 품셈 **13-6** 이고 돌쌓기는 **13-4** 다 — **규격 축이 다르다.** # 돌쌓기는 **뒷길이**(35·45·55·60㎝), 큰돌쌓기는 **직경**(40~60·60~80·80~100㎝). @@ -946,6 +949,13 @@ EXPANDERS = { } +def _stone_kin(): + """골막이·바닥막이 사유 함수 — 늦게 가져온다(서로 부르는 것을 푸는 자리).""" + from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import stone_kin_withheld + + return stone_kin_withheld + + def _revetment(): """기슭막이 전개 함수를 늦게 가져온다 — 서로 부르는 것을 풀기 위한 자리.""" from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import revetment diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py index 3c7bf94a..be151028 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py @@ -43,6 +43,20 @@ WITHHELD_FORMS: dict[str, str] = { "바자": "바자얽기(품셈 5-15)는 별도 일위대가를 만들어 잇기로 확정(2026-09-09 ⑧-4)", } +#: 골막이·바닥막이 — **왜 아직 안 서는지**. 둘 다 돌 계열이나 돌쌓기 식을 그대로 못 쓴다. +#: ⚠ 근거 없는 식을 만들지 않는다(2026-09-09 확인). 칸·표가 오면 그때 붙인다. +STONE_KIN_WITHHELD: dict[str, str] = { + "erosion_check": ( + "골막이는 돌쌓기와 **두께식·밑수·단위가 다름**(소광리 정본 「골막이(찰)(치수조서연결)」 " + "실측) — 평균두께 {(0.45+0.1H)+(0.45+0.4H)}÷2 · 밑수 「돌쌓기+돌붙임」 · 개소당. " + "게다가 정면적이 사다리꼴이라 **상장·하장 칸이 있어야** 셈이 서는데 등록부에 없음" + ), + "bed_sill": ( + "바닥막이는 형태가 **돌붙임**(품셈 13-5)이라 돌쌓기(13-4) 표를 못 씀 — " + "밑수도 면적(㎡)이라 축이 다름. 돌붙임 원단위 미확보" + ), +} + #: 돌쌓기 식이 계수를 가르는 데 쓰는 칸 — 기슭막이 등록부에 아직 없는 것을 알린다. STONE_SPEC_KEYS: tuple[tuple[str, str], ...] = ( ("back_len_cm", "뒷길이"), @@ -79,3 +93,14 @@ def revetment( "— 그 값이 고임돌·야면석·채움콘크리트 계수를 가름" ) return components, notes + + +def stone_kin_withheld(type_id: str) -> tuple[list[Component], list[str]]: + """골막이·바닥막이 — **식을 안 두는 까닭**만 낸다. + + ⚠ 돌 계열이라 `stone_masonry` 를 부르고 싶어지는 자리다. 실제로 부르면 **값이 나오므로** + 아무 시험도 안 잡는다 — 오늘 큰돌쌓기에서 같은 일이 있었다(직경 60~80㎝ 가 「뒷길이 + 45㎝」 계수로 돌던 자리). 그래서 **일부러 안 부른다.** + """ + why = STONE_KIN_WITHHELD.get(type_id) + return [], [why] if why else [] From 60b7196754b4b8cb5cbc2034821b2841cb034a7d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 08:27:37 +0900 Subject: [PATCH 12/36] =?UTF-8?q?docs(B08):=20=EA=B3=A8=EB=A7=89=EC=9D=B4?= =?UTF-8?q?=EB=A5=BC=20=EB=8F=8C=EC=8C=93=EA=B8=B0=EC=99=80=20=ED=95=A9?= =?UTF-8?q?=EC=B9=98=EC=A7=80=20=EB=AA=BB=ED=95=98=EA=B2=8C=20=EB=AA=BB=20?= =?UTF-8?q?=EB=B0=95=EC=9D=8C=20=E2=80=94=20=EB=8B=A4=EB=A5=B8=20=EB=84=B7?= =?UTF-8?q?=C2=B7=EA=B0=99=EC=9D=80=20=EC=97=AC=EC=84=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「같은 돌쌓기니 한 함수로」가 되기 쉬운 자리라, 두 정본을 나란히 잰 결과를 코드에 남김. 다른 것 넷 (소광리 「골막이(찰)(치수조서연결)」 ↔ 「돌기슭막이」) 두께식 골막이 (0.45+0.1H)/(0.45+0.4H) ↔ 기슭막이 뒷길이 기반(ℓ3+0.30 …) 밑수 골막이 **돌쌓기 + 돌붙임** ↔ 기슭막이 돌쌓기만 단위 골막이 **개소당** ↔ 기슭막이 m당 (확정 ⑦ 「통일 안 함」) 정면적 골막이 **사다리꼴**(상장·하장) ↔ 기슭막이 직사각(H × 1) 같은 것 여섯 — 막자갈 2/3 · 야면석 0.88 ton/㎡ · 물구멍 2㎡/개소 · 기울기 몫 1.04 · 고임돌 0.15 · 채움콘크리트 0.2. **우리 기본값이 어디서 왔는지의 답**이기도 함. `STONE_MASONRY` 상수 머리에도 한 줄 — 「**기슭막이 계열 전용**. 골막이는 두께식이 다름 (정본 C26)」. 상수만 보고 가져다 쓰면 **값이 나오므로 아무 시험도 안 잡는** 자리임. 자체검증 — 회귀 586 통과 · 0 실패. Co-Authored-By: Claude Opus 5 (1M context) --- B08_Quantity/B08_Quantity_Engine_UnitQuantity.py | 4 ++++ .../B08_Quantity_Engine_UnitQuantity_Revetment.py | 12 ++++++++++++ 2 files changed, 16 insertions(+) diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py index f0c9b6ab..03370aff 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -170,6 +170,10 @@ def stone_coefficients(options: dict[str, Any], back_cm: int) -> tuple[dict[str, # 돌쌓기 전개식의 상수 — 실무 수식에 박혀 있던 값을 뺀 것. +#: ⚠⚠ **이 상수는 기슭막이 계열(돌쌓기 찰·메) 전용이다.** +#: 골막이는 두께식이 다르다 — 소광리 정본 「골막이(찰)(치수조서연결)」 C26 이 +#: `{(0.45+0.1H) + (0.45+0.4H)} ÷ 2` 이고, 여기 두께식(뒷길이 기반)과 갈린다. +#: 상수만 보고 골막이에 가져다 쓰면 **값이 나오므로 아무 시험도 안 잡는다**(2026-09-09). STONE_MASONRY = { # ⚠ **곱하는 값이 아니라 검산 참고값이다** (2026-09-08 ㉘ 에서 고침). # 실무 시트의 「돌쌓기 = 정면적 × 1.04」에서 그 1.04 가 **곧 기울기 몫**이다 diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py index be151028..1ccae6f7 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py @@ -45,6 +45,18 @@ WITHHELD_FORMS: dict[str, str] = { #: 골막이·바닥막이 — **왜 아직 안 서는지**. 둘 다 돌 계열이나 돌쌓기 식을 그대로 못 쓴다. #: ⚠ 근거 없는 식을 만들지 않는다(2026-09-09 확인). 칸·표가 오면 그때 붙인다. +#: +#: ⚠⚠ **골막이를 돌쌓기와 합치지 말 것** — 「같은 돌쌓기니 한 함수로」가 되기 쉬운 자리다. +#: 소광리 정본 「골막이(찰)(치수조서연결)」과 「돌기슭막이」를 나란히 재면 **넷이 다르다**: +#: +#: 두께식 골막이 (0.45+0.1H)/(0.45+0.4H) ↔ 기슭막이 뒷길이 기반(ℓ3+0.30 …) +#: 밑수 골막이 **돌쌓기 + 돌붙임** ↔ 기슭막이 돌쌓기만 +#: 단위 골막이 **개소당** ↔ 기슭막이 m당 (확정 ⑦ 「통일 안 함」) +#: 정면적 골막이 **사다리꼴**(상장·하장) ↔ 기슭막이 직사각(H × 1) +#: +#: ⓘ 같은 것도 있다 — 막자갈 **2/3**·야면석 **0.88 ton/㎡**·물구멍 **2㎡/개소**· +#: 기울기 몫 **1.04**·고임돌 **0.15**·채움콘크리트 **0.2** 는 두 시트가 같은 값이다. +#: 우리 기본값이 어디서 왔는지의 답이기도 하다. STONE_KIN_WITHHELD: dict[str, str] = { "erosion_check": ( "골막이는 돌쌓기와 **두께식·밑수·단위가 다름**(소광리 정본 「골막이(찰)(치수조서연결)」 " From a22bf97964a5a5481f91c9d19e39b162c0b9cbcf Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 08:28:00 +0900 Subject: [PATCH 13/36] =?UTF-8?q?docs(B08):=20STONE=5FMASONRY=20=EB=8A=94?= =?UTF-8?q?=20=EA=B8=B0=EC=8A=AD=EB=A7=89=EC=9D=B4=20=EC=A0=84=EC=9A=A9?= =?UTF-8?q?=EC=9E=84=EC=9D=84=20=EB=A8=B8=EB=A6=AC=EC=97=90=20=EB=AA=BB=20?= =?UTF-8?q?=EB=B0=95=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 소광리 숨김탭 「골막이(찰)(치수조서연결)」 C26 이 우리가 확정 ② 로 버린 옛 식과 글자까지 같음 — 그 식은 틀린 식이 아니라 **골막이 식**이었음. 기슭막이에 쓰던 것이 어긋남의 정체였음 - 골막이는 두께식 말고도 셋이 더 다름(밑수 = 돌쌓기+돌붙임 · 단위 = 개소당 · 정면적 = 사다리꼴) — 상수만 보고 가져다 쓰지 못하게 적음 Co-Authored-By: Claude Opus 5 (1M context) --- B08_Quantity/B08_Quantity_Engine_UnitQuantity.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py index 1e98217e..173be3c9 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -170,6 +170,13 @@ def stone_coefficients(options: dict[str, Any], back_cm: int) -> tuple[dict[str, # 돌쌓기 전개식의 상수 — 실무 수식에 박혀 있던 값을 뺀 것. +# ⚠⚠ **기슭막이 계열 전용이다. 다른 구조물로 넓히지 말 것**(2026-09-09). +# 골막이는 **두께식부터 다르다** — 소광리 숨김탭 「골막이(찰)(치수조서연결)」 C26: +# 평균두께 = {(0.45 + 0.1×H) + (0.45 + 0.4×H)} ÷ 2 +# 우리가 확정 ② 로 버린 옛 식과 **글자까지 같다.** 그 식은 틀린 식이 아니라 **골막이 식**이고, +# 그것을 기슭막이에까지 쓰고 있었던 것이 어긋남의 정체였다. +# ⚠ 골막이는 그 밖에도 셋이 더 다르다 — 밑수가 **돌쌓기 + 돌붙임**(기슭막이는 돌쌓기만) · +# 단위가 **개소당**(기슭막이는 m당) · 정면적이 **사다리꼴**. **상수만 보고 가져다 쓰지 말 것.** STONE_MASONRY = { # ⚠ **곱하는 값이 아니라 검산 참고값이다** (2026-09-08 ㉘ 에서 고침). # 실무 시트의 「돌쌓기 = 정면적 × 1.04」에서 그 1.04 가 **곧 기울기 몫**이다 From c892bdc93592b54115799379228a9d67248859c2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 08:30:30 +0900 Subject: [PATCH 14/36] =?UTF-8?q?feat(B09):=20=EC=A1=B0=ED=95=A9=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=20=EC=8B=9C=20=EB=B3=B8=EC=B2=B4=20=EC=9E=A1?= =?UTF-8?q?=EC=9E=AC=EB=A3=8C=2016%=20+=20=ED=84=B0=ED=8C=8C=EA=B8=B0=20?= =?UTF-8?q?=EB=85=B8=ED=8B=B0=EC=8A=A4=20=EB=AC=B8=EA=B5=AC=20=EC=A0=95?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ① 품셈 제8장 [주]⑤ 의 마지막 조각 — 「…조합하여 사용할 때는 …**잡재료비율을 16%로 계상**하고, 리퍼·브레이커·집게의 손료 및 치즐 소모율을 추가」. 손료·치즐만 넣고 이 줄을 빠뜨리면 본체 재료비가 22% 로 남아 조금씩 비싸짐. - 기계마다 「#조합」 층을 함께 세움(손료·조종원 같음, 잡재료만 16%) - 조립 **끝에 한 번** 조합 공종의 본체 줄을 그 층으로 바꿔 닮 — 도중에는 어느 공종이 조합인지 아직 모름. 바꾼 줄 24 - 부착 장비는 **이름이 아니라 분류번호**로 잡음(0103 리퍼 · 0230 브레이커 · 0240 진동콤팩터 · 7206 집게) — 이름은 원천이 뭉개 놓는 일이 있음(0230 이 그랬음) - 결과: 구조물터파기 72,378.3 → 72,333.2 · 돌쌓기(메) 78,864.5 → 78,423.8 · 돌쌓기(찰) 61,757.3 → 61,418.0 · 본체 30,892,279 → **30,849,376원** ② 터파기 노티스 문구 정정 — 「실무는 기초만 세고 우리는 벽 전체를 셈(6.6배)」이 **틀렸음**. 우리 식은 정본(기초 0.45 + 비탈) 그대로이고, 6.6배는 소광리 시트가 비탈 줄을 안 적어서 난 것임. ⇒ 「비탈 터파기를 셀지 확정 대기 — 안 세면 약 1/7」로 고침. 값·금액은 그대로 검증: pytest 290 통과(신규 1) Co-Authored-By: Claude Opus 5 (1M context) --- .../B09_Estimation_BillOfQuantities_Rows.py | 25 +++--- B09_Estimation/B09_Estimation_UnitPrice.py | 89 +++++++++++++++++++ 2 files changed, 99 insertions(+), 15 deletions(-) diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py index bcf9bb38..ed02f559 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py @@ -332,26 +332,21 @@ def _leaf_row( #: 수량 산식이 **사용자 확정을 기다리는** 공종 — 금액은 세우되 그 사실을 함께 싣는다. #: -#: ⚠ **값을 우리가 바꾸지 않는다.** 실무 시트와 어긋나는 것이 확인됐을 뿐이고, 어느 쪽으로 -#: 갈지는 설계 판단이다(계획서 4-12 3단계, 확정 대기). -#: 돌쌓기(찰) H=2.0·ℓ3=45 · m당 기준으로 실무 대비 -#: 터파기 실무 0.35 우리 2.30 (약 6.6배) — 실무는 기초만(기초폭 × 깊이 0.3), -#: 우리는 벽 전체를 센다 -#: 되메우기 실무 0.185 우리 0.40 · 잔토정리 실무 0.165 우리 1.90 (위 둘의 차) +#: ⚠ **값을 우리가 바꾸지 않는다.** 어느 쪽으로 갈지는 설계 판단이다(계획서 4-12 3단계). +#: +#: ⚠ **2026-09-09 문구를 고쳤다.** 처음에는 「실무는 기초만 세고 우리는 벽 전체를 센다」로 +#: 적었는데 **그것이 틀렸다** — 우리 식은 정본(기초 0.45 + 비탈) 그대로이고, 6.6배 차이는 +#: **소광리 시트가 비탈 터파기 줄을 안 적어서** 난 것이다. 다투는 자리는 「우리 식이 +#: 맞는가」가 아니라 **「비탈 터파기를 셀 것인가」**다. _PENDING_FORMULA: dict[str, str] = { "FP-09-13": ( - "⚠ 터파기 수량 산식이 확정 대기입니다 — 실무 시트는 기초만(기초폭 × 깊이 0.3) 세고 " - "우리는 벽 전체를 셉니다(m당 실무 0.35 vs 우리 2.30, 약 6.6배). 실무 식으로 확정되면 " - "이 줄 금액이 약 1/6.6 로 줄어듭니다." + "⚠ 비탈 터파기를 셀지 확정 대기입니다 — 정본은 세고(기초 0.45 + 비탈) 다른 실무 " + "시트는 안 셉니다. 안 세는 쪽으로 확정되면 이 줄 금액이 약 1/7 로 줄어듭니다." ), "FP-09-14": ( - "⚠ 되메우기 수량 산식이 확정 대기입니다 — 터파기와 같은 뿌리입니다" - "(m당 실무 0.185 vs 우리 0.40)." - ), - "FP-09-15": ( - "⚠ 잔토처리 수량 산식이 확정 대기입니다 — 터파기·되메우기의 차라 같은 뿌리입니다" - "(m당 실무 0.165 vs 우리 1.90)." + "⚠ 되메우기도 같은 확정에 걸립니다 — 비탈 터파기를 안 세면 되메울 부피도 함께 줍니다." ), + "FP-09-15": ("⚠ 잔토처리도 같은 확정에 걸립니다 — 터파기와 되메우기의 차라 같은 뿌리입니다."), } diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index 79921d94..c4c02382 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -19,6 +19,7 @@ from __future__ import annotations import re from dataclasses import dataclass, field +from dataclasses import replace as dataclass_replace from decimal import Decimal from functools import lru_cache @@ -102,6 +103,8 @@ class UnitPriceBuild: #: ⚠ 값이 남의 절에서 온 것이면 **화면이 그렇게 말해야** 한다 — 안 그러면 나중에 #: 「이 숫자 어디서 왔지」로 되짚을 길이 없다. factor_sources: dict[str, str] = field(default_factory=dict) + #: 조합 사용(품셈 [주]⑤)으로 **잡재료 16% 층으로 바꿔 단 줄** 수. + combined_swapped: int = 0 #: 배분율 표인데 일부 몫만 붙은 공종 — 「단가가 일부만 섬」. 값은 붙은 몫(%). partial_ratio: dict[str, Decimal] = field(default_factory=dict) #: 시공능력 공식으로 장비 몫을 세운 공종 — 산출근거를 화면에 그대로 보인다. @@ -137,6 +140,50 @@ def _add_labor_titles(book: PriceBook, wages: dict[str, Decimal]) -> None: _ATTACHMENT_WORDS = ("브레이커", "리퍼", "부착용집게", "집게") +#: 조합 사용 시 본체(굴착기·불도저)의 잡재료비율 — 품셈 제8장 [주]⑤. +#: 「…리퍼, 브레이커, 부착용집게를 **조합하여 사용**할 때는 …**잡재료비율을 16%로 계상**하고, +#: 리퍼, 브레이커, 부착용 집게의 손료 및 치즐 소모율을 추가하는 것이다.」 +#: ⚠ 손료·치즐만 더하고 이 줄을 빠뜨리기 쉽다 — 그러면 본체 재료비가 계속 22% 로 서서 +#: 조금씩 비싸진다(굴착기 0.7 기준 시간당 1,285원). +COMBINED_MISC_PERCENT = Decimal(16) + +#: 조합으로 쓰는 부착 장비 — 이 층이 붙은 공종의 본체는 위 비율을 쓴다. +#: 카탈로그 분류번호로 잡는다 — 0103 유압식 리퍼 · 0230 대형 브레이커 · +#: 0240 유압식 진동콤팩터(굴착기 부착용) · 7206 부착용 집게. +#: ⚠ 이름이 아니라 **번호**로 잡는다 — 이름은 원천이 뭉개 놓는 일이 있다(0230 이 그랬다). +_ATTACHMENT_PREFIXES = ("X-0103-", "X-0230-", "X-0240-", "X-7206-") + + +def _apply_combined_misc_rate(book: PriceBook, work_item_titles: list[str]) -> int: + """조합 사용 공종의 본체 기계를 **잡재료 16% 짜리 층**으로 바꿔 단다. + + 바꾼 줄 수를 돌려준다. ⚠ 원래 층은 그대로 둔다 — 조합이 아닌 공종은 22% 그대로다. + """ + swapped = 0 + for title_code in work_item_titles: + details = book.details.get(title_code) or [] + if not any(detail.ref_code.startswith(_ATTACHMENT_PREFIXES) for detail in details): + continue + for index, detail in enumerate(details): + if not detail.ref_code.startswith("X-") or detail.ref_code.startswith( + _ATTACHMENT_PREFIXES + ): + continue + combined = f"{detail.ref_code}#조합" + if combined not in book.titles: + continue + details[index] = dataclass_replace( + detail, + ref_code=combined, + note=( + (detail.note + " · " if detail.note else "") + + "조합 사용 — 잡재료 16% (품셈 제8장 [주]⑤)" + ), + ) + swapped += 1 + return swapped + + def _add_machine_layers(book: PriceBook, machine_codes: set[str]) -> list[str]: """`S`(취득가) · `L`(운전사) · `M`(연료) 을 세우고 그 위에 `X` 를 올린다. @@ -232,6 +279,32 @@ def _add_machine_layers(book: PriceBook, machine_codes: set[str]) -> list[str]: # 잡재료는 **주연료의 %** — 유가와 같이 움직인다. liters = liters * (Decimal(1) + record.misc_material_percent / Decimal(100)) book.add_detail(PriceDetail(hourly_code, fuel_code, liters, note="주연료 + 잡재료")) + + # 조합 사용(리퍼·브레이커·집게)일 때 쓸 **잡재료 16%** 짜리 층을 함께 세운다. + # 같은 기계라도 조합이면 본체 잡재료가 줄어든다(품셈 제8장 [주]⑤). + combined_code = f"{hourly_code}#조합" + if combined_code not in book.titles: + book.add_title( + PriceTitle( + code=combined_code, + kind=PriceKind.MACHINE_HOURLY, + name=machine.name, + spec=(f"{machine.specification} · 조합").strip(" ·"), + unit="hr", + ) + ) + book.add_detail( + PriceDetail(combined_code, base_code, Decimal(1), note="시간당 손료") + ) + book.add_detail( + PriceDetail( + combined_code, + fuel_code, + record.fuel_liters_per_hour + * (Decimal(1) + COMBINED_MISC_PERCENT / Decimal(100)), + note=f"주연료 + 잡재료 {COMBINED_MISC_PERCENT}% (조합 사용)", + ) + ) else: incomplete.append(f"{code} (연료소모량 없음)") @@ -251,6 +324,17 @@ def _add_machine_layers(book: PriceBook, machine_codes: set[str]) -> list[str]: slots=_slots(wages[wage_code]), ) ) + # 조합 층에도 조종원을 같이 단다 — 본체를 모는 사람은 하나뿐이다. + combined_code = f"{hourly_code}#조합" + if combined_code in book.titles: + book.add_detail( + PriceDetail( + combined_code, + wage_code, + per_hour_person, + note="조종원 (1일 8시간 × 제수당·상여·퇴직충당 16/12 × 25/20)", + ) + ) book.add_detail( PriceDetail( hourly_code, @@ -668,6 +752,11 @@ def build_unit_prices( covered += machine_share if covered < Decimal(100): build.partial_ratio[work_item_code] = covered + # ⚠ **마지막에 한 번** — 조합 사용 공종의 본체 기계를 잡재료 16% 층으로 바꿔 단다 + # (품셈 제8장 [주]⑤). 조립 도중에 바꾸면 어느 공종이 조합인지 아직 모른다. + build.combined_swapped = _apply_combined_misc_rate( + build.book, [code for code in build.book.titles if code.startswith("B-")] + ) return build From 62f1e252d56bfafcfffc575fc79bd90295315368 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 08:36:47 +0900 Subject: [PATCH 15/36] =?UTF-8?q?feat(B08):=20=EB=B0=94=EB=8B=A5=EB=A7=89?= =?UTF-8?q?=EC=9D=B4=20=EC=A0=84=EA=B0=9C=20=E2=80=94=20=EB=8F=8C=EB=B6=99?= =?UTF-8?q?=EC=9E=84=20=E3=8E=A1=EB=8B=B9=20=EC=9B=90=EB=8B=A8=EC=9C=84=20?= =?UTF-8?q?(3=EB=8B=A8=EA=B3=84=20=EC=85=8B=EC=A7=B8).=20=EC=95=9E?= =?UTF-8?q?=EC=84=9C=20=EB=82=B8=20=EC=82=AC=EC=9C=A0=EA=B0=80=20=ED=8B=80?= =?UTF-8?q?=EB=A0=B8=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚠ **정정** — 어제 「돌붙임 원단위 미확보」로 사유를 냈으나 **소광리 정본에 식이 셋 있었음.** 안 찾아본 것을 「없다」고 적은 자리였음. 53탭 전수에서 드러남. 붙인 원단위 (정본 「돌붙임L3=45(사방)」 · 「돌붙임L3=30(야면석메붙임)」) ``` 돌붙임(찰) 두께 0.45 · 돌 30×30×45㎝ **0.918 ton/㎡**(= 0.45 × 0.77 × 2.65) · 고임돌 0.15 · 모르타르 0.009 · 채움 0.2 + **버림 0.1** · 터파기 0.45 8줄 돌붙임(메) 두께 0.30 · 야면석 20×20×30㎝ **0.42 ton/㎡**(원문 직접값) · 고임돌 0.07 · 터파기 0.30 5줄 ⚠ 모르타르·채움·버림은 **줄 자체가 없음** — 0 으로 만들지 않고 뺌 ``` 밑수는 **면적(㎡)** — 등록부 `area_m2` 와 축이 그대로 맞음. **기울기 몫을 안 곱함**(평면). ⚠⚠ **돌쌓기 표(13-4)와 합치지 못하게 못 박음** — 값이 겹쳐 보여 합치고 싶어지는 자리: ``` 돌쌓기(13-4) 밑수 비탈면적 · 뒷길이 일곱 규격 · 기울기 몫 √(1+n²) 돌붙임(13-5) 밑수 **평면적** · 뒷길이 **넷** · **기울기 몫 없음** · 메는 모르타르·채움 없음 ⚠ 45㎝ 에서 고임돌 0.15·채움 0.20 이 **우연히 같음** — 합치면 25·30·60·75 에서 조용히 틀림 ``` ⓘ **돌중량이 관측값이 아니라 유도값임** — `뒷길이 × 0.77(채움률) × 2.65(비중)`. 두 원본에서 같은 모양이 나왔음. 다만 **야면석은 그 식이 안 맞아**(공극이 커 ㎥당 무게가 다름) 원문 직접값 0.42 를 그대로 옮김. ⇒ 확정 ⑤ 가 「값 고르기」가 아니라 **「채움률·비중 두 인자 정하기」**로 바뀔 수 있음 — 우리 0.88 은 안 건드렸음. ⚠ 뒷길이가 **붙박이**(찰 0.45 · 메 0.30) — 정본 탭이 그 둘뿐이고 등록부에 칸이 없음. 그 사실을 줄로 냄. 칸이 생기면 표를 열면 됨. 자체검증 — 새 시험 5건(정본 값 대조 · 메는 세 줄이 빠지는지 · 기울기 몫 없음), 회귀 591 통과 · 0 실패. Co-Authored-By: Claude Opus 5 (1M context) --- .../B08_Quantity_Engine_UnitQuantity.py | 10 +- ..._Quantity_Engine_UnitQuantity_Revetment.py | 107 +++++++++++++++++- 2 files changed, 112 insertions(+), 5 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py index 03370aff..6ce09aa9 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -942,7 +942,8 @@ EXPANDERS = { "revetment": lambda h, l, o, f=None, r="": _revetment()(h, l, o, f, r), # 골막이·바닥막이는 돌 계열이나 **돌쌓기 식을 그대로 못 쓴다** — 까닭은 그 모듈에 적었다. "erosion_check": lambda h, l, o, f=None, r="": _stone_kin()("erosion_check"), - "bed_sill": lambda h, l, o, f=None, r="": _stone_kin()("bed_sill"), + # 바닥막이는 **돌붙임 ㎡당**이라 높이·연장이 아니라 **면적**이 밑수다(정본 「돌붙임L3=…」). + "bed_sill": lambda h, l, o, f=None, r="": _bed_sill()(_num(o.get("area_m2"), 0.0), o), # ⚠⚠ **큰돌쌓기(`boulder_masonry`)를 여기에 두지 않는다** (2026-09-07 발견). # 큰돌쌓기는 품셈 **13-6** 이고 돌쌓기는 **13-4** 다 — **규격 축이 다르다.** # 돌쌓기는 **뒷길이**(35·45·55·60㎝), 큰돌쌓기는 **직경**(40~60·60~80·80~100㎝). @@ -953,6 +954,13 @@ EXPANDERS = { } +def _bed_sill(): + """바닥막이 전개 함수를 늦게 가져온다.""" + from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import bed_sill + + return bed_sill + + def _stone_kin(): """골막이·바닥막이 사유 함수 — 늦게 가져온다(서로 부르는 것을 푸는 자리).""" from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import stone_kin_withheld diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py index 1ccae6f7..8af1bf40 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py @@ -63,10 +63,45 @@ STONE_KIN_WITHHELD: dict[str, str] = { "실측) — 평균두께 {(0.45+0.1H)+(0.45+0.4H)}÷2 · 밑수 「돌쌓기+돌붙임」 · 개소당. " "게다가 정면적이 사다리꼴이라 **상장·하장 칸이 있어야** 셈이 서는데 등록부에 없음" ), - "bed_sill": ( - "바닥막이는 형태가 **돌붙임**(품셈 13-5)이라 돌쌓기(13-4) 표를 못 씀 — " - "밑수도 면적(㎡)이라 축이 다름. 돌붙임 원단위 미확보" - ), +} + +#: 돌붙임 원단위 — 소광리 정본 「돌붙임L3=45(사방)」(찰) · 「돌붙임L3=30(야면석메붙임)」(메). +#: ⚠⚠ **돌쌓기 표(13-4)와 합치지 말 것.** 값이 겹쳐 보여 합치고 싶어지는 자리다: +#: +#: 돌쌓기(13-4) 밑수 **비탈면적** · 뒷길이 **일곱 규격** · **기울기 몫 √(1+n²)** +#: 돌붙임(13-5) 밑수 **평면적** · 뒷길이 **넷** · **기울기 몫 없음** +#: 메는 모르타르·채움콘크리트가 통째로 빠진다 +#: +#: ⚠ 45㎝ 에서 고임돌 0.15 · 채움 0.20 이 **우연히 같다** — 그것이 합치고 싶어지는 까닭이고, +#: 합치면 뒷길이 25·30·60·75 에서 조용히 틀린다(돌붙임 표에 없는 규격이다). +#: +#: ⚠ **뒷길이는 지금 붙박이다** — 찰 0.45m · 메 0.30m. 정본 탭이 그 둘로만 있고 바닥막이 +#: 등록부에 뒷길이 칸이 없다. 칸이 생기면 이 표를 열면 된다. +#: ⓘ 돌중량은 **관측값이 아니라 유도값**이다 — `뒷길이 × 0.77(채움률) × 2.65(비중)`. +#: 찰 0.45 × 0.77 × 2.65 = 0.918 ton/㎡. 메(야면석)는 0.42 로 **원문에 값이 직접 적혀 있어** +#: 같은 유도식이 안 맞는다(야면석은 공극이 커 ㎥당 무게가 다르다) — 그대로 옮긴다. +BED_SILL_FORMS: dict[str, dict[str, Any]] = { + "돌붙임(찰)": { + "back_len_m": 0.45, + "stone_name": "돌", + "stone_spec": "30×30×45㎝", + "stone_ton_per_m2": 0.45 * 0.77 * 2.65, + "wedge_stone_m3_per_m2": 0.15, + "mortar_m3_per_m2": 0.009, + "fill_concrete_m3_per_m2": 0.2, + "blinding_m3_per_m2": 0.1, + }, + "돌붙임(메)": { + "back_len_m": 0.30, + "stone_name": "야면석", + "stone_spec": "20×20×30㎝", + "stone_ton_per_m2": 0.42, + "wedge_stone_m3_per_m2": 0.07, + # 메붙임은 모르타르·채움콘크리트·버림이 **없다**(정본 탭에 줄 자체가 없음). + "mortar_m3_per_m2": None, + "fill_concrete_m3_per_m2": None, + "blinding_m3_per_m2": None, + }, } #: 돌쌓기 식이 계수를 가르는 데 쓰는 칸 — 기슭막이 등록부에 아직 없는 것을 알린다. @@ -116,3 +151,67 @@ def stone_kin_withheld(type_id: str) -> tuple[list[Component], list[str]]: """ why = STONE_KIN_WITHHELD.get(type_id) return [], [why] if why else [] + + +def bed_sill(area_m2: float, options: dict[str, Any]) -> tuple[list[Component], list[str]]: + """바닥막이 전개 — **돌붙임 ㎡당** 원단위를 면적에 곱한다(소광리 정본 두 탭). + + ⚠ 밑수가 **면적**이다. 돌쌓기처럼 높이·연장으로 셈하지 않는다 — 정본 표 제목이 「㎡당」이고 + 등록부 밑수도 `area_m2` 라 축이 그대로 맞는다. + ⚠ 기울기 몫이 **없다** — 평면에 붙이는 것이라 √(1+n²) 를 곱하지 않는다. + """ + from B08_Quantity.B08_Quantity_Engine_UnitQuantity import DESTINATION + + form = str(options.get("form") or "").strip() + table = BED_SILL_FORMS.get(form) + if table is None: + return [], [ + f"바닥막이 형태 「{form or '빈 값'}」 — 돌붙임(찰)·돌붙임(메)만 원단위가 있습니다" + ] + if area_m2 <= 0: + return [], ["면적이 없어 전개하지 않음"] + + back = table["back_len_m"] + rows: list[tuple[str, str, float, str]] = [ + ("돌붙임", "㎡", area_m2, f"면적 {area_m2:g}㎡ (평면적 — 기울기 몫 없음)"), + ("입적", "㎥", area_m2 * back, f"면적 × 두께 {back:g}m"), + ( + table["stone_name"], + "ton", + area_m2 * table["stone_ton_per_m2"], + f"면적 × {table['stone_ton_per_m2']:.3f} ton/㎡ · {table['stone_spec']}" + + ( + f" (= 뒷길이 {back:g} × 0.77 × 2.65)" + if form == "돌붙임(찰)" + else " (원문 직접값 — 야면석은 유도식이 안 맞음)" + ), + ), + ( + "고임돌", + "㎥", + area_m2 * table["wedge_stone_m3_per_m2"], + f"면적 × {table['wedge_stone_m3_per_m2']} ㎥/㎡", + ), + ] + for name, key, unit in ( + ("모르터", "mortar_m3_per_m2", "㎥"), + ("채움콘크리트", "fill_concrete_m3_per_m2", "㎥"), + ("버림콘크리트", "blinding_m3_per_m2", "㎥"), + ): + value = table[key] + if value is not None: + rows.append((name, unit, area_m2 * value, f"면적 × {value} ㎥/㎡")) + # 터파기 — 정본은 「두께 × 1 × 1」이라 **면적 × 두께**와 같다. + rows.append(("터파기", "㎥", area_m2 * back, f"면적 × 두께 {back:g}m")) + + components = [ + Component(name, unit, amount, DESTINATION.get(name, "quantity"), basis) + for name, unit, amount, basis in rows + ] + notes = [f"뒷길이 {back:g}m 는 정본 탭 붙박이 값입니다 — 바닥막이 제원에 뒷길이 칸이 없습니다"] + if form == "돌붙임(찰)": + notes.append( + "버림 콘크리트 0.1 ㎥/㎡ 포함 — 정본 주기 「바닥 10㎝ 이상 콘크리트(버림) 포설 후 " + "돌붙임」(KDS 44 90 00 의 100㎜ 와 같음)" + ) + return components, notes From 68ca189220abd77c07cf1c6d3bf5c491883f0d68 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 08:37:44 +0900 Subject: [PATCH 16/36] =?UTF-8?q?fix(B08):=20=EC=9A=B4=EB=B0=98=EC=9D=B4?= =?UTF-8?q?=20=EB=91=90=20=EC=B6=95=EC=97=90=20=EC=8B=A4=EB=A0=A4=20?= =?UTF-8?q?=EB=82=B4=EC=97=AD=20=EC=A4=84=EC=9D=B4=20=EB=91=90=20=EB=B2=8C?= =?UTF-8?q?=EC=9D=B4=EB=8D=98=20=EA=B2=83=20=EB=A7=89=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 토공집계표는 실무 토적집계 모양이라 무대·도자운반·덤프운반을 함께 싣는데, 인계본에는 운반표(FP-10-11·FP-10-12)가 같은 물량으로 또 실렸음 — 둘 다 in_bill 이었음 (실측: 도자 17.389·61.130 · 덤프 37.511·122.417 이 두 축에 각각) - 집계 쪽 운반 줄을 in_bill=False 로 두고 사유를 적음. 값은 남김 — 검산 (무대+도자+덤프 = 총 운반토량)이 그 값을 씀(무대를 그렇게 둔 것과 같은 규칙) - 성토 같은 다른 집계 줄은 안 건드림 - tmp/tests/test_b08_haul_double_count.py 신설(5건) Co-Authored-By: Claude Opus 5 (1M context) --- B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py | 12 ++++++++++++ B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py | 11 +++++++---- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py index d66196fe..d20bf41b 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py @@ -139,6 +139,18 @@ SLOPE_GROUPS = frozenset({"성토면다짐", "초류종자살포", "지장목제 #: 집계 합계 줄 — 내역 줄이 아니라 검산용이다. SUBTOTAL_GROUPS = frozenset({"보정량계"}) +#: ⚠⚠ **토공집계표에도 운반 줄이 있다 — 그런데 내역 줄은 운반표 쪽이다**(2026-09-09 실측). +#: 집계표는 실무 토적집계 모양이라 「무대·도자운반·덤프운반」을 함께 싣는데, 인계본에는 +#: 운반표(`_haul_rows`, FP-10-11·FP-10-12)가 **같은 물량으로 또 실렸다** — 같은 운반이 +#: 두 줄이었다(실측: 도자 17.389·61.130, 덤프 37.511·122.417 이 두 축에 각각). +#: ⇒ 집계 쪽은 **값은 내되 내역 줄이 아니다**. 빼지 않는 까닭은 검산(무대+도자+덤프 = 총 +#: 운반토량)이 그 값을 쓰기 때문이다 — 무대를 그렇게 둔 것과 같은 규칙이다. +HAUL_SUMMARY_GROUPS = frozenset({"무대(종방향유용토)", "도자운반", "덤프운반"}) +NOTE_HAUL_IN_SUMMARY = ( + "집계 값 — 내역 줄은 운반표 쪽(FP-10-11·FP-10-12)이 세움. 여기서 또 세우면 같은 운반이" + " 두 줄이 됨(검산용으로만 실림)" +) + def _latest_dataset_path(directory: Path | None = None) -> Path | None: folder = directory or DATASET_DIR diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py index d650a009..52c7ec19 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py @@ -14,7 +14,9 @@ from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import ( BLOCKED_FORMULA_MISSING, BLOCKED_INPUT_MISSING, BLOCKED_UNIT_DATA_MISSING, + HAUL_SUMMARY_GROUPS, METHOD_TO_GROUND, + NOTE_HAUL_IN_SUMMARY, NOTE_METHOD_MISSING, ORIGIN_EARTHWORK, ORIGIN_HAUL, @@ -114,7 +116,8 @@ def _earthwork_rows( continue ground = row.get("item") or None origin = ORIGIN_SLOPE if group in SLOPE_GROUPS else ORIGIN_EARTHWORK - is_subtotal = group in SUBTOTAL_GROUPS + # ⚠ 운반은 **집계에도 오르고 운반표에도 오른다** — 내역 줄은 운반표 쪽 하나뿐이다. + is_subtotal = group in SUBTOTAL_GROUPS or group in HAUL_SUMMARY_GROUPS lookup_ground, method_note = _mapping_ground(ground, methods) entry = mapping.for_earthwork(group, lookup_ground) if method_note == "" else None code = (entry or {}).get("work_item_code") @@ -169,9 +172,9 @@ def _earthwork_rows( # 합계 줄과 무대 줄은 값은 내되 내역에 안 선다. "in_bill": bool(row.get("in_bill", True)) and not is_subtotal and mismatch is None, "excavation_method": methods.get(ground) if ground else None, - "in_bill_reason": "집계 합계 줄 — 검산용" - if is_subtotal - else str(row.get("note") or ""), + "in_bill_reason": NOTE_HAUL_IN_SUMMARY + if group in HAUL_SUMMARY_GROUPS + else ("집계 합계 줄 — 검산용" if is_subtotal else str(row.get("note") or "")), "origin": origin, } ) From e0081cd953de52042273f769ac43535078616420 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 09:03:49 +0900 Subject: [PATCH 17/36] =?UTF-8?q?fix(B09):=20=EC=A0=9C=EA=B7=BC=20?= =?UTF-8?q?=EC=9D=B4=EC=A4=91=EA=B3=84=EC=83=81=20=E2=80=94=20=ED=91=9C?= =?UTF-8?q?=EA=B0=80=20=E3=80=8C=EA=B3=A0=EB=A5=B4=EB=9D=BC=E3=80=8D?= =?UTF-8?q?=EA=B3=A0=20=EB=91=94=20=EC=9E=A5=EB=B9=84=20=EB=B8=94=EB=A1=9D?= =?UTF-8?q?=EC=9D=84=20=EB=8B=A4=20=EB=8D=94=ED=95=98=EA=B3=A0=20=EC=9E=88?= =?UTF-8?q?=EC=97=88=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 품셈 9-21 제근은 장비 규격 두 갈래(0.2㎥·0.7㎥)를 **고르라고** 둔 표인데 둘 다 붙고 보통인부도 두 번 붙어 단가가 **128,039원**으로 서 있었음. 밑수가 원문에 없어 아직 금액이 안 서 있었을 뿐, **밑수가 정해지는 날 조용히 두 배로 설 자리**였음. - `B09_Estimation_ResourceAxis_ChooseOne` 신설 — 블록마다 갈래 하나, 열마다 갈래 하나. 장비 2 × 임목밀도 3 = **여섯 갈래**로 세움 (55,625.9 ~ 111,161.4원) - ⚠ **「고르는 표」를 좁게 가름** — 기계 줄이 둘이라고 다 그런 표가 아님. 9-13 암절취는 깨기(브레이커)와 들어내기(백호우)가 **함께 듦**. 자국은 **같은 이름에 규격만 다른 것**. 품셈도 그렇게 말함 — 9-20-1 [주]④ 「0.2㎥ 또는 0.4㎥ … 계수를 달리 적용」 - ⚠ 밑수는 만들지 않음 — 9-21 은 표 머리·제목·[주] 어디에도 밑수 표기가 없음 (앞 절 9-20-1 「(10주당)」·9-20-2 「(단위: 10주당)」과 대비되는 자리) ⚠ 짓다 잡은 것 — 기종 해석이 **넘겨받은 카탈로그를 무시**하고 기계 카탈로그를 직접 봐서, 노무만 든 카탈로그로 돌릴 때도 기계 줄이 나왔음. 카탈로그에 기계가 없으면 이 표를 안 읽게 막음(시험이 그것을 잡았음). ⚠ 시험 하나가 **버그를 맞는 것으로 적어 두고 있었음** — 「제근은 보통인부 줄이 둘이다」. 그것이 곧 이중계상이었음. 뒤집어 다시 적었음. 검증: pytest 295 통과(신규 4) · 자원 축 539 → 547 줄, 늘어난 8 줄은 전부 제근 Co-Authored-By: Claude Opus 5 (1M context) --- B09_Estimation/B09_Estimation_ResourceAxis.py | 9 + .../B09_Estimation_ResourceAxis_ChooseOne.py | 200 ++++++++++++++++++ 2 files changed, 209 insertions(+) create mode 100644 B09_Estimation/B09_Estimation_ResourceAxis_ChooseOne.py diff --git a/B09_Estimation/B09_Estimation_ResourceAxis.py b/B09_Estimation/B09_Estimation_ResourceAxis.py index 5a336691..ac9add90 100644 --- a/B09_Estimation/B09_Estimation_ResourceAxis.py +++ b/B09_Estimation/B09_Estimation_ResourceAxis.py @@ -378,6 +378,15 @@ def match_table( if match_three_axis_table(node, table, catalog, result): return + # ⚠ **「둘 중 하나를 고르는」 장비 블록 표도 먼저 가른다.** 그냥 읽으면 블록을 다 더해 + # 장비 두 대·인부 두 몫이 서서 대략 두 배가 된다(2026-09-09 제근 128,039원). + from B09_Estimation.B09_Estimation_ResourceAxis_ChooseOne import ( + match_choose_one_machine_table, + ) + + if match_choose_one_machine_table(node, table, catalog, result): + return + form = table.get("pum_form", "") if form in NON_WORK_ITEM_FORMS or form in UNUSABLE_FORMS or form not in USABLE_FORMS: result.skipped_forms[form] = result.skipped_forms.get(form, 0) + 1 diff --git a/B09_Estimation/B09_Estimation_ResourceAxis_ChooseOne.py b/B09_Estimation/B09_Estimation_ResourceAxis_ChooseOne.py new file mode 100644 index 00000000..11314660 --- /dev/null +++ b/B09_Estimation/B09_Estimation_ResourceAxis_ChooseOne.py @@ -0,0 +1,200 @@ +"""B09 원가계산 — **표가 「둘 중 하나를 고르라」고 둔 장비 블록** 읽기 (2026-09-09). + +품셈에는 같은 일을 **장비 규격에 따라 달리 세는** 표가 있다. 블록이 둘인데 **둘 다 더하면 +장비 두 대와 인부 두 몫이 서서 대략 두 배**가 된다. + + 9-21 제근 + | 종 류 | 명 칭 | 단위 | 소 | 중 | 밀 | + | 굴착기(무한궤도) | 굴착기(무한궤도,0.2㎥) | hr | 0.80 | 1.01 | 1.22 | ┐ 0.2㎥ 블록 + | 보통인부 | 인 | 0.03 | 0.04 | 0.05 | ┘ + | 굴착기(무한궤도,0.7㎥) | hr | 0.46 | 0.58 | 0.70 | ┐ 0.7㎥ 블록 + | 보통인부 | 인 | 0.03 | 0.04 | 0.05 | ┘ + +2026-09-09 실측: 제근 단가가 **128,039원**으로 서 있었다 — 굴착기 0.2·0.7 이 둘 다 붙고 +보통인부도 두 번 붙은 값이다. 밑수가 원문에 없어 아직 금액이 안 서 있었을 뿐, +**밑수가 정해지는 날 조용히 두 배로 설 자리**였다. + +읽는 법 — **블록마다 갈래 하나**, 열마다 갈래 하나. 둘을 곱해 갈래를 낸다. + + 갈래 = 「굴착기(무한궤도) 0.2㎥ · 소」 … 「굴착기(무한궤도) 0.7㎥ · 밀」 (2 × 3 = 6) + +⚠ **「고르는 표」인지 아닌지를 좁게 가른다.** 기계 줄이 둘이라고 다 고르는 표가 아니다 — +9-13 암절취는 「깨기(대형브레이커)」와 「들어내기(백호우)」가 **함께 드는** 표다. +가르는 자국은 **같은 기계 이름에 규격만 다른 것**이다(굴착기 0.2 vs 0.7). 품셈도 그렇게 +말한다 — 9-20-1 [주]④ 「0.2㎥ 또는 0.4㎥ 용량의 굴착기를 사용하는 경우에는 …적용계수를 +달리 적용하도록 한다」. + +⚠ **밑수는 여기서 만들지 않는다.** 9-21 은 표 머리·제목·[주] 어디에도 밑수가 없다 +(2026-09-09 원문 전수 확인). 갈래만 바로 세우고 밑수는 빈 채로 둔다. +""" + +from __future__ import annotations + +import re + +from decimal import Decimal +from typing import Any + +from B09_Estimation.B09_Estimation_ResourceAxis import ( + AxisResult, + CatalogEntry, + ResourceCatalog, + ResourceRow, + UnmatchedRow, + parse_amount, + split_name_and_spec, +) + +_NUMBER = re.compile(r"^\d+(?:\.\d+)?$") + +#: 열 머리로 인정하지 않는 말 — 값이 아니라 설명이다. +_NOT_A_COLUMN = ("비고", "적요", "참고", "단위", "명칭", "명 칭", "종류", "종 류", "규격", "규 격") + + +def _clean(cell: Any) -> str: + return " ".join(str(cell or "").split()) + + +def _column_labels(header: list[Any]) -> list[str]: + """표 머리에서 **갈래 열 이름**만 골라 낸다 — 「소·중·밀」.""" + labels = [_clean(cell) for cell in header] + return [ + label + for label in labels + if label and "".join(label.split()) not in {"".join(w.split()) for w in _NOT_A_COLUMN} + ] + + +def _machine_of(cells: list[str], catalog: ResourceCatalog): + """그 줄이 기계 줄이면 (칸 번호, 기종, 원문 칸). 아니면 `None`. + + ⚠ 자원 카탈로그의 이름·규격 짝으로는 안 풀린다 — 품셈이 「굴착기(무한궤도,0.2㎥)」처럼 + **규격을 괄호 안에 몰아** 적기 때문이다. 기종 해석은 그 모양을 아는 쪽에 맡긴다. + """ + from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog + from B09_Estimation.B09_Estimation_MachineProductivity import resolve_machine + + machines = load_machine_catalog().machines + for index, cell in enumerate(cells): + found = resolve_machine(cell) + if found is None: + continue + machine = machines.get(found[0]) + if machine is None: + continue + entry = CatalogEntry( + code=found[0], name=machine.name, kind="machine", spec=str(machine.specification) + ) + return index, entry, cell + return None + + +def _values_of(cells: list[str], count: int) -> list[Decimal] | None: + """그 줄 끝에서 값 `count` 개. 개수가 안 맞으면 `None` — 짐작해 채우지 않는다.""" + numbers = [cell for cell in cells if _NUMBER.match(cell)] + if len(numbers) < count: + return None + picked = [parse_amount(cell) for cell in numbers[-count:]] + return None if any(value is None for value in picked) else picked # type: ignore[return-value] + + +def match_choose_one_machine_table( + node: dict[str, Any], + table: dict[str, Any], + catalog: ResourceCatalog, + result: AxisResult, +) -> bool: + """「둘 중 하나를 고르는」 장비 블록 표를 읽는다. 그런 표가 아니면 `False`. + + ⚠ 같은 기계 이름에 **규격만 다른** 블록이 둘 이상일 때만 내 표로 본다. + """ + # ⚠ **기계 카탈로그가 없는 조립에서는 이 표를 읽지 않는다.** 기종 해석은 기계 쪽 + # 카탈로그를 직접 보므로, 노무만 든 카탈로그로 돌릴 때도 기계 줄이 나와 버린다 + # (2026-09-09 시험이 그것을 잡았다). **넘겨받은 카탈로그의 결을 따른다.** + if not any(entry.kind == "machine" for entry in catalog.entries): + return False + + rows = [row for row in (table.get("raw_row") or []) if isinstance(row, list)] + header = list(table.get("condition_note") or []) + labels = _column_labels(header) + if len(rows) < 2 or len(labels) < 2: + return False + + blocks: list[dict[str, Any]] = [] + for row in rows: + cells = [_clean(cell) for cell in row] + if not cells: + continue + found = _machine_of(cells, catalog) + if found is not None: + _index, entry, raw_cell = found + values = _values_of(cells, len(labels)) + if values is None: + return False + blocks.append({"entry": entry, "cell": raw_cell, "rows": [], "values": values}) + continue + if not blocks: + continue + name, spec = split_name_and_spec(cells[0]) + entry = catalog.resolve(name, spec) + values = _values_of(cells, len(labels)) + if entry is None or values is None: + continue + blocks[-1]["rows"].append({"entry": entry, "values": values, "cell": cells[0]}) + + if len(blocks) < 2: + return False + # ⚠ **같은 이름 · 다른 규격**일 때만 「고르는 표」다. 이름이 다르면 함께 드는 장비다. + names = {block["entry"].name for block in blocks} + specs = {block["entry"].spec for block in blocks} + if len(names) != 1 or len(specs) != len(blocks): + return False + + work_item_code = str(node.get("work_item_code", "")) + table_id = str(table.get("pum_table_id", "")) + form = str(table.get("pum_form", "")) + unit = table.get("basis_unit") or "" + + made = 0 + for block in blocks: + machine = block["entry"] + for column, label in enumerate(labels): + variant = f"{machine.name} {machine.spec} · {label}".strip() + entries = [(machine, block["values"][column], block["cell"])] + entries += [ + (item["entry"], item["values"][column], item["cell"]) for item in block["rows"] + ] + for entry, amount, cell in entries: + result.rows.append( + ResourceRow( + work_item_code=work_item_code, + pum_table_id=table_id, + pum_form=form, + resource_kind=entry.kind, + resource_code=entry.code, + resource_name=entry.name, + resource_spec=entry.spec, + amount=amount, + amount_unit=unit, + raw_row_index=0, + variant=variant, + ) + ) + made += 1 + del cell + + if made == 0: + return False + result.unmatched.append( + UnmatchedRow( + work_item_code=work_item_code, + pum_table_id=table_id, + cell=" | ".join(_clean(cell) for cell in header), + reason=( + f"장비 규격 {len(blocks)} 가지 × 갈래 {len(labels)} 가지로 세웠습니다 — " + "표가 「둘 중 하나」로 둔 자리라 **더하지 않고 고르게** 합니다. " + "⚠ 밑수(무엇당)는 원문에 없습니다." + ), + ) + ) + return True From 446426e99750fa3736b1d462b4f6927611ad1ead Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 11:19:57 +0900 Subject: [PATCH 18/36] =?UTF-8?q?feat(B09):=20=EC=9B=90=EB=AC=B8=EC=97=90?= =?UTF-8?q?=EB=8A=94=20=EC=9E=88=EB=8A=94=EB=8D=B0=20=EB=8B=A8=EA=B0=80?= =?UTF-8?q?=EC=97=90=20=EB=AA=BB=20=EC=8B=A4=EB=A6=B0=20=EB=AA=AB=EC=9D=84?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=EC=9D=B4=20=EB=A7=90=ED=95=98=EA=B2=8C=20?= =?UTF-8?q?=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「반쪽 단가」 표시가 여태 **이름을 카탈로그에서 못 찾은 줄**만 잡고 있었음. 그물 밖에 둘이 더 있었고, 둘 다 **금액이 서 있는 줄**이라 표시가 없으면 완성된 값으로 읽혔음. - 기초잡석 12-25 — 원문에 「운반 | 덤프트럭(15ton)」 줄이 있는데 우리 단가에 없음. ⚠ 사유를 「원문이 값을 안 줌」으로 뭉뚱그리지 않음 — 그 값은 품셈 10-12(덤프운반)가 **운반거리로** 내는 것이라 **거리가 정해지면 풀림**. 「영영 막힘」과 「곧 풀림」은 사용자가 보는 뜻이 다름 - 기초잡석 소할 — 원문이 「**브레이커 사용할 때 제외**」라 한 줄인데 지금은 무조건 붙음. ⚠ 조건부 열두 줄 중 **이것만 금액이 서 있음**(다른 열한 줄은 붙는 쪽, 이것은 빠지는 쪽) - 규준틀 11-2·11-3 — 지금 값은 **인력 품만**. 손율은 원문에 있음(비탈 50% · 수평 80%) ⚠ 앞 보고를 정정함 — 규준틀은 「표시가 아무것도 없다」고 했으나 **B08 인계가 이미 「재료량은 [주]④ 설계수량에 따른다라 미확보」를 싣고 있었음.** 화면 문구 뒤쪽이 잘려 안 보였던 것임. 그래서 그 말은 되풀이하지 않고 **B09 쪽에서만 아는 것**만 보탬. 「잘린 자리를 사유가 없는 자리로 읽지 말 것」을 주석에 남김 검증: pytest 300 통과(신규 5) Co-Authored-By: Claude Opus 5 (1M context) --- .../B09_Estimation_BillOfQuantities_Rows.py | 9 +++ B09_Estimation/B09_Estimation_KnownGaps.py | 67 +++++++++++++++++++ .../B09_Estimation_UnitPrice_View.py | 4 ++ 3 files changed, 80 insertions(+) create mode 100644 B09_Estimation/B09_Estimation_KnownGaps.py diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py index ed02f559..fbf2c438 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py @@ -314,6 +314,12 @@ def _leaf_row( if pending: row.note = " / ".join(part for part in (row.note, pending) if part) + # ⚠ **원문에는 있는데 단가에 못 실린 몫**도 같은 자리에서 말한다. 금액이 서 있는 줄이라 + # 표시가 없으면 완성된 값으로 읽힌다(규준틀 둘이 인력만으로 492만원이었다). + gap = known_gap_note(node.code) + if gap: + row.note = " / ".join(part for part in (row.note, gap) if part) + # 쓰인 차례를 기억한다 — 실무 참조번호(「단산 46」)가 그 차례다. if price_code not in result.used_unit_prices: result.used_unit_prices.append(price_code) @@ -350,6 +356,9 @@ _PENDING_FORMULA: dict[str, str] = { } +from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note # noqa: E402 + + def pending_formula_note(code: str | None) -> str: """그 공종의 수량 산식이 확정 대기인가 — 맞으면 실을 문구.""" if not code: diff --git a/B09_Estimation/B09_Estimation_KnownGaps.py b/B09_Estimation/B09_Estimation_KnownGaps.py new file mode 100644 index 00000000..ac0f36b2 --- /dev/null +++ b/B09_Estimation/B09_Estimation_KnownGaps.py @@ -0,0 +1,67 @@ +"""B09 원가계산 — **원문에는 있는데 단가에 못 실린 몫** (2026-09-09). + +「반쪽 단가」 표시는 여태 **이름을 카탈로그에서 못 찾은 줄**만 잡았다. 그물 밖에 둘이 더 있다. + + ㉠ 표에 줄은 있는데 **값을 못 적는** 것 — 기초잡석 「운반 | 덤프트럭(15ton)」 + ㉡ 표에 아예 없고 **[주]가 별도라 한** 것 — 규준틀 목재·표지판 + +둘 다 **금액이 서 있는 줄**이라 표시가 없으면 **완성된 값으로 읽힌다**(2026-09-09 실측: +규준틀 둘이 4,921,267원인데 인력만의 값이었다). + +⚠ **사유를 뭉뚱그리지 않는다.** 「원문이 값을 안 줌」은 **영영 막힌 것**으로 읽히고, +「거리 미정」·「설계수량 대기」는 **곧 풀릴 것**으로 읽힌다. 사용자가 보는 뜻이 다르다. + +⚠ **여기 적는 것은 원문에 있는 말뿐이다.** 값을 만들지 않는다 — 무엇이 왜 빠졌는지만 적는다. +⚠ **마스터가 [주]를 싣게 되면 이 표는 지운다** — 두 곳에 같은 말을 두면 한쪽만 고쳐진다. +""" + +from __future__ import annotations + +#: 공종코드 → (짧은 딱지, 사유 한 줄). 접두사로 맞춘다(갈래가 붙어도 걸리게). +KNOWN_GAPS: dict[str, tuple[str, str]] = { + # ⚠ 규준틀 둘은 **B08 인계가 이미 「재료량은 [주]④ 설계수량에 따른다라 미확보」**를 싣는다. + # 그러니 그 말을 되풀이하지 않고 **B09 쪽에서만 아는 것**만 보탠다 — + # ㉠ 지금 선 값이 **인력 품만**이라는 것 ㉡ 손율은 원문에 이미 있다는 것. + # (2026-09-09: 처음엔 「표시가 아무것도 없다」고 봤는데, 인계 사유가 화면 문구 뒤쪽에 + # 잘려 안 보였던 것이다. 잘린 자리를 사유가 없는 자리로 읽지 말 것.) + "FP-11-02": ( + "인력 품만", + "ⓘ 지금 값은 **인력 품만**입니다. 재료가 서면 손율이 함께 걸립니다 — " + "품셈 11-2 [주]③ 「목재의 손율은 1개소 사용당 50%」.", + ), + "FP-11-03": ( + "인력 품만", + "ⓘ 지금 값은 **인력 품만**입니다. 재료가 서면 손율이 함께 걸립니다 — " + "품셈 11-3 [주]③ 「목재의 손율은 1개소 사용당 80%」.", + ), + "FP-12-25": ( + "운반거리 미정", + "⚠ 이 값에는 **운반 몫이 빠져 있습니다** — 품셈 12-25 는 「운반 | 덤프트럭(15ton)」 줄을 " + "두었으나 시간을 적지 않았습니다. 그 값은 품셈 10-12(덤프운반)가 **운반거리로** 냅니다" + "(같은 15ton 장비). 거리가 정해지면 사토 운반·덤프 운반과 **함께** 섭니다.", + ), +} + +#: 조건이 서면 **빠져야 하는** 줄 — 지금은 무조건 붙어 있다. +#: ⚠ 다른 조건부 줄들(계획서 9-9)과 달리 **이것은 금액이 서 있는 줄**이다. +CONDITIONAL_INCLUDED: dict[str, str] = { + "FP-12-25": ( + "ⓘ 소할(할석공 0.06인)은 품셈 12-25 가 「**브레이커 사용할 때 제외**」라 적은 줄입니다 — " + "브레이커 갈래가 서면 이 몫이 빠져야 합니다. 지금은 붙어 있습니다." + ), +} + + +def known_gap_note(code: str | None) -> str: + """그 공종에 **원문에는 있는데 못 실린 몫**이 있으면 사유 한 줄.""" + if not code: + return "" + plain = str(code).split("#")[0] + parts = [] + for prefix, (_label, note) in KNOWN_GAPS.items(): + if plain.startswith(prefix): + parts.append(note) + for prefix, note in CONDITIONAL_INCLUDED.items(): + if plain.startswith(prefix): + parts.append(note) + return " / ".join(parts) diff --git a/B09_Estimation/B09_Estimation_UnitPrice_View.py b/B09_Estimation/B09_Estimation_UnitPrice_View.py index b3cd3790..200999ec 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice_View.py +++ b/B09_Estimation/B09_Estimation_UnitPrice_View.py @@ -167,6 +167,8 @@ def detail_of(build: UnitPriceBuild, code: str) -> dict: money = build.book.resolve(code) # 코드에서 공종을 도로 뽑는다 — 「B-FP-09-11-01#갈래」의 갈래는 떼고 본다. work_item_code = code[2:].split("#")[0] if code.startswith("B-") else "" + from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note + unattached = list(build.unattached.get(work_item_code, [])) rows: list[dict] = [] for detail in build.book.details.get(code, []): @@ -261,6 +263,8 @@ def detail_of(build: UnitPriceBuild, code: str) -> dict: # ⚠ **표에 있는데 못 붙은 줄** — 이 단가가 일부만으로 섰다는 뜻이다. # 안 보이면 조용히 싼 단가가 내역서에 그대로 든다. "unattached": unattached, + # ⚠ 원문에는 있는데 못 실린 몫 — 이름을 못 찾은 줄과 **다른 갈래**다. + "known_gap_note": known_gap_note(work_item_code), "unattached_note": ( f"⚠ 품셈 표에 있는 {len(unattached)}줄이 아직 안 붙었습니다 — " f"{', '.join(unattached[:4])}" From 9f2e81babc5f911a3a4b9adc1b915f706ed8b613 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 11:28:30 +0900 Subject: [PATCH 19/36] =?UTF-8?q?feat(B08):=20=EA=B0=9C=EA=B1=B0(=EA=B2=89?= =?UTF-8?q?=EB=8F=84=EB=9E=91)=20=EC=A0=84=EA=B0=9C=20=E2=80=94=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B8=20=EB=91=90=20=ED=91=9C=EB=A5=BC=20m?= =?UTF-8?q?=EB=8B=B9=EC=9C=BC=EB=A1=9C=20(3=EB=8B=A8=EA=B3=84=20=EB=84=B7?= =?UTF-8?q?=EC=A7=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 소광리 정본 「개거(150-200) (2)」·「L형수로-(201)」 — **둘 다 m당**. ⚠⚠ **두 표가 담는 것이 다름.** 값이 비슷해 보여 합치고 싶어지는 자리라 못 박음: ``` 콘크리트 개거 150×200 터파기 · 유로폼 · 면목 **세 줄뿐** — **콘크리트 본체 줄이 없음** 콘크리트 L형수로 H=0.2 터파기 · 되메우기 · 잔토 · **콘크리트** · PVC Φ50 · 이형철근 D13 · 거푸집 · 면목 **여덟 줄** ⓘ 같은 것 — 둘 다 m당이고 거푸집 계열(유로폼·거푸집)과 면목이 있음 ``` ⚠ 개거에 콘크리트가 없는 것은 **빠뜨린 것이 아니라 원문 그대로**임. 0 으로 때우거나 L형수로 값을 옮겨 채우지 않음 — 돌붙임(메)에서 세 줄을 안 만든 그 판단과 같음. ⚠ **PVC Φ50 은 줄만 있고 수량 칸이 빔** — 안 세우고 그 사실을 줄로 냄. ⚠ 규격이 **붙박이**(「콘크리트 개거 150×200」) — `open_ditch` 등록부에 **옵션이 하나도 없음**. `ditch_spec` 을 주면 L형수로로 갈림. 칸이 생기면 열면 됨. **측구(`ditch_side`)는 안 건드림** — `design_owner: 횡단 설계` 라 B06 소관이고 `enabled: False`. 정본 「측구수로400/500」은 **「터파기 계산서」**라 터파기·잔토·되메우기 셋뿐임(본체 없음) — 그쪽이 쓸 때를 위해 적어 둠. 실전개 (연장 10m) 개거 터파기 0.3㎥ · 유로폼 5.5㎡ · 면목 20m L형수로 터파기 3.1645 · 되메우기 1.2997 · 잔토 1.8647 · 콘크리트 1.508 · 이형철근 3.98㎏ · 거푸집 7.38㎡ · 면목 10m 자체검증 — 새 시험 5건(정본 값 대조 · 개거에 콘크리트가 없는지 · 잔토 = 터파기−되메우기 · 빈 수량 줄 · 없는 규격), 회귀 596 통과 · 0 실패. Co-Authored-By: Claude Opus 5 (1M context) --- .../B08_Quantity_Engine_UnitQuantity.py | 9 +++ ..._Quantity_Engine_UnitQuantity_Revetment.py | 81 +++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py index 6ce09aa9..d3932054 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -942,6 +942,8 @@ EXPANDERS = { "revetment": lambda h, l, o, f=None, r="": _revetment()(h, l, o, f, r), # 골막이·바닥막이는 돌 계열이나 **돌쌓기 식을 그대로 못 쓴다** — 까닭은 그 모듈에 적었다. "erosion_check": lambda h, l, o, f=None, r="": _stone_kin()("erosion_check"), + # 개거는 **m당** 원단위라 연장이 밑수다(정본 「개거(150-200)」·「L형수로-(201)」). + "open_ditch": lambda h, l, o, f=None, r="": _open_ditch()(l, o), # 바닥막이는 **돌붙임 ㎡당**이라 높이·연장이 아니라 **면적**이 밑수다(정본 「돌붙임L3=…」). "bed_sill": lambda h, l, o, f=None, r="": _bed_sill()(_num(o.get("area_m2"), 0.0), o), # ⚠⚠ **큰돌쌓기(`boulder_masonry`)를 여기에 두지 않는다** (2026-09-07 발견). @@ -954,6 +956,13 @@ EXPANDERS = { } +def _open_ditch(): + """개거 전개 함수를 늦게 가져온다.""" + from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import open_ditch + + return open_ditch + + def _bed_sill(): """바닥막이 전개 함수를 늦게 가져온다.""" from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import bed_sill diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py index 8af1bf40..cd425949 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py @@ -153,6 +153,87 @@ def stone_kin_withheld(type_id: str) -> tuple[list[Component], list[str]]: return [], [why] if why else [] +#: 개거(겉도랑) 원단위 — 소광리 정본 「개거(150-200) (2)」·「L형수로-(201)」, **둘 다 m당**. +#: ⚠⚠ **두 표가 담는 것이 다르다.** 값이 비슷해 보여 합치고 싶어지는 자리다: +#: +#: 콘크리트 개거 150×200 터파기 · 유로폼 · 면목 **세 줄뿐** — **콘크리트 본체 줄이 없다** +#: 콘크리트 L형수로 H=0.2 터파기 · 되메우기 · 잔토 · **콘크리트** · PVC Φ50 · 이형철근 D13 · +#: 거푸집 · 면목 **여덟 줄** +#: +#: ⓘ 같은 것 — **둘 다 m당**이고 거푸집 계열(유로폼·거푸집)과 면목이 있다. +#: ⚠ 개거 표에 콘크리트가 없는 것은 **빠뜨린 것이 아니라 원문 그대로**다. 0 으로 때우거나 +#: L형수로 값을 옮겨 채우지 않는다(2026-09-09 판단 — 돌붙임(메)에서 세 줄을 안 만든 그 자리). +#: +#: ⚠ **규격이 붙박이다** — 정본에 「150×200」 하나뿐이고 `open_ditch` 등록부에 **옵션이 하나도 +#: 없다**(규격 칸 없음). 칸이 생기면 이 표를 열면 된다. +OPEN_DITCH_FORMS: dict[str, dict[str, Any]] = { + "콘크리트 개거 150×200": { + "rows": ( + ("터파기", "㎥", 0.3 * 0.1 * 1.0, "0.3 × 0.1 × 1"), + ("유로폼", "㎡", (0.2 + 0.2 + 0.15) * 1.0, "(0.2 + 0.2 + 0.15) × 1"), + ("면목", "m", 2.0 * 1.0, "2 × 1"), + ), + }, + "콘크리트 L형수로 H=0.2": { + "rows": ( + ("터파기", "㎥", 0.31645, "[{(1.11+1.51)÷2×0.2} + {(0.33×0.33)÷2}] × 1.0"), + ("되메우기", "㎥", 0.129975, "(0.2+0.30)÷2×0.25×1 + (0.2+0.19)÷2×0.33×1.0"), + ("잔토처리", "㎥", 0.186475, "터파기 − 되메우기"), + ( + "콘크리트", + "㎥", + 0.1508, + "{(0.2+0.15)÷2×0.7} + (0.21×0.13)×1 + {(0.15+0.21)÷2×0.20}×1", + ), + ("이형철근 D13", "kg", 0.398, "0.2 × 2 × 0.995"), + ("거푸집", "㎡", 0.738, "0.2 + 0.33 + √(0.2² + 0.06²)"), + ("면목", "m", 1.0, "1"), + ), + # ⚠ 정본에 줄은 있으나 **수량 칸이 비어 있다**(PVC Φ50). 0 으로 만들지 않고 알린다. + "blank_rows": (("PVC 파이프 Φ50㎜", "m"),), + }, +} + + +def open_ditch(length_m: float, options: dict[str, Any]) -> tuple[list[Component], list[str]]: + """개거(겉도랑) 전개 — **m당** 원단위를 연장에 곱한다(소광리 정본 두 탭). + + ⚠ 규격을 고를 칸이 없어 지금은 **「콘크리트 개거 150×200」 붙박이**다. 그 사실을 줄로 낸다. + """ + from B08_Quantity.B08_Quantity_Engine_UnitQuantity import DESTINATION + + if length_m <= 0: + return [], ["연장이 없어 전개하지 않음"] + spec = str(options.get("ditch_spec") or "콘크리트 개거 150×200").strip() + table = OPEN_DITCH_FORMS.get(spec) + if table is None: + return [], [ + f"개거 규격 「{spec}」 원단위가 없습니다 — 있는 것: {' · '.join(OPEN_DITCH_FORMS)}" + ] + + components = [ + Component( + name, + unit, + per_m * length_m, + DESTINATION.get(name, "quantity"), + f"{basis} × 연장 {length_m:g}m (m당 {per_m:g})", + ) + for name, unit, per_m, basis in table["rows"] + ] + notes: list[str] = [] + if "ditch_spec" not in options: + notes.append(f"규격이 「{spec}」 붙박이입니다 — 개거 제원에 규격 칸이 없습니다") + if spec == "콘크리트 개거 150×200": + notes.append( + "⚠ 이 표에는 **콘크리트 본체 줄이 없습니다** — 정본 원문 그대로입니다" + "(터파기·유로폼·면목 세 줄뿐). 지어내지 않았습니다" + ) + for name, unit in table.get("blank_rows", ()): + notes.append(f"{name}({unit}) 은 정본에 줄은 있으나 **수량이 비어 있어** 안 세웠습니다") + return components, notes + + def bed_sill(area_m2: float, options: dict[str, Any]) -> tuple[list[Component], list[str]]: """바닥막이 전개 — **돌붙임 ㎡당** 원단위를 면적에 곱한다(소광리 정본 두 탭). From e100d311471f7a75442d3314fdabb05254ba4635 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 11:30:15 +0900 Subject: [PATCH 20/36] =?UTF-8?q?feat(B08):=20=EA=B7=9C=EC=A4=80=ED=8B=80?= =?UTF-8?q?=20=EC=9E=AC=EB=A3=8C=20=E2=80=94=20=EC=A0=9C=EC=95=88=EA=B0=92?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=84=B8=EC=9A=B0=EA=B3=A0=20=EA=B3=A0?= =?UTF-8?q?=EC=B9=A0=20=EC=88=98=20=EC=9E=88=EA=B2=8C=20(=ED=92=88?= =?UTF-8?q?=EC=85=88=20[=EC=A3=BC]=E2=91=A3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 「재료량은 설계수량에 따른다」는 값을 안 준다는 뜻이지 안 센다는 뜻이 아님. 근주이식·임목파쇄(셀지 말지가 설계 판단)와 성격이 다름 — 확정 ⑨·⑩ 과 같은 틀로 감 - 제안값(울진 소광 §8 관측): 각재 50×50 0.0044㎥ · 판재 T12 0.0029㎥ · 못 0.03㎏/개소 - ⚠ 「실무 관측값이지 법정 기준 아님」과 손율 원문값(비탈 50% · 수평 80%)을 줄 사유와 화면 칸 밑에 함께 적음 — 값만 박으면 「대신 페이지에 남길 것」 지시를 어기는 것 - 개소가 안 서면 재료도 안 섬(밑수가 그 줄) · 자재 축으로 감(할증은 자재총괄에서 한 번) - 설정 `frame_material` + 화면 칸 셋 - tmp/tests/test_b08_frame_material.py 신설(7건) Co-Authored-By: Claude Opus 5 (1M context) --- .../B08_Quantity_Engine_Preparation.py | 70 ++++++++++++++++++- B08_Quantity/B08_Quantity_Router_Earthwork.py | 2 + B08_Quantity/B08_Quantity_Router_Material.py | 15 ++++ B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts | 2 + B08_Quantity/B08_Quantity_UI_Page.ts | 37 ++++++++++ common_util/common_util_project_settings.py | 4 ++ ui_template/ui_template_locale_b2.ts | 8 +++ 7 files changed, 136 insertions(+), 2 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Engine_Preparation.py b/B08_Quantity/B08_Quantity_Engine_Preparation.py index cfe56495..52e665c7 100644 --- a/B08_Quantity/B08_Quantity_Engine_Preparation.py +++ b/B08_Quantity/B08_Quantity_Engine_Preparation.py @@ -324,6 +324,67 @@ def _topsoil_haul_row(topsoil: dict[str, Any], distance_m: float | None) -> dict } +#: 규준틀 재료 — **세는 것은 확정이고 수량만 몰랐던 자리**(2026-09-09). +#: 품셈 11-2·11-3 [주]④ 「재료량은 **설계수량에 따른다**」 ⇒ 품셈이 값을 안 주는 것이지 +#: 「안 센다」가 아니다. 그래서 **제안값을 보이고 사용자가 고치는** 모양으로 둔다 +#: (확정 ⑨·⑩ 과 같은 틀 — 「가는 기본값이고 나 선택처럼 동작할 수 있어야 함」). +#: ⚠ **제안값은 실무 관측값이지 법정 기준이 아니다** — 울진 소광 원단위 라이브러리 §8 +#: 「규준틀 수평 | 개소 | 각재 50×50 0.0044㎥ · 판재 T12 0.0029㎥ · 못 0.03㎏」. +#: ⚠ **비탈 규준틀 값은 그 시트에 없다** — 수평 값을 준용하고 그 사실을 사유에 적는다. +#: ⚠ **손율은 원문에 있다** — 품셈 11-2 [주]③ 비탈 **50%** · 11-3 [주]③ 수평 **80%**. +FRAME_MATERIAL_SUGGESTED = { + "각재 50×50": (0.0044, "㎥"), + "판재 T12": (0.0029, "㎥"), + "못": (0.03, "㎏"), +} +FRAME_MATERIAL_SOURCE = ( + "⚠ 실무 관측값(울진 소광 원단위 라이브러리 §8 규준틀 수평) — **법정 기준 아님**." + " 품셈 11-2·11-3 [주]④ 는 「재료량은 설계수량에 따른다」로만 둠. 산출 조건에서 고칠 수 있음" +) +FRAME_LOSS_RATE = {"비탈 규준틀": 50, "수평 규준틀": 80} + + +def frame_material_rows( + frame_rows: list[dict[str, Any]], overrides: dict[str, Any] | None = None +) -> list[dict[str, Any]]: + """규준틀 재료 — 개소 × 개소당 수량. **자재 축으로 보낸다.** + + ⚠ 개소가 안 서면 재료도 안 선다(밑수가 그 줄이다). + ⚠ 값은 **제안값**이고 산출 조건에서 덮어쓸 수 있다 — 그 사실이 사유에 적힌다. + """ + given = overrides or {} + rows: list[dict[str, Any]] = [] + for frame in frame_rows: + count = frame.get("amount") + if not count: + continue + loss = FRAME_LOSS_RATE.get(str(frame.get("item")), None) + for name, (default, unit) in FRAME_MATERIAL_SUGGESTED.items(): + raw = given.get(name) + try: + per_ea = ( + float(raw) if raw is not None and str(raw).strip() != "" else float(default) + ) + except (TypeError, ValueError): + per_ea = float(default) + picked = "산출 조건에서 고른 값" if raw not in (None, "") else "제안값(기본)" + rows.append( + { + "name": name, + "unit": unit, + "amount": float(count) * per_ea, + "destination": "material", + "source": str(frame.get("item") or "규준틀"), + "basis": ( + f"{frame.get('item')} {float(count):g}개소 × {per_ea:g}{unit}/개소" + f" ({picked}) · {FRAME_MATERIAL_SOURCE}" + + (f" · 손율 {loss}%(품셈 [주]③)" if loss else "") + ), + } + ) + return rows + + def _batter_frame_row(slope_rows: list[dict[str, Any]]) -> dict[str, Any]: """비탈 규준틀 한 줄. **개소는 원문 기준으로 서고 재료는 미확보**다.""" count, notes = batter_frame_count(slope_rows) @@ -333,7 +394,11 @@ def _batter_frame_row(slope_rows: list[dict[str, Any]]) -> dict[str, Any]: "unit": "개소", "amount": float(count) if count else None, "status": STATUS_READY if count else STATUS_PENDING, - "reason": ("; ".join(notes) + " · 재료량은 품셈 11-2 [주]④ 「설계수량에 따른다」라 미확보"), + "reason": ( + "; ".join(notes) + + " · 재료량은 품셈 11-2 [주]④ 「설계수량에 따른다」 —" + + " **제안값(실무 관측)으로 서고 산출 조건에서 고칠 수 있음**" + ), "work_item_code": "FP-11-02", } @@ -349,7 +414,8 @@ def _level_frame_row(slope_rows: list[dict[str, Any]]) -> dict[str, Any]: "status": STATUS_READY if count is not None else STATUS_PENDING, "reason": "; ".join(notes) + ( - " · 재료량은 품셈 11-3 [주]④ 「설계수량에 따른다」라 미확보" + " · 재료량은 품셈 11-3 [주]④ 「설계수량에 따른다」 —" + " **제안값(실무 관측)으로 서고 산출 조건에서 고칠 수 있음**" if count is not None else "" ), diff --git a/B08_Quantity/B08_Quantity_Router_Earthwork.py b/B08_Quantity/B08_Quantity_Router_Earthwork.py index 2ebb4d30..7cf37ca5 100644 --- a/B08_Quantity/B08_Quantity_Router_Earthwork.py +++ b/B08_Quantity/B08_Quantity_Router_Earthwork.py @@ -324,6 +324,8 @@ class QuantitySettingsBody(BaseModel): topsoil_haul_distance_m: float | None = None # 임목축적 등급 — "소림"·"중림"·"밀림"(품셈 9-21 [주]①). `""` 는 「안 정함」이다. stand_volume_class: str | None = None + # 규준틀 개소당 재료 — `{자재명: 수량}`. 비우면 제안값(실무 관측)이 선다. + frame_material: dict[str, Any] | None = None #: `None` 이 「안 정함」을 뜻하는 칸 — 저장에서 **버리지 않고 그대로 덮어쓴다**. diff --git a/B08_Quantity/B08_Quantity_Router_Material.py b/B08_Quantity/B08_Quantity_Router_Material.py index d9e2cd49..f5c55b21 100644 --- a/B08_Quantity/B08_Quantity_Router_Material.py +++ b/B08_Quantity/B08_Quantity_Router_Material.py @@ -43,6 +43,7 @@ from common_util.common_util_project_settings import ( rock_method, ) from B08_Quantity.B08_Quantity_Engine_HaulInputs import haul_inputs +from B08_Quantity.B08_Quantity_Engine_Preparation import frame_material_rows from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_structure_lengths import structure_lengths from config.config_db import run_with_connection @@ -251,6 +252,20 @@ async def get_handoff(project_id: UUID) -> JSONResponse: # 토공·운반 표는 토적표 라우터의 것을 그대로 쓴다 — 여기서 다시 만들지 않는다. earthwork = await _earthwork_tables(project_id) + # 규준틀 재료 — **개소가 선 뒤에야 설 수 있어** 준비공 표를 받은 다음 자재 축에 얹는다. + # ⚠ 값은 **제안값(실무 관측)**이고 산출 조건에서 고칠 수 있다 — 그 사실이 줄 사유에 적힌다. + frame_rows = [ + row + for row in ((earthwork.get("preparation") or {}).get("rows") or []) + if str(row.get("item") or "").endswith("규준틀") + ] + material_table = build_material_table( + unit_table, + supply_map=settings.get("material_supply") or {}, + concrete_placing_method=settings.get("concrete_placing_method"), + extra_materials=frame_material_rows(frame_rows, settings.get("frame_material") or {}), + ) + handoff = build_handoff( summary_table=earthwork.get("summary"), haul_table=earthwork.get("haul"), diff --git a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts index 681fb0b4..7eb74241 100644 --- a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts +++ b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts @@ -81,6 +81,8 @@ export interface QuantitySettings { ancillary_counts?: Record; /** 임목축적 등급 — `"소림"`·`"중림"`·`"밀림"`(품셈 9-21 [주]①). 본수가 아니라 축적이다. */ stand_volume_class?: string | null; + /** 규준틀 개소당 재료 — `{자재명: 수량}`. 비우면 제안값(실무 관측)이 선다. */ + frame_material?: Record; } export interface EarthworkTable { diff --git a/B08_Quantity/B08_Quantity_UI_Page.ts b/B08_Quantity/B08_Quantity_UI_Page.ts index 72fb1ada..d710a543 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.ts +++ b/B08_Quantity/B08_Quantity_UI_Page.ts @@ -93,6 +93,7 @@ async function saveQuantitySettings(projectId: string, draft: DraftSettings): Pr structure_trench_water: draft.structure_trench_water, topsoil_haul_distance_m: draft.topsoil_haul_distance_m, stand_volume_class: draft.stand_volume_class, + frame_material: draft.frame_material, // 개소는 **통째로** 보낸다 — 지운 항목까지 그대로 가야 되돌릴 길이 있다. ancillary_counts: draft.ancillary_counts, }), @@ -114,6 +115,19 @@ function field(label: string, value: string): HTMLElement { return row; } +/** 규준틀 재료 칸 — 이름은 **서버 `FRAME_MATERIAL_SUGGESTED` 와 같은 낱말**이라야 한다. */ +const FRAME_MATERIAL_FIELDS: ReadonlyArray = [ + ["각재 50×50", "B08_Quantity_Frame_Square", "0.0001"], + ["판재 T12", "B08_Quantity_Frame_Board", "0.0001"], + ["못", "B08_Quantity_Frame_Nail", "0.01"], +]; +/** 채워 보이는 **제안값**(실무 관측). ⚠ 법정 기준이 아니라 「고치라고 보이는 값」이다. */ +const FRAME_MATERIAL_SUGGESTED: Record = { + "각재 50×50": 0.0044, + "판재 T12": 0.0029, + 못: 0.03, +}; + /** 부대시설 항목 키 — 서버 `ANCILLARY_ITEMS` 와 **같은 차례·같은 낱말**이라야 한다. */ const ANCILLARY_KEYS = [ "national_point_sign", @@ -268,6 +282,8 @@ interface DraftSettings { ancillary_counts: Record; // 임목축적 등급 — "소림"·"중림"·"밀림". ⚠ 본수가 아니라 축적이다(품셈 9-21 [주]①). stand_volume_class: string; + // 규준틀 개소당 재료 — 비우면 **제안값(실무 관측)**이 선다. 값이 아니라 「고칠 수 있음」이 요점. + frame_material: Record; // 자재별 관급/사급 — 표 안에서 줄마다 고른 값. material_supply: Record; dirty: boolean; @@ -552,6 +568,26 @@ function buildQuantitySidePanel( } panel.append(hintRow(L("B08_Quantity_Side_Ancillary_Hint"))); + // ── 규준틀 재료 — ⚠ **세는 것은 확정이고 수량만 모르던 자리**(품셈 [주]④ 「설계수량에 따른다」). + // 그래서 **제안값을 채워 보이고 고칠 수 있게** 둔다(확정 ⑨·⑩ 과 같은 틀). + // ⚠ 값만 박고 근거를 안 보이면 사용자 지시(「대신 페이지에 남길 것」)를 어기는 것이라 + // 칸 밑에 **관측값임**과 **손율 원문값**을 함께 적는다. + panel.append(field(L("B08_Quantity_Side_Frame"), "")); + for (const [key, label, step] of FRAME_MATERIAL_FIELDS) { + panel.append( + optionalNumberField( + L(label as keyof typeof ui_locales), + draft.frame_material[key] ?? FRAME_MATERIAL_SUGGESTED[key], + step, + (value) => { + draft.frame_material[key] = value; + draft.dirty = true; + }, + ), + ); + } + panel.append(hintRow(L("B08_Quantity_Side_Frame_Hint"))); + // ── 콘크리트 타설 방식 — ⚠ **금액에 바로 걸리는 값**이라 잠정임을 조용히 두지 않는다 ── panel.append(field(L("B08_Quantity_Side_Placing"), "")); panel.append( @@ -809,6 +845,7 @@ export async function renderB08Quantity(root: HTMLElement): Promise { structure_trench_water: (stored.structure_trench_water as string) ?? "", topsoil_haul_distance_m: (stored.topsoil_haul_distance_m as number | null) ?? null, stand_volume_class: (stored.stand_volume_class as string) ?? "", + frame_material: { ...((stored.frame_material ?? {}) as Record) }, ancillary_counts: { ...((stored.ancillary_counts ?? {}) as Record), }, diff --git a/common_util/common_util_project_settings.py b/common_util/common_util_project_settings.py index 3503cb28..805a72fe 100644 --- a/common_util/common_util_project_settings.py +++ b/common_util/common_util_project_settings.py @@ -135,6 +135,10 @@ def default_settings() -> dict[str, Any]: # [주]① 「소림 30~60㎥/㏊ · 중림 60~90 · 밀림 90 이상」 — 본수가 아니라 **축적**이다. # ⚠ 산림조사부·영림계획에서 옮겨 적는 값이라 프로그램이 만들 수 없다. 비면 막는다. "stand_volume_class": None, + # 규준틀 개소당 재료 — `{자재명: 개소당 수량}`. **비우면 제안값(실무 관측)이 선다.** + # 품셈 11-2·11-3 [주]④ 가 「재료량은 설계수량에 따른다」라 값을 안 주는 자리이고, + # 세는 것 자체는 확정이라 **제안값 + 고칠 수 있게**로 둔다(확정 ⑨·⑩ 과 같은 틀). + "frame_material": {}, "dataset_versions": {}, }, "estimation": { diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index ea2bb4c8..68a5d0a5 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -683,6 +683,14 @@ export const ui_locales_b2 = { "제근(품셈 9-21)이 이 등급으로 갈립니다 — ⚠ 본수가 아니라 축적이고, 산림조사부·영림계획에서 옮겨 적는 값입니다", "Root removal (9-21) splits by this class — stand volume, not tree count; copied from the forest survey", ], + B08_Quantity_Side_Frame: ["규준틀 개소당 재료", "Batter Board Materials (per unit)"], + B08_Quantity_Side_Frame_Hint: [ + "⚠ 채워진 값은 실무 관측값(제안값)이고 법정 기준이 아닙니다 — 품셈 11-2·11-3 [주]④ 는 「재료량은 설계수량에 따른다」로만 둡니다. 손율은 원문값(비탈 50% · 수평 80%)", + "Pre-filled values are field observations, not a legal standard — the standard only says “per design quantity”", + ], + B08_Quantity_Frame_Square: ["각재 50×50 (㎥/개소)", "Square timber (㎥/ea)"], + B08_Quantity_Frame_Board: ["판재 T12 (㎥/개소)", "Board T12 (㎥/ea)"], + B08_Quantity_Frame_Nail: ["못 (㎏/개소)", "Nails (㎏/ea)"], B08_Quantity_Side_Ancillary: ["부대시설 개소", "Ancillary Facilities"], B08_Quantity_Side_Ancillary_Hint: [ "개소를 넣어야 줄이 섭니다 — 원문이 배치 간격을 정하지 않아 연장÷500 같은 산식을 쓰지 않습니다(확정 13)", From 24a734cdb219fe56e0b258b7427374dc73b80b8c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 11:32:51 +0900 Subject: [PATCH 21/36] auto: 2026-09-09 11:32 (ESD_LAPTOP) --- .../B08_Quantity_Engine_UnitQuantity.py | 9 ++++ ..._Quantity_Engine_UnitQuantity_Revetment.py | 50 ++++++++++++++++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py index d3932054..9280374b 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -944,6 +944,8 @@ EXPANDERS = { "erosion_check": lambda h, l, o, f=None, r="": _stone_kin()("erosion_check"), # 개거는 **m당** 원단위라 연장이 밑수다(정본 「개거(150-200)」·「L형수로-(201)」). "open_ditch": lambda h, l, o, f=None, r="": _open_ditch()(l, o), + # 흙막이는 **아직 못 세움** — 정본에 떼흙막이 하나뿐인데 우리 형식 칸에 「떼」가 없음. + "soil_guard": lambda h, l, o, f=None, r="": _soil_guard()(o), # 바닥막이는 **돌붙임 ㎡당**이라 높이·연장이 아니라 **면적**이 밑수다(정본 「돌붙임L3=…」). "bed_sill": lambda h, l, o, f=None, r="": _bed_sill()(_num(o.get("area_m2"), 0.0), o), # ⚠⚠ **큰돌쌓기(`boulder_masonry`)를 여기에 두지 않는다** (2026-09-07 발견). @@ -956,6 +958,13 @@ EXPANDERS = { } +def _soil_guard(): + """흙막이 사유 함수를 늦게 가져온다.""" + from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import soil_guard + + return soil_guard + + def _open_ditch(): """개거 전개 함수를 늦게 가져온다.""" from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import open_ditch diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py index cd425949..23c0ff91 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py @@ -164,6 +164,17 @@ def stone_kin_withheld(type_id: str) -> tuple[list[Component], list[str]]: #: ⚠ 개거 표에 콘크리트가 없는 것은 **빠뜨린 것이 아니라 원문 그대로**다. 0 으로 때우거나 #: L형수로 값을 옮겨 채우지 않는다(2026-09-09 판단 — 돌붙임(메)에서 세 줄을 안 만든 그 자리). #: +#: ⓘ **「다른 탭에 본체가 있나」를 찾아봤고 없었다**(2026-09-09 53탭 전수): +#: · 「개거」 글자가 나오는 탭은 **둘뿐** — 이 탭과 「콘크리트개거」. +#: 뒤엣것은 **제목만 「콘크리트개거 (물넘이형)」이고 내용이 국가지점번호판 표**다. +#: · 탭 이름의 「(2)」는 **짝 번호가 아니라 엑셀 사본 표식**이다 — `Φ1000` ↔ `Φ1000(2)`, +#: `떼수로(윤주1.08)` ↔ `…(2)` 가 **내용까지 똑같다**(대조 확인). 「(1)」은 없다. +#: · 같은 시트의 「측구수로400/500」도 제목이 **「터파기 계산서」**이고 본체가 없다 — +#: **한 구조물을 부분 계산서로 쪼개는 버릇**이 있는 시트다. +#: ⇒ 그러므로 이 표는 **부분 계산서일 가능성이 남아 있다.** 다만 이 원본 안에서는 +#: 본체 표를 못 찾았으므로 **「원문에 없음」으로 두고 그 사실을 화면에 적는다** — +#: 「콘크리트 개거인데 콘크리트가 0」이 우리 결함으로 읽히지 않게. +#: #: ⚠ **규격이 붙박이다** — 정본에 「150×200」 하나뿐이고 `open_ditch` 등록부에 **옵션이 하나도 #: 없다**(규격 칸 없음). 칸이 생기면 이 표를 열면 된다. OPEN_DITCH_FORMS: dict[str, dict[str, Any]] = { @@ -195,6 +206,40 @@ OPEN_DITCH_FORMS: dict[str, dict[str, Any]] = { } +#: 떼흙막이 원단위 — 소광리 정본 「떼흙막이」, **개소당**. 치수는 전부 **평균 붙박이**다 +#: (상단 1.5 · 하단 1.1 · 높이 0.5 · 두께 0.2 · 기슭 0.54 — 원문에 「(평균)」이라 적혀 있다). +#: ⚠⚠ **아직 못 쓴다** — 우리 `soil_guard` 형식이 「콘크리트 · 돌(찰) · 돌(메) · 블록쌓기 · +#: 돌망태」 다섯이고 **「떼」가 없다.** 형식 칸이 생기면 이 표를 그대로 붙이면 된다. +#: ⚠ 나머지 다섯 형식은 **정본에 탭이 없다.** 돌(찰)/(메)에 기슭막이 식을 빌려 쓰지 않는다 — +#: 흙막이는 교본상 앞면 1:0.3·뒷면 수직·천단 30㎝ 라 축이 다르고, 무엇보다 **근거가 없다.** +SOIL_GUARD_SOD: dict[str, Any] = { + "unit_label": "개소당", + "rows": ( + ( + "떼", + "㎡", + 1.39, + "머리떼 1.5×0.2 + 바닥떼 1.1×0.2 + 정면떼 (1.5+1.1)÷2×0.5 " + "+ 양기슭바닥떼 0.54×0.2×2 · 규격 20×20㎝", + ), + ("바닥파기", "㎥", 0.17, "(0.54×0.4×0.2×2) + (1.1×0.4×0.2)"), + ), +} + + +def soil_guard(options: dict[str, Any]) -> tuple[list[Component], list[str]]: + """흙막이 — **아직 못 세운다.** 어느 형식이 왜 안 되는지만 낸다.""" + form = str(options.get("form") or "").strip() + if not form: + return [], ["흙막이 형식이 정해지지 않았습니다 — 형식을 골라야 물량이 섭니다"] + return [], [ + f"흙막이 형태 「{form}」 — 정본(소광리 53탭)에 그 형식의 산출식이 없습니다. " + "있는 것은 **떼흙막이** 하나인데 우리 형식 칸에 「떼」가 없습니다. " + "⚠ 돌(찰)·돌(메)에 기슭막이 식을 빌려 쓰지 않습니다 — 흙막이는 앞면 1:0.3·뒷면 수직·" + "천단 30㎝(교본)라 축이 다르고 근거가 없습니다" + ] + + def open_ditch(length_m: float, options: dict[str, Any]) -> tuple[list[Component], list[str]]: """개거(겉도랑) 전개 — **m당** 원단위를 연장에 곱한다(소광리 정본 두 탭). @@ -227,7 +272,10 @@ def open_ditch(length_m: float, options: dict[str, Any]) -> tuple[list[Component if spec == "콘크리트 개거 150×200": notes.append( "⚠ 이 표에는 **콘크리트 본체 줄이 없습니다** — 정본 원문 그대로입니다" - "(터파기·유로폼·면목 세 줄뿐). 지어내지 않았습니다" + "(터파기·유로폼·면목 세 줄뿐). 같은 원본 53탭에서 본체 표를 찾았으나 없었고" + "(「콘크리트개거」 탭은 제목만 그 이름이고 내용이 다른 표), 이 시트가" + "「측구수로 터파기 계산서」처럼 **부분 계산서를 쓰는 버릇**이 있어" + "**다른 문서에 본체가 있을 수 있습니다.** 지어내지 않았습니다" ) for name, unit in table.get("blank_rows", ()): notes.append(f"{name}({unit}) 은 정본에 줄은 있으나 **수량이 비어 있어** 안 세웠습니다") From 4060f4e3d28291b2577664045e72b5ba48d55726 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 11:40:29 +0900 Subject: [PATCH 22/36] =?UTF-8?q?fix(B09):=20=EB=B0=91=EC=88=98=20?= =?UTF-8?q?=E3=80=8C=EC=9D=B8=E3=80=8D=EC=9D=84=20=EB=B0=9B=EB=8A=94=20?= =?UTF-8?q?=EC=AA=BD=EC=97=90=EC=84=9C=20=EB=A7=89=EC=9D=8C=20+=20?= =?UTF-8?q?=EA=B7=9C=EC=A4=80=ED=8B=80=20=EC=82=AC=EC=9C=A0=20=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ① 마스터가 본문·표에서 밑수를 「인」으로 읽어 온 자리가 여섯 있음(드론방제 둘·지상방제· 야면석 채집·휘발유·천공기). 「인」은 **사람 수**이지 공종 단위가 아니라, 그대로 두면 「몇 인짜리 공종」이 되어 **단위 불일치 검사가 엉뚱하게 통과**함. ⚠ 원문 쪽 고치기는 마스터 몫(데스크탑 메인) — 여기서는 **받는 쪽에서 막음**. 같은 규칙이 `WorkItemUnit` 머리말에 이미 있어 짝을 맞춤 ② 규준틀 사유 갱신 — 데스크탑 메인이 각재·판재·못을 자재 축에 세워(`e100d311`) 「인력 품만」의 뜻이 바뀜. 재료가 오되 관급·사급이 안 갈려 아직 금액 0 임을 적음 ⚠ 계획서 9장 재판정(미체크 27 → 21 · 크기 딱지)은 `docs/` 가 git 밖이라 커밋에 안 실림 — 파일에는 반영돼 있음. 검증: pytest 300 통과 · 밑수가 「인」인 자원 줄 0 Co-Authored-By: Claude Opus 5 (1M context) --- B09_Estimation/B09_Estimation_KnownGaps.py | 10 ++++++---- B09_Estimation/B09_Estimation_ResourceAxis.py | 11 +++++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/B09_Estimation/B09_Estimation_KnownGaps.py b/B09_Estimation/B09_Estimation_KnownGaps.py index ac0f36b2..b0838fe9 100644 --- a/B09_Estimation/B09_Estimation_KnownGaps.py +++ b/B09_Estimation/B09_Estimation_KnownGaps.py @@ -26,13 +26,15 @@ KNOWN_GAPS: dict[str, tuple[str, str]] = { # 잘려 안 보였던 것이다. 잘린 자리를 사유가 없는 자리로 읽지 말 것.) "FP-11-02": ( "인력 품만", - "ⓘ 지금 값은 **인력 품만**입니다. 재료가 서면 손율이 함께 걸립니다 — " - "품셈 11-2 [주]③ 「목재의 손율은 1개소 사용당 50%」.", + "ⓘ 이 단가는 **인력 품만**입니다 — 목재·못은 **자재 축**으로 따로 갑니다" + "(2026-09-09 각재·판재·못이 자재 축에 섰음. 다만 관급·사급이 안 갈려 아직 금액 0). " + "재료가 금액으로 서면 손율이 함께 걸립니다 — 품셈 [주]③ 「목재의 손율은 1개소 사용당 50%」.", ), "FP-11-03": ( "인력 품만", - "ⓘ 지금 값은 **인력 품만**입니다. 재료가 서면 손율이 함께 걸립니다 — " - "품셈 11-3 [주]③ 「목재의 손율은 1개소 사용당 80%」.", + "ⓘ 이 단가는 **인력 품만**입니다 — 목재·못은 **자재 축**으로 따로 갑니다" + "(2026-09-09 각재·판재·못이 자재 축에 섰음. 다만 관급·사급이 안 갈려 아직 금액 0). " + "재료가 금액으로 서면 손율이 함께 걸립니다 — 품셈 [주]③ 「목재의 손율은 1개소 사용당 80%」.", ), "FP-12-25": ( "운반거리 미정", diff --git a/B09_Estimation/B09_Estimation_ResourceAxis.py b/B09_Estimation/B09_Estimation_ResourceAxis.py index ac9add90..7af12c3c 100644 --- a/B09_Estimation/B09_Estimation_ResourceAxis.py +++ b/B09_Estimation/B09_Estimation_ResourceAxis.py @@ -350,6 +350,10 @@ def _resolve_cell(catalog: ResourceCatalog, name_cell: str, cells: list[str]): return None +#: 공종 단위로 인정하지 않는 말 — **품의 단위**(사람·날)이지 물리 수량이 아니다. +_NOT_A_WORK_ITEM_UNIT = frozenset({"인", "인당", "일", "일당", "인/일"}) + + def match_table( node: dict[str, Any], table: dict[str, Any], @@ -395,6 +399,13 @@ def match_table( basis = table.get("basis_quantity") basis_quantity = None if basis is None else Decimal(str(basis)) unit = table.get("basis_unit") or "" + # ⚠ **「인」은 공종 단위가 아니다** — 사람 수다. 마스터가 본문·표에서 「인」을 밑수로 + # 읽어 온 자리가 있고(드론방제·지상방제·야면석 채집·휘발유), 그대로 두면 「몇 인짜리 + # 공종」이라는 뜻이 되어 **단위 불일치 검사가 엉뚱하게 통과**한다. + # ⚠ 원문 고치기는 마스터 쪽 몫(2026-09-09 데스크탑 메인) — 여기서는 **받는 쪽에서 막는다.** + # 짝 규칙은 `B09_Estimation_WorkItemUnit` 머리말에 이미 적어 둔 것과 같다. + if unit.strip() in _NOT_A_WORK_ITEM_UNIT: + unit = "" # ⚠ **한 표에 밑수가 둘인 표가 있다** — 「㎡당 0.17 / ㎥당 0.64」(채집 13-2 계열). # 행-자원으로 읽으면 **앞줄만 잡고 뒷줄을 버린다** — 막돌 채집이 ㎡당 값을 ㎥ 단위로 From 4fc5b38d61bb83ff1cb485c12d7457bb1f68cc0c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 11:46:58 +0900 Subject: [PATCH 23/36] =?UTF-8?q?fix(B08):=20=EB=A7=88=EC=8A=A4=ED=84=B0?= =?UTF-8?q?=20=EB=B0=91=EC=88=98=20=ED=8C=90=EB=8F=85=20=EB=91=98=20?= =?UTF-8?q?=E2=80=94=20=E3=80=8C=EC=9D=B8=E3=80=8D=EC=9D=80=20=EC=88=AB?= =?UTF-8?q?=EC=9E=90=EA=B0=80=20=EB=B6=99=EC=9D=84=20=EB=95=8C=EB=A7=8C=20?= =?UTF-8?q?=C2=B7=20=EC=B9=B8=EC=9D=84=20=EC=9D=B4=EC=96=B4=20=EB=B6=99?= =?UTF-8?q?=EC=97=AC=20=EC=9D=BD=EC=A7=80=20=EC=95=8A=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「인」 오독 넷(원문 대조) - 8-6-2 드론방제·8-6-3 지상방제 「(단위 : 인)」은 **소요인력**이지 공종 밑수가 아님 - 13-2-4 야면석 채집 「(단위: 인 당)」은 표 안이 ㎡당·㎥당 두 줄이라 하나로 못 정함 - ⚠ 넓게 「인」을 버리면 2-2-5 천공기가 사라짐(「천공인부 1인당 1대」) — 숫자가 붙었나로 좁게 가름 칸 이어붙임 — 앞 칸 값 + 뒤 칸 단위로 **원문에 없는 밑수**가 서던 자리 - 13-2-4 「0.36 ㎥당」(0.36 은 뒷길이 60 의 ㎡당 값) - 5-19-2 「30 ㎥당」(30 은 표토두께 ㎝) - ⇒ detect_basis 가 **칸 하나 안에서만** 봄. 「100㎥당」처럼 한 칸에 다 있는 것은 그대로 - 마스터 재생성: 밑수 확보 182 → 180 (없는 값 둘이 빠지고 넷이 미확보로 정직해짐) - tmp/tests/test_b08_master_basis_person.py 신설(5건) Co-Authored-By: Claude Opus 5 (1M context) --- .../B08_Quantity_Build_WorkItemMaster.py | 51 ++++++++++++++++--- .../data_work_item_master/_manifest.json | 10 ++-- .../basis_missing_2026-01-01.json | 30 +++++++++++ .../work_item_master_2026-01-01.json | 38 +++++++------- 4 files changed, 97 insertions(+), 32 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Build_WorkItemMaster.py b/B08_Quantity/B08_Quantity_Build_WorkItemMaster.py index ba5cb8d7..525b1d64 100644 --- a/B08_Quantity/B08_Quantity_Build_WorkItemMaster.py +++ b/B08_Quantity/B08_Quantity_Build_WorkItemMaster.py @@ -301,6 +301,22 @@ SOURCE_BASIS_NOTE_RE = re.compile(r"([\d,]*\.?\d*)\s*개소\s*사용\s*당") SOURCE_NOTE_LOOKAHEAD = 8 +#: ⚠⚠ **「인」은 품의 단위이지 공종 밑수가 아니다**(2026-09-09 원문 대조로 넷이 오독으로 드러남). +#: 8-6-2 드론방제·8-6-3 지상방제 「(단위 : 인)」 — 그 「인」은 **소요인력**이고 공종 밑수는 +#: ha 다. 13-2-4 야면석 채집 「(단위: 인 당)」 — 표 안이 **㎡당·㎥당** 두 줄이다. +#: ⇒ **원문에 「<숫자>인당」이 그대로 있을 때만** 밑수로 인정한다. +#: ⚠ **넓게 「인」을 통째로 버리면 2-2-5 천공기가 사라진다** — 그 표는 셀 안에 「천공인부 +#: **1인당** 1대」로 숫자가 붙어 있다. 그래서 「숫자가 붙었나」로 좁게 가른다. +PERSON_UNITS = {"인", "공"} + + +def person_basis_ok(raw_quantity: str | None, unit: str | None) -> bool: + """「인」 계열 밑수를 인정할 것인가 — **숫자가 붙어 있을 때만** 참.""" + if unit not in PERSON_UNITS: + return True + return bool((raw_quantity or "").strip()) + + def basis_from_source(lines: list[str], line_no: int) -> tuple[float | None, str | None]: """표 바로 위 본문에서 밑수를 읽는다. 못 찾으면 `(None, None)` — 1 로 단정하지 않는다. @@ -315,6 +331,8 @@ def basis_from_source(lines: list[str], line_no: int) -> tuple[float | None, str break # 앞 표에 닿았다 — 그 위는 남의 밑수다 if m := SOURCE_BASIS_RATIO_RE.search(text): raw = (m.group(1) or "").replace(",", "") + if not person_basis_ok(raw, m.group(2)): + continue # 「인」에 숫자가 안 붙었다 — 품의 단위이지 밑수가 아니다 try: quantity = float(raw) if raw else 1.0 except ValueError: @@ -324,6 +342,8 @@ def basis_from_source(lines: list[str], line_no: int) -> tuple[float | None, str unit = m.group("u1") or m.group("u2") raw = (m.group(1) if m.group("u1") else m.group(3)) or "" raw = raw.replace(",", "") + if not person_basis_ok(raw, unit): + continue # 위와 같음 — 「(단위 : 인)」·「(단위: 인 당)」은 밑수가 아니다 try: quantity = float(raw) if raw else 1.0 except ValueError: @@ -347,14 +367,29 @@ def basis_from_source(lines: list[str], line_no: int) -> tuple[float | None, str def detect_basis(table: dict[str, Any]) -> tuple[float | None, str | None]: - """「100㎥당」 같은 밑수. 없으면 `(None, None)` — 단위당 1 로 단정하지 않는다.""" - hay = " ".join(norm(h) for h in table.get("headers", [])) - hay += " " + " ".join(norm(c) for r in table.get("rows", [])[:2] for c in r) - if m := BASIS_RE.search(hay): - try: - return float(m.group(1).replace(",", "")), m.group(2) - except ValueError: - return None, m.group(2) + """「100㎥당」 같은 밑수. 없으면 `(None, None)` — 단위당 1 로 단정하지 않는다. + + ⚠⚠ **칸 하나 안에서만 본다**(2026-09-09). 여러 칸을 이어 붙여 보면 **앞 칸의 값**과 + **뒤 칸의 단위**가 붙어 없는 밑수가 생긴다 — 13-2-4 야면석 채집이 그랬다: + 행 ① 인 부 | ㎡당 | 0.11 | 0.17 | 0.22 | 0.28 | **0.36** + 행 ② **㎥당** | 0.60 | … + 이어 붙이면 「0.36 ㎥당」이 되어 **밑수 0.36㎥** 라는 값이 선다(원문에 없는 값). + ⚠ 그 표는 **㎡당·㎥당 두 줄**이라 애초에 하나로 못 정한다 — 「미확보」가 정직하다. + ⚠ **「인」은 여기서도 숫자가 붙어야 인정한다** — `BASIS_RE` 가 숫자를 요구하므로 + 2-2-5 「천공인부 **1인당** 1대」는 그대로 서고 「(단위 : 인)」은 안 선다. + """ + cells = [norm(h) for h in table.get("headers", []) or []] + cells += [norm(c) for r in (table.get("rows", []) or [])[:2] for c in r] + for cell in cells: + if not cell: + continue + if m := BASIS_RE.search(cell): + if not person_basis_ok(m.group(1), m.group(2)): + continue + try: + return float(m.group(1).replace(",", "")), m.group(2) + except ValueError: + return None, m.group(2) return None, None diff --git a/resources/data_work_item_master/_manifest.json b/resources/data_work_item_master/_manifest.json index 2abebbdb..9be25944 100644 --- a/resources/data_work_item_master/_manifest.json +++ b/resources/data_work_item_master/_manifest.json @@ -1,7 +1,7 @@ { "schema_version": "1.0", "dataset_id": "data_work_item_master_manifest", - "generated_at": "2026-09-08T08:33:10+09:00", + "generated_at": "2026-09-09T11:44:28+09:00", "built_by": "B08_Quantity/B08_Quantity_Build_WorkItemMaster.py", "source": { "dataset_id": "pum_forest", @@ -12,8 +12,8 @@ "files": [ { "file": "work_item_master_2026-01-01.json", - "sha256": "3752af1e0328a2faf4947268e449e0bc602a0738cc6a29a78ed71d343cf2e583", - "size_bytes": 856832 + "sha256": "3462899767156784158f724513c793052711806260e60db59d3e2a5fbf1fab21", + "size_bytes": 856810 }, { "file": "form_undetermined_2026-01-01.json", @@ -22,8 +22,8 @@ }, { "file": "basis_missing_2026-01-01.json", - "sha256": "244851609c65ba8adbfc318668224cbf3a81032e9271cfda916de435d460dd92", - "size_bytes": 18360 + "sha256": "644b179f7d4b57641fc4ea6df27ef3e3904a03a0bf49c6a4a8bcf44f1195bdb1", + "size_bytes": 19018 } ] } \ No newline at end of file diff --git a/resources/data_work_item_master/basis_missing_2026-01-01.json b/resources/data_work_item_master/basis_missing_2026-01-01.json index a15cf0d1..2627bc66 100644 --- a/resources/data_work_item_master/basis_missing_2026-01-01.json +++ b/resources/data_work_item_master/basis_missing_2026-01-01.json @@ -280,6 +280,12 @@ "pum_form": "requirement", "line": 2653 }, + { + "pum_table_id": "F0127", + "section": "5-19-2. 표토펴기 및 고르기", + "pum_form": "requirement", + "line": 2664 + }, { "pum_table_id": "F0129", "section": "5-21. 표토이식", @@ -586,6 +592,24 @@ "pum_form": "requirement", "line": 4434 }, + { + "pum_table_id": "F0228", + "section": "8-6-2. 드론방제", + "pum_form": "requirement", + "line": 4516 + }, + { + "pum_table_id": "F0229", + "section": "8-6-2. 드론방제", + "pum_form": "requirement", + "line": 4536 + }, + { + "pum_table_id": "F0230", + "section": "8-6-3. 지상방제", + "pum_form": "requirement", + "line": 4557 + }, { "pum_table_id": "F0232", "section": "8-7. 방제 실행 등록", @@ -754,6 +778,12 @@ "pum_form": "requirement", "line": 6943 }, + { + "pum_table_id": "F0401", + "section": "13-2-4. 야면석 채집(인력)", + "pum_form": "requirement", + "line": 7025 + }, { "pum_table_id": "F0405", "section": "13-3. 기초다짐 및 뒤채움’ 항을 적용한다.", diff --git a/resources/data_work_item_master/work_item_master_2026-01-01.json b/resources/data_work_item_master/work_item_master_2026-01-01.json index 44658daf..bb11ffc2 100644 --- a/resources/data_work_item_master/work_item_master_2026-01-01.json +++ b/resources/data_work_item_master/work_item_master_2026-01-01.json @@ -2,7 +2,7 @@ "schema_version": "1.0", "dataset_id": "work_item_master_forest", "effective_date": "2026-01-01", - "generated_at": "2026-09-08T08:33:10+09:00", + "generated_at": "2026-09-09T11:44:28+09:00", "dataset_version": { "dataset_id": "pum_forest", "effective_date": "2026-01-01", @@ -21,9 +21,9 @@ "tables_attached": 456, "tables_orphan": 19, "form_undetermined": 77, - "basis_found": 185, - "basis_missing": 135, - "basis_grouped": 36 + "basis_found": 180, + "basis_missing": 140, + "basis_grouped": 35 }, "orphan_tables": [ { @@ -15789,9 +15789,9 @@ "source_line": 2664, "pum_form": "requirement", "form_basis": "직종 표기((인)·인부·공)", - "basis_quantity": 30.0, - "basis_unit": "㎥", - "basis_source": "표 안", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, "resource_shares": {}, "partial_ratio": false, "expression_cells": [], @@ -26997,9 +26997,9 @@ "source_line": 4516, "pum_form": "requirement", "form_basis": "직종 표기((인)·인부·공)", - "basis_quantity": 1.0, - "basis_unit": "인", - "basis_source": "본문", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, "resource_shares": {}, "partial_ratio": false, "expression_cells": [], @@ -27087,9 +27087,9 @@ "source_line": 4536, "pum_form": "requirement", "form_basis": "직종 표기((인)·인부·공)", - "basis_quantity": 1.0, - "basis_unit": "인", - "basis_source": "본문", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, "resource_shares": {}, "partial_ratio": false, "expression_cells": [], @@ -27187,9 +27187,9 @@ "source_line": 4557, "pum_form": "requirement", "form_basis": "직종 표기((인)·인부·공)", - "basis_quantity": 1.0, - "basis_unit": "인", - "basis_source": "본문", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, "resource_shares": {}, "partial_ratio": false, "expression_cells": [], @@ -39551,9 +39551,9 @@ "source_line": 7025, "pum_form": "requirement", "form_basis": "직종 표기((인)·인부·공)", - "basis_quantity": 1.0, - "basis_unit": "인", - "basis_source": "본문", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, "resource_shares": {}, "partial_ratio": false, "expression_cells": [], From 3af2bc2b6bb0a45dd732761916a613e61b6c2155 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 11:47:04 +0900 Subject: [PATCH 24/36] auto: 2026-09-09 11:47 (EOMSANGDON-HOME) --- main.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/main.py b/main.py index b3f96e6f..a3967f44 100644 --- a/main.py +++ b/main.py @@ -50,6 +50,7 @@ from B05_Profile.B05_Profile_Router_Lifecycle import router as b05_route_lifecyc from B05_Profile.B05_Profile_Router_Replan import router as b05_route_replan_router from B05_Profile.B05_Profile_Structures_Router import router as b05_structures_router from B06_Section.B06_Section_Router import router as b06_section_router +from B06_Section.B06_Section_Router_Stations import router as b06_section_stations_router from B06_Section.B06_Section_Router_Confirm import ( router as b06_section_confirm_router, ) @@ -538,6 +539,7 @@ app.include_router(b05_corridor_router, dependencies=protected_with_company) app.include_router(b05_route_replan_router, dependencies=protected_with_company) app.include_router(b05_structures_router, dependencies=protected_with_company) app.include_router(b06_section_router, dependencies=protected_with_company) +app.include_router(b06_section_stations_router, dependencies=protected_with_company) app.include_router(b06_section_confirm_router, dependencies=protected_with_company) app.include_router(b06_section_haul_plan_router, dependencies=protected_with_company) app.include_router(b07_design_router, dependencies=protected_with_company) From cbadb7fd93565d8a69b9a9902cd5e91fc577f679 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 11:56:57 +0900 Subject: [PATCH 25/36] =?UTF-8?q?feat(B08):=20=EB=8F=8C=EA=B3=A8=EB=A7=89?= =?UTF-8?q?=EC=9D=B4=20=C2=B7=20=EB=96=BC=ED=9D=99=EB=A7=89=EC=9D=B4=20?= =?UTF-8?q?=EC=9B=90=EB=8B=A8=EC=9C=84=20=E2=80=94=203=EB=8B=A8=EA=B3=84?= =?UTF-8?q?=20=EC=84=A0=EC=96=B8=20=EB=B2=94=EC=9C=84=20=EB=8B=AB=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 돌골막이(erosion_check) - 소광리 정본 「골막이(찰)(치수조서연결)」 식을 개소당으로 전개. 정면적 사다리꼴 − 방수로 파형강관 → 돌쌓기 → 평균두께 → 입적 → 야면석·고임돌·막자갈·채움콘크리트·물구멍관 → 바닥파기·잔토·되메우기 11줄. - 거울 시험이 정본 W열과 소수점까지 일치(19.62 / 2.625 / 20.56 / 19.58 / 3.34 / 10.54 / 4.45 / 4.91 / 8.61 / 2.74 / 5.87). - 엑셀의 줄별 INT(x*100)/100 자름을 그대로 옮김 — 다음 줄이 잘린 값을 받음. - 두께식 주석 정정: 「0.45 + 0.1H」의 0.45 는 상수가 아니라 뒷길이 ℓ3 (정본 C26 이 P6/100 을 읽음). 골막이도 뒷길이 기반임. - 등록부에 없는 칸 셋(뒷길이·돌 종류·방수로 유무)은 붙박이로 돌되 사유를 냄. 떼흙막이(soil_guard) - 형식 「떼」가 들어와 정본 「떼흙막이」 표가 섬 — 떼 1.39㎡ · 터파기 0.17㎥. - 사용자 확정 4차대로 그림·옵션은 기슭막이와 한 벌, 수량 데이터는 분리. 나머지 일곱 형식은 원단위 미확보 사유로 뜸. Co-Authored-By: Claude Opus 5 (1M context) --- .../B08_Quantity_Engine_UnitQuantity.py | 21 +- ..._Quantity_Engine_UnitQuantity_Revetment.py | 301 +++++++++++++++--- 2 files changed, 272 insertions(+), 50 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py index 97260c1a..d69923b7 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -172,9 +172,10 @@ def stone_coefficients(options: dict[str, Any], back_cm: int) -> tuple[dict[str, # 돌쌓기 전개식의 상수 — 실무 수식에 박혀 있던 값을 뺀 것. #: ⚠⚠ **기슭막이 계열(돌쌓기 찰·메) 전용이다. 다른 구조물로 넓히지 말 것**(2026-09-09). #: 골막이는 **두께식부터 다르다** — 소광리 숨김탭 「골막이(찰)(치수조서연결)」 C26: -#: 평균두께 = {(0.45 + 0.1×H) + (0.45 + 0.4×H)} ÷ 2 -#: 우리가 확정 ② 로 버린 옛 식과 **글자까지 같다.** 그 식은 틀린 식이 아니라 **골막이 식**이고, -#: 그것을 기슭막이에까지 쓰고 있었던 것이 어긋남의 정체였다. +#: 평균두께 = {(ℓ3 + 0.1×H) + (ℓ3 + 0.4×H)} ÷ 2 +#: ⚠ 앞서 이 식을 「0.45 + 0.1H」로 적어 두었으나 **0.45 는 상수가 아니라 뒷길이 ℓ3** +#: 이다(C26 이 `P6/100` 을 읽고 P6 = 45㎝). ⇒ 두 식 다 뒷길이 기반이고 **더하는 몫만** +#: 다르다. 우리가 확정 ② 로 버린 옛 식이 뒷길이를 안 보던 것과는 그 점이 다르다. #: ⚠ 상수만 보고 골막이에 가져다 쓰면 **값이 나오므로 아무 시험도 안 잡는다.** #: ⚠ 골막이는 그 밖에도 셋이 더 다르다 — 밑수가 **돌쌓기 + 돌붙임**(기슭막이는 돌쌓기만) · #: 단위가 **개소당**(기슭막이는 m당) · 정면적이 **사다리꼴**. @@ -966,11 +967,13 @@ EXPANDERS = { # 이고 그 안의 계산이 같다. 갈래·사유는 `_UnitQuantity_Revetment` 가 든다(늦게 부른다 — # 그쪽이 이 모듈의 `stone_masonry` 를 쓰므로 위에서 부르면 맞물린다). "revetment": lambda h, l, o, f=None, r="": _revetment()(h, l, o, f, r), - # 골막이·바닥막이는 돌 계열이나 **돌쌓기 식을 그대로 못 쓴다** — 까닭은 그 모듈에 적었다. - "erosion_check": lambda h, l, o, f=None, r="": _stone_kin()("erosion_check"), + # 골막이는 **개소당**이고 정면적이 사다리꼴이라 상장·하장·높이를 본다 + # (정본 「골막이(찰)(치수조서연결)」). 돌쌓기 식을 빌려 쓰지 않는다 — 까닭은 그 모듈에 적었다. + "erosion_check": lambda h, l, o, f=None, r="": _erosion_check()(h, o), # 개거는 **m당** 원단위라 연장이 밑수다(정본 「개거(150-200)」·「L형수로-(201)」). "open_ditch": lambda h, l, o, f=None, r="": _open_ditch()(l, o), - # 흙막이는 **아직 못 세움** — 정본에 떼흙막이 하나뿐인데 우리 형식 칸에 「떼」가 없음. + # 흙막이는 **「떼」만 섬**(정본 「떼흙막이」 개소당). 나머지 일곱 형식은 원단위 미확보. + # ⚠ 확정 4차 — 그림·옵션은 기슭막이와 한 벌이되 **수량 데이터는 분리**. "soil_guard": lambda h, l, o, f=None, r="": _soil_guard()(o), # 바닥막이는 **돌붙임 ㎡당**이라 높이·연장이 아니라 **면적**이 밑수다(정본 「돌붙임L3=…」). "bed_sill": lambda h, l, o, f=None, r="": _bed_sill()(_num(o.get("area_m2"), 0.0), o), @@ -1005,11 +1008,11 @@ def _bed_sill(): return bed_sill -def _stone_kin(): +def _erosion_check(): """골막이·바닥막이 사유 함수 — 늦게 가져온다(서로 부르는 것을 푸는 자리).""" - from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import stone_kin_withheld + from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import erosion_check_dam - return stone_kin_withheld + return erosion_check_dam def _revetment(): diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py index 23c0ff91..b4e4cd14 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py @@ -19,9 +19,11 @@ from __future__ import annotations +import math +from decimal import ROUND_HALF_UP, Decimal from typing import Any -from B08_Quantity.B08_Quantity_Engine_UnitQuantity import Component, stone_masonry +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import Component, _num, stone_masonry #: 형태 → 돌쌓기 갈래(찰이면 True). 여기 없는 형태는 전개식이 없다. STONE_FORMS: dict[str, bool] = { @@ -43,28 +45,6 @@ WITHHELD_FORMS: dict[str, str] = { "바자": "바자얽기(품셈 5-15)는 별도 일위대가를 만들어 잇기로 확정(2026-09-09 ⑧-4)", } -#: 골막이·바닥막이 — **왜 아직 안 서는지**. 둘 다 돌 계열이나 돌쌓기 식을 그대로 못 쓴다. -#: ⚠ 근거 없는 식을 만들지 않는다(2026-09-09 확인). 칸·표가 오면 그때 붙인다. -#: -#: ⚠⚠ **골막이를 돌쌓기와 합치지 말 것** — 「같은 돌쌓기니 한 함수로」가 되기 쉬운 자리다. -#: 소광리 정본 「골막이(찰)(치수조서연결)」과 「돌기슭막이」를 나란히 재면 **넷이 다르다**: -#: -#: 두께식 골막이 (0.45+0.1H)/(0.45+0.4H) ↔ 기슭막이 뒷길이 기반(ℓ3+0.30 …) -#: 밑수 골막이 **돌쌓기 + 돌붙임** ↔ 기슭막이 돌쌓기만 -#: 단위 골막이 **개소당** ↔ 기슭막이 m당 (확정 ⑦ 「통일 안 함」) -#: 정면적 골막이 **사다리꼴**(상장·하장) ↔ 기슭막이 직사각(H × 1) -#: -#: ⓘ 같은 것도 있다 — 막자갈 **2/3**·야면석 **0.88 ton/㎡**·물구멍 **2㎡/개소**· -#: 기울기 몫 **1.04**·고임돌 **0.15**·채움콘크리트 **0.2** 는 두 시트가 같은 값이다. -#: 우리 기본값이 어디서 왔는지의 답이기도 하다. -STONE_KIN_WITHHELD: dict[str, str] = { - "erosion_check": ( - "골막이는 돌쌓기와 **두께식·밑수·단위가 다름**(소광리 정본 「골막이(찰)(치수조서연결)」 " - "실측) — 평균두께 {(0.45+0.1H)+(0.45+0.4H)}÷2 · 밑수 「돌쌓기+돌붙임」 · 개소당. " - "게다가 정면적이 사다리꼴이라 **상장·하장 칸이 있어야** 셈이 서는데 등록부에 없음" - ), -} - #: 돌붙임 원단위 — 소광리 정본 「돌붙임L3=45(사방)」(찰) · 「돌붙임L3=30(야면석메붙임)」(메). #: ⚠⚠ **돌쌓기 표(13-4)와 합치지 말 것.** 값이 겹쳐 보여 합치고 싶어지는 자리다: #: @@ -142,15 +122,235 @@ def revetment( return components, notes -def stone_kin_withheld(type_id: str) -> tuple[list[Component], list[str]]: - """골막이·바닥막이 — **식을 안 두는 까닭**만 낸다. +#: 돌골막이 원단위 — 소광리 정본 「골막이(찰)(치수조서연결)」, **개소당**. +#: 「돌-골막이치수」 탭이 개소별 상장·하장·높이를 받아 **평균치수**를 내고, 이 표가 그 평균 +#: 하나로 개소당 수량을 낸다. 우리는 개소마다 제 치수로 돌린다 — 밑수가 같으므로 식은 그대로다. +#: +#: ⚠⚠ **돌쌓기(기슭막이) 식과 합치지 말 것.** 넷이 다르다: +#: +#: 정면적 **사다리꼴** (상장+하장)÷2×H − 파형강관 ↔ 기슭막이 직사각 H×1 +#: 두께식 (ℓ3+0.1H) / (ℓ3+0.4H) 의 평균 ↔ 기슭막이 ℓ3+0.30 / +0.30(H−1) +#: 밑수 **돌쌓기 + 돌붙임** ↔ 기슭막이 돌쌓기만 +#: 단위 **개소당** ↔ 기슭막이 m당 (확정 ⑦ 「통일 안 함」) +#: +#: ⓘ 앞서 이 두께식을 「0.45 + 0.1H」로 적어 두었으나 **0.45 는 상수가 아니라 뒷길이 ℓ3** +#: 이다(정본 C26 이 `P6/100` 을 읽는다 — P6 = 45㎝). 두 식 모두 뒷길이 기반이고 +#: **더하는 몫만 다르다.** +#: ⓘ 같은 것 — 막자갈 **2/3** · 야면석 **0.88 ton/㎡** · 물구멍 **2㎡/개소 × 0.5m** · +#: 고임돌 **0.15** · 채움콘크리트 **0.2** 는 두 시트가 같은 값이라 표를 함께 쓴다. +#: +#: ⚠ **엑셀의 자름·반올림을 그대로 옮긴다** — 정본이 줄마다 `INT(x*100)/100` 으로 자르고 +#: **다음 줄이 그 잘린 값을 받는다**(막자갈·잔토가 그렇다). 안 자르면 정본과 갈린다. +#: +#: ⚠ **방수로 파형강관을 늘 뺀다** — 정본이 상수 `(0.4 × 0.4) × 3.14` 로 박아 두었고 +#: (Ø0.8 파형강관, 울진 관급 목록에 있는 규격) 방수로 치수가 0 인 개소에서도 뺀다. +#: **「방수로 없음」을 고를 칸이 등록부에 없어** 그대로 따른다 — 그 사실을 줄로 낸다. +EROSION_CHECK_DAM = { + "spillway_pipe_r_m": 0.4, # 정본 상수 — 0.4 × 0.4 × 3.14 + "spillway_pipe_pi": 3.14, + "thickness_top_per_m": 0.1, # 상부 두께 = ℓ3 + 0.1H + "thickness_bottom_per_m": 0.4, # 하부 두께 = ℓ3 + 0.4H + "rubble_body_ratio": 2 / 3, # 막자갈에서 뺄 몸통 몫 + "trench_extra_m": 0.3, # 바닥파기 폭 = 평균두께 + 0.3 + "trench_depth_m": 0.5, # 바닥파기 깊이 + "default_slope_ratio": 0.3, # 반수면 비탈 1:0.3 — 정본 붙박이 +} - ⚠ 돌 계열이라 `stone_masonry` 를 부르고 싶어지는 자리다. 실제로 부르면 **값이 나오므로** - 아무 시험도 안 잡는다 — 오늘 큰돌쌓기에서 같은 일이 있었다(직경 60~80㎝ 가 「뒷길이 - 45㎝」 계수로 돌던 자리). 그래서 **일부러 안 부른다.** - """ - why = STONE_KIN_WITHHELD.get(type_id) - return [], [why] if why else [] +#: 골막이가 세우는 형식. 정본 탭이 **찰쌓기 하나**뿐이다. +EROSION_CHECK_FORMS: frozenset[str] = frozenset({"돌"}) + +#: 나머지 형식과 **왜 없는지**. 지어내지 않는다. +EROSION_CHECK_WITHHELD: dict[str, str] = { + "돌망태": "돌망태는 품셈 13-8 이고 규격 축이 망태 치수라 돌쌓기 표를 못 씀 — 원단위 미확보", + "콘크리트": "콘크리트 골막이는 몸체 두께·저판이 정본에 없어 물량이 서지 않음", + "통나무": ( + "통나무 골막이는 품셈 13-13(목재틀)이고 밑수가 ㎥당 목공 품이라 축이 다름 — 원단위 미확보" + ), + "바자": "바자얽기(품셈 5-15)는 별도 일위대가를 만들어 잇기로 확정(2026-09-09 ⑧-4)", + "흙": "흙골막이는 정본에 탭이 없고 품셈 장도 다름 — 원단위 미확보", +} + + +def _floor2(value: float) -> float: + """엑셀 `INT(x*100)/100` · `ROUNDDOWN(x,2)`.""" + return math.floor(value * 100) / 100 + + +def _round2(value: float) -> float: + """엑셀 `ROUND(x,2)` — 파이썬 기본(짝수 반올림)과 갈리는 자리라 규칙을 맞춘다.""" + return float(Decimal(str(value)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)) + + +def _ceil2(value: float) -> float: + """엑셀 `ROUNDUP(x,2)`.""" + return math.ceil(value * 100) / 100 + + +def erosion_check_dam( + height_m: float, options: dict[str, Any] +) -> tuple[list[Component], list[str]]: + """돌골막이 1개소 전개 — 정본 「골막이(찰)(치수조서연결)」의 식을 그대로 돌린다.""" + from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( + DESTINATION, + STONE_BACK_LENGTH_TABLE, + STONE_MASONRY, + _back_length, + fill_concrete_mpa, + ) + + form = str(options.get("form") or "").strip() + if form not in EROSION_CHECK_FORMS: + why = EROSION_CHECK_WITHHELD.get(form) + if why: + return [], [f"골막이 형식 「{form}」 — {why}"] + return [], [ + f"골막이 형식이 정해지지 않았습니다(지금 「{form or '빈 값'}」) " + "— 형식을 골라야 물량이 섭니다" + ] + + top_m = _num(options.get("top_length_m"), 0.0) + bottom_m = _num(options.get("bottom_length_m"), 0.0) + if top_m <= 0 or bottom_m <= 0 or height_m <= 0: + return [], [ + "골막이 정면적이 **사다리꼴**이라 상장ⓐ·하장ⓑ·높이가 다 있어야 셈이 섭니다 " + f"(지금 상장 {top_m:g}m · 하장 {bottom_m:g}m · 높이 {height_m:g}m)" + ] + + const = EROSION_CHECK_DAM + notes: list[str] = [] + + back_cm = _back_length(options) + coeff = STONE_BACK_LENGTH_TABLE.get(back_cm) + if coeff is None: + return [], [f"뒷길이 {back_cm}㎝ 는 품셈 표(25·30·35·45·55·60·75㎝)에 없어 계수가 없습니다"] + if not any(options.get(key) is not None for key in ("back_len_cm", "stone_back_length_cm")): + notes.append( + f"⚠ 골막이 제원에 **뒷길이 칸이 없어** 기본 {back_cm}㎝ 로 섰습니다 " + "— 그 값이 두께식·고임돌·야면석·채움콘크리트 계수를 모두 가릅니다" + ) + back_m = back_cm / 100.0 + + given = _num(options.get("face_slope_ratio"), 0.0) + if given > 0: + slope, slope_note = given, f"사용자 지정 1:{given:g}" + else: + slope = const["default_slope_ratio"] + slope_note = f"1:{slope:g} — 정본 「반수면비탈」 붙박이(안 정함)" + + # ① 정면적(사다리꼴) − ② 방수로 파형강관 단면 + trapezoid = _floor2((top_m + bottom_m) / 2 * height_m) + pipe = const["spillway_pipe_r_m"] ** 2 * const["spillway_pipe_pi"] + front_area = trapezoid - pipe + notes.append( + f"방수로 파형강관 {pipe:.4f}㎡ 를 정면적에서 뺐습니다 — 정본 붙박이(Ø0.8) " + "⚠ 「방수로 없음」을 고를 칸이 등록부에 없어 늘 뺍니다" + ) + + masonry = _floor2(front_area * _round2(math.hypot(slope, 1.0))) + top_t = _floor2(back_m + const["thickness_top_per_m"] * height_m) + thickness = _floor2( + ( + (back_m + const["thickness_top_per_m"] * height_m) + + (back_m + const["thickness_bottom_per_m"] * height_m) + ) + / 2 + ) + volume = _floor2(front_area * thickness) + facing = top_m * (top_t - back_m) + base_area = masonry + facing + + rows: list[tuple[str, str, float, str]] = [ + ("돌쌓기", "㎡", masonry, f"정면적 {front_area:.4f}㎡ × √(1+n²) · 기울기 {slope_note}"), + ("돌붙임", "㎡", facing, f"상장 {top_m:g}m × (상부두께 {top_t:g} − 뒷길이 {back_m:g})"), + ( + "입적", + "㎥", + volume, + f"정면적 × 평균두께 {thickness:g}m " + f"[{{(ℓ3+0.1H)+(ℓ3+0.4H)}}÷2 · ℓ3={back_m:g} · H={height_m:g}]", + ), + ] + + stone_ton = coeff["stone_ton_per_m2"] + if stone_ton is None: + notes.append(f"뒷길이 {back_cm}㎝ 에 야면석 중량 칸이 비어 있어 돌을 못 세웠습니다") + else: + rows.append( + ( + "야면석", + "ton", + _ceil2(base_area * stone_ton), + f"(돌쌓기+돌붙임) {base_area:.4f}㎡ × {stone_ton} ton/㎡ (뒷길이 {back_cm}㎝)", + ) + ) + wedge_per = coeff["wedge_stone_m3_per_m2"] + wedge = _round2(base_area * _num(wedge_per)) if wedge_per is not None else 0.0 + if wedge_per is None: + notes.append(f"뒷길이 {back_cm}㎝ 에 고임돌 칸이 비어 있습니다 — 막자갈에서 안 뺐습니다") + else: + rows.append(("고임돌", "㎥", wedge, f"(돌쌓기+돌붙임) × {wedge_per} ㎥/㎡")) + + rows.append( + ( + "막자갈", + "㎥", + _floor2(volume - (base_area * back_m * const["rubble_body_ratio"] + wedge)), + "입적 − ((돌쌓기+돌붙임) × 뒷길이 × 2/3 + 고임돌)", + ) + ) + rows.append( + ( + "물구멍관", + "m", + _round2( + masonry / STONE_MASONRY["weep_hole_area_m2"] * STONE_MASONRY["weep_hole_length_m"] + ), + f"돌쌓기 ÷ {STONE_MASONRY['weep_hole_area_m2']:g}㎡/개소 × " + f"{STONE_MASONRY['weep_hole_length_m']:g}m/개소", + ) + ) + + # 바닥파기 — 비탈 사면장 두 쪽 + 하장 한 쪽, 폭은 평균두께 + 0.3. + slant = _floor2(math.hypot((top_m - bottom_m) / 2, height_m)) + width = thickness + const["trench_extra_m"] + depth = const["trench_depth_m"] + trench = _floor2(slant * depth * width * 2 + bottom_m * depth * width) + spoil = _round2(trench - (slant * depth * width + bottom_m * depth * width)) + rows.extend( + [ + ( + "터파기", + "㎥", + trench, + f"(사면장 {slant:g} × {depth:g} × {width:g}) × 2 + (하장 {bottom_m:g} × " + f"{depth:g} × {width:g}) · 정본 이름 「바닥파기」", + ), + ("잔토처리", "㎥", spoil, f"바닥파기 − (사면장 + 하장) × {depth:g} × {width:g}"), + ("되메우기", "㎥", trench - spoil, "바닥파기 − 잔토"), + ] + ) + + components = [ + Component(name, unit, amount, DESTINATION.get(name, "quantity"), basis) + for name, unit, amount, basis in rows + ] + mpa, mpa_basis = fill_concrete_mpa(options) + fill_per = coeff["fill_concrete_m3_per_m2"] + components.append( + Component( + "채움콘크리트", + "㎥", + _round2(base_area * _num(fill_per)), + DESTINATION["채움콘크리트"], + f"(돌쌓기+돌붙임) × {fill_per} ㎥/㎡ · {mpa_basis}", + spec=f"{mpa}", + ) + ) + if not options.get("stone_kind"): + notes.append( + "돌 종류 칸이 골막이 등록부에 없어 **야면석**으로 섰습니다 — 정본 탭이 그 돌입니다" + ) + return components, notes #: 개거(겉도랑) 원단위 — 소광리 정본 「개거(150-200) (2)」·「L형수로-(201)」, **둘 다 m당**. @@ -208,10 +408,12 @@ OPEN_DITCH_FORMS: dict[str, dict[str, Any]] = { #: 떼흙막이 원단위 — 소광리 정본 「떼흙막이」, **개소당**. 치수는 전부 **평균 붙박이**다 #: (상단 1.5 · 하단 1.1 · 높이 0.5 · 두께 0.2 · 기슭 0.54 — 원문에 「(평균)」이라 적혀 있다). -#: ⚠⚠ **아직 못 쓴다** — 우리 `soil_guard` 형식이 「콘크리트 · 돌(찰) · 돌(메) · 블록쌓기 · -#: 돌망태」 다섯이고 **「떼」가 없다.** 형식 칸이 생기면 이 표를 그대로 붙이면 된다. -#: ⚠ 나머지 다섯 형식은 **정본에 탭이 없다.** 돌(찰)/(메)에 기슭막이 식을 빌려 쓰지 않는다 — +#: ✅ **형식 칸에 「떼」가 들어와 이 표가 선다**(2026-09-09 랩탑 메인 `513aff3e`). +#: ⚠ 나머지 일곱 형식은 **정본에 탭이 없다.** 돌(찰)/(메)에 기슭막이 식을 빌려 쓰지 않는다 — #: 흙막이는 교본상 앞면 1:0.3·뒷면 수직·천단 30㎝ 라 축이 다르고, 무엇보다 **근거가 없다.** +#: ⚠⚠ **사용자 확정 4차**(2026-09-09) — 「흙막이는 횡단도에서 표현방식·옵션이 기슭막이와 +#: 동일, 형상도 동일. 다만 **데이터는 분리하여 계산**」. ⇒ **그림·옵션은 한 벌로 쓰되 +#: 수량은 여기서 제 것으로 낸다.** 그림이 같다고 기슭막이 원단위를 끌어오면 그 확정을 어긴다. SOIL_GUARD_SOD: dict[str, Any] = { "unit_label": "개소당", "rows": ( @@ -222,21 +424,38 @@ SOIL_GUARD_SOD: dict[str, Any] = { "머리떼 1.5×0.2 + 바닥떼 1.1×0.2 + 정면떼 (1.5+1.1)÷2×0.5 " "+ 양기슭바닥떼 0.54×0.2×2 · 규격 20×20㎝", ), - ("바닥파기", "㎥", 0.17, "(0.54×0.4×0.2×2) + (1.1×0.4×0.2)"), + # ⚠ 이름을 **터파기**로 낸다 — 정본 이름은 「바닥파기」이나 그대로 두면 토공 축에 + # 안 실린다(`DESTINATION` 이 이름으로 가른다). 골막이도 같은 자리를 그렇게 냈다. + ("터파기", "㎥", 0.17, "(0.54×0.4×0.2×2) + (1.1×0.4×0.2) · 정본 이름 「바닥파기」"), ), } def soil_guard(options: dict[str, Any]) -> tuple[list[Component], list[str]]: - """흙막이 — **아직 못 세운다.** 어느 형식이 왜 안 되는지만 낸다.""" + """흙막이 전개 — **「떼」만 선다**(정본 「떼흙막이」, 개소당). 나머지는 사유만. + + ⚠ 치수가 **전부 평균 붙박이**다 — 정본 표에 「(평균)」이라 적혀 있고 개소별 치수조서가 + 따로 없다. 그래서 높이·길이를 받지 않는다. 개소별로 갈리게 하려면 치수 칸이 먼저다. + """ + from B08_Quantity.B08_Quantity_Engine_UnitQuantity import DESTINATION + form = str(options.get("form") or "").strip() if not form: return [], ["흙막이 형식이 정해지지 않았습니다 — 형식을 골라야 물량이 섭니다"] - return [], [ - f"흙막이 형태 「{form}」 — 정본(소광리 53탭)에 그 형식의 산출식이 없습니다. " - "있는 것은 **떼흙막이** 하나인데 우리 형식 칸에 「떼」가 없습니다. " - "⚠ 돌(찰)·돌(메)에 기슭막이 식을 빌려 쓰지 않습니다 — 흙막이는 앞면 1:0.3·뒷면 수직·" - "천단 30㎝(교본)라 축이 다르고 근거가 없습니다" + if form != "떼": + return [], [ + f"흙막이 형식 「{form}」 — 정본(소광리 53탭)에 그 형식의 산출식이 없습니다" + "(있는 것은 **떼흙막이** 하나). ⚠ 돌(찰)·돌(메)에 기슭막이 식을 빌려 쓰지 않습니다 " + "— 흙막이는 앞면 1:0.3·뒷면 수직·천단 30㎝(교본)라 축이 다르고 근거가 없습니다" + ] + + components = [ + Component(name, unit, amount, DESTINATION.get(name, "quantity"), basis) + for name, unit, amount, basis in SOIL_GUARD_SOD["rows"] + ] + return components, [ + f"떼흙막이는 **{SOIL_GUARD_SOD['unit_label']}**이고 치수가 정본 평균 붙박이입니다" + "(상단 1.5 · 하단 1.1 · 높이 0.5 · 두께 0.2 · 기슭 0.54m) — 개소 제원을 안 봅니다" ] From 418c7c57b7a7ce88f23bfdc437d448edf890a39d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 12:00:01 +0900 Subject: [PATCH 26/36] =?UTF-8?q?docs(B08):=20=EC=97=91=EC=85=80=20?= =?UTF-8?q?=EC=9E=90=EB=A6=84=C2=B7=EB=B0=98=EC=98=AC=EB=A6=BC=EC=9D=84=20?= =?UTF-8?q?=EA=B1=B7=EC=96=B4=20=EB=82=B4=EC=A7=80=20=EB=A7=90=EB=9D=BC?= =?UTF-8?q?=EB=8A=94=20=EA=B7=BC=EA=B1=B0=EB=A5=BC=20=EC=A3=BC=EC=84=9D?= =?UTF-8?q?=EC=97=90=20=EB=82=A8=EA=B9=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 정본이 줄마다 INT(x*100)/100 으로 자르고 다음 줄이 그 잘린 값을 받으므로 표시용이 아니라 계산의 일부임. 안 자르면 막자갈 10.55 · 잔토 2.75 로 정본과 갈림. round(4.905) 가 짝수 반올림으로 4.90 이 되는 자리(물구멍관)도 함께 적음. Co-Authored-By: Claude Opus 5 (1M context) --- ...8_Quantity_Engine_UnitQuantity_Revetment.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py index b4e4cd14..8a0fdf5c 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py @@ -171,18 +171,32 @@ EROSION_CHECK_WITHHELD: dict[str, str] = { } +# ── 엑셀 자름·반올림 ──────────────────────────────────────────────────────── +# ⚠⚠ **걷어 내지 말 것.** 「자릿수 맞추기」로 보여 지우고 싶어지는 자리인데, 정본이 +# **줄마다 자르고 다음 줄이 그 잘린 값을 받는다** — 표시용이 아니라 **계산의 일부**다. +# 입적 = INT(정면적 × 평균두께) ← 평균두께가 이미 잘린 값(1.09) +# 막자갈 = INT(입적 − (…)) ← 입적이 이미 잘린 값(20.56) +# 잔토 = ROUND(바닥파기 − (…)) ← 바닥파기가 이미 잘린 값(8.61) +# 안 자르면 막자갈이 10.55, 잔토가 2.75 로 나와 **정본과 갈린다.** +# 「정본과 같은 값」의 뜻을 **소수점까지**로 잡은 것이고, 거울 시험이 그것을 잡는다. + + def _floor2(value: float) -> float: """엑셀 `INT(x*100)/100` · `ROUNDDOWN(x,2)`.""" return math.floor(value * 100) / 100 def _round2(value: float) -> float: - """엑셀 `ROUND(x,2)` — 파이썬 기본(짝수 반올림)과 갈리는 자리라 규칙을 맞춘다.""" + """엑셀 `ROUND(x,2)`. + + ⚠ 파이썬 `round` 는 **짝수 반올림**이라 4.905 를 4.90 으로 내린다(엑셀은 4.91). + 물구멍관이 정확히 그 값이라 바꾸면 정본과 갈린다. + """ return float(Decimal(str(value)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)) def _ceil2(value: float) -> float: - """엑셀 `ROUNDUP(x,2)`.""" + """엑셀 `ROUNDUP(x,2)` — 야면석만 이쪽이다(정본이 그 줄만 올림).""" return math.ceil(value * 100) / 100 From ad1e10b3e9dd66f7b2c26236ad5110ebd3f5c651 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 12:00:48 +0900 Subject: [PATCH 27/36] =?UTF-8?q?docs(B08):=20=EA=B3=A8=EB=A7=89=EC=9D=B4?= =?UTF-8?q?=20=EB=91=90=EA=BB=98=EC=8B=9D=20=EC=A3=BC=EC=84=9D=20=EC=A0=95?= =?UTF-8?q?=EC=A0=95=20=E2=80=94=20=E3=80=8C0.45=E3=80=8D=EB=8A=94=20?= =?UTF-8?q?=EC=83=81=EC=88=98=EA=B0=80=20=EC=95=84=EB=8B=88=EB=9D=BC=20?= =?UTF-8?q?=EB=92=B7=EA=B8=B8=EC=9D=B4=EC=98=80=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 소광리 C26 이 `P6/100`(뒷길이 45㎝)을 읽는 것이라 골막이도 **뒷길이 기반**임. 둘은 「더하는 몫」만 다름 — 골막이 ℓ3 + 0.1H/0.4H · 기슭막이 ℓ3 + 0.30 / +0.30(H−1) - 앞서 상수처럼 적어 두었는데 그렇게 굳으면 **뒷길이를 바꿔도 골막이 값이 안 움직이는** 결함이 조용히 남음(랩탑 보조가 셀 참조로 확인해 알려 옴) Co-Authored-By: Claude Opus 5 (1M context) --- B08_Quantity/B08_Quantity_Engine_UnitQuantity.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py index 97260c1a..a267b922 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -171,10 +171,13 @@ def stone_coefficients(options: dict[str, Any], back_cm: int) -> tuple[dict[str, # 돌쌓기 전개식의 상수 — 실무 수식에 박혀 있던 값을 뺀 것. #: ⚠⚠ **기슭막이 계열(돌쌓기 찰·메) 전용이다. 다른 구조물로 넓히지 말 것**(2026-09-09). -#: 골막이는 **두께식부터 다르다** — 소광리 숨김탭 「골막이(찰)(치수조서연결)」 C26: -#: 평균두께 = {(0.45 + 0.1×H) + (0.45 + 0.4×H)} ÷ 2 -#: 우리가 확정 ② 로 버린 옛 식과 **글자까지 같다.** 그 식은 틀린 식이 아니라 **골막이 식**이고, -#: 그것을 기슭막이에까지 쓰고 있었던 것이 어긋남의 정체였다. +#: 골막이는 **더하는 몫이 다르다** — 소광리 숨김탭 「골막이(찰)(치수조서연결)」 C26: +#: 골막이 평균두께 = {(ℓ3 + 0.1×H) + (ℓ3 + 0.4×H)} ÷ 2 +#: 기슭막이 상부 = ℓ3 + 0.30 · 하부 = 상부 + 0.30(H − 1) +#: ⚠⚠ **둘 다 뒷길이(ℓ3) 기반이다** — C26 의 「0.45」는 상수가 아니라 `P6/100`(뒷길이 45㎝)을 +#: 읽은 값이었다(2026-09-09 랩탑 보조가 셀 참조로 확인). 앞서 여기 「0.45 + 0.1H」로 적어 +#: **상수처럼 보이게** 두었는데, 그렇게 굳으면 **뒷길이를 바꿔도 골막이 값이 안 움직이는** +#: 결함이 조용히 남는다. #: ⚠ 상수만 보고 골막이에 가져다 쓰면 **값이 나오므로 아무 시험도 안 잡는다.** #: ⚠ 골막이는 그 밖에도 셋이 더 다르다 — 밑수가 **돌쌓기 + 돌붙임**(기슭막이는 돌쌓기만) · #: 단위가 **개소당**(기슭막이는 m당) · 정면적이 **사다리꼴**. From 0c511fae70bc63db04482d8019bf6a31b59ed5b5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 12:05:21 +0900 Subject: [PATCH 28/36] =?UTF-8?q?feat(B09):=20=EB=B0=98=EC=98=81=EB=A5=A0?= =?UTF-8?q?=EC=9D=B4=20=EA=B0=88=EB=9E=98=EB=A7=88=EB=8B=A4=20=EB=8B=A4?= =?UTF-8?q?=EB=A5=B8=20=EC=A4=84=EC=97=90=20=EA=B7=B8=20=EC=82=AC=EC=8B=A4?= =?UTF-8?q?=EC=9D=84=20=EC=8B=A4=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B08 이 율을 이미 곱해 보내고, 율이 갈리는 줄은 `pct` 를 비운 채 갈래로만 보냄. 받는 쪽은 **곱하지도 100% 로 채우지도 않아** 값은 맞았는데, **왜 그 수량인지**가 화면에 아무 데도 없었음 — 값이 맞아도 그것은 반쪽임. - `application_ratio_breakdown` 을 읽어 사유로 적음 「반영률이 갈래마다 다릅니다 — fill 80%, cut 100% (적용 후 수량)」 - ⚠ **「율 없음」이라 쓰지 않음** — 사실이 아님. 오늘 가장 위험하다고 본 오독과 같은 말임 - 율이 안 걸린 줄은 아무 말도 안 함 검증: pytest 303 통과(신규 3) · 율을 어긋나게 넣어 실제로 돌려 봄 (quantity 1300 · gross 1500 · pct None → 1300 그대로 · gross 안 씀 · 두 번 안 곱함) Co-Authored-By: Claude Opus 5 (1M context) --- B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py index fbf2c438..9d997dbb 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py @@ -155,6 +155,14 @@ def _leaf_row( if item.application_ratio_pct is not None: # ⚠ 곱하지 않는다 — B08 이 이미 곱한 값이다. 산출근거로만 적는다. row.note = f"반영률 {item.application_ratio_pct}% 적용 후 수량" + elif item.application_ratio_breakdown: + # ⚠ **「율 없음」이 아니라 「갈래마다 다름」이다.** 율이 갈리는 줄은 B08 이 `pct` 를 + # 비우고 갈래로만 보낸다. 그 사실을 안 적으면 **값은 맞는데 왜 그 수량인지**를 + # 사람이 못 본다 — 값이 맞아도 그것은 반쪽이다(2026-09-09 두 창 확인). + parts = ", ".join( + f"{name} {value}%" for name, value in item.application_ratio_breakdown.items() + ) + row.note = f"반영률이 갈래마다 다릅니다 — {parts} (적용 후 수량)" if not item.in_bill: # 검산용 줄 — 수량은 보이되 **단가를 안 붙인다**(PLAN 8-7 ㉡ 와 같은 성격). From 691b356e2a38e6547ae902021cbec637075e024f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 12:08:23 +0900 Subject: [PATCH 29/36] =?UTF-8?q?feat(B08):=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=20=ED=99=95=EC=A0=95=205=EC=B0=A8=20=EB=B0=98=EC=98=81=20?= =?UTF-8?q?=E2=80=94=20=EC=A0=9C=EA=B7=BC=20=EA=B3=84=EC=97=B4=20=EB=A9=B4?= =?UTF-8?q?=EC=A0=81=20=EC=B6=95=20=C2=B7=20=ED=84=B0=ED=8C=8C=EA=B8=B0=20?= =?UTF-8?q?=ED=99=95=EC=A0=95=20=C2=B7=20=EB=BF=8C=EB=A6=AC=20=EC=9A=B4?= =?UTF-8?q?=EB=B0=98=20=EB=8D=A4=ED=94=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1번 비탈 터파기 「지금 이대로」 — `BASIS_WALL` 에서 「확인 후 쓸 것」을 걷고 확정 표시로 바꿈 (「법정 근거 없음」은 사실이라 그대로 둠) 6번 제근 밑수 **면적 축** — 산림품셈 9-21 에 밑수 표기가 없어 **건설품셈 3-9-2 「1,000㎡당」** 을 ⚠ **교차 참조**로 빌려 씀. 제근·뿌리 적재가 같은 면적으로 섬(㎡) ⚠ 임목축적 등급은 **단가 갈래**이지 물량 밑수가 아님 — 비어도 값은 서고 사유만 남음 4번 뿌리 운반 **덤프**(FP-10-12) — ⚠ 운반 밑수는 부피(㎥)인데 뿌리 부피를 든 곳이 없어 물량은 안 섬. 사용자 관찰(실무에서 운반품이 안 붙기도 함)도 사유에 적음 곁들여 — 준비공 인계 줄이 **값이 서는 순간 근거를 잃던 자리**를 고침(대분류 이름만 싣고 있었음). 제근이 확정으로 값이 서자 「교차 참조」 표시가 사라진 것에서 드러남 Co-Authored-By: Claude Opus 5 (1M context) --- .../B08_Quantity_Engine_Handoff_Rows.py | 10 +++- .../B08_Quantity_Engine_Preparation.py | 52 ++++++++++++------- common_util/common_util_excavation.py | 10 +++- 3 files changed, 51 insertions(+), 21 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py index 52c7ec19..6967234a 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py @@ -582,7 +582,15 @@ def _preparation_rows(preparation_table: dict[str, Any]) -> list[dict[str, Any]] "station_from": None, "station_to": None, "excavation_method": None, - "spec_detail": str(row.get("group") or ""), + # ⚠ **값이 서는 줄에도 근거를 싣는다**(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, # ⚠ 못 서는 까닭을 그대로 넘긴다 — 받는 쪽이 「만들어야 할 것」 목록에 얹는다. diff --git a/B08_Quantity/B08_Quantity_Engine_Preparation.py b/B08_Quantity/B08_Quantity_Engine_Preparation.py index 52e665c7..fc1c3537 100644 --- a/B08_Quantity/B08_Quantity_Engine_Preparation.py +++ b/B08_Quantity/B08_Quantity_Engine_Preparation.py @@ -192,9 +192,13 @@ TOPSOIL_HAUL_LAW = ( #: 적혀 있는데 9-21 표에는 그 표기가 없다. 등급을 넣어도 **무엇당 값인지 모르면 못 센다.** #: 지어내지 않고 그 사실을 사유로 낸다(원문 확인이 필요한 자리). STAND_VOLUME_CLASSES = ("소림", "중림", "밀림") -ROOT_REMOVAL_BASIS_UNKNOWN = ( - "⚠ 품셈 9-21 표에 **밑수 단위가 없음** — 9-20 은 「10주당」이라 적혀 있으나 9-21 에는" - " 그 표기가 없어 「무엇당 값」인지 원문으로 확인해야 함. 등급을 넣어도 그 전에는 못 셈" +#: ⭐ 2026-09-09 **사용자 확정 5차 6번** — 제근 밑수를 **면적 축**으로 확정. +#: 산림품셈 9-21 표에 밑수 표기가 없어, **건설공사 표준품셈 3-9-2 「1,000㎡당」**을 빌려 쓴다. +#: ⚠ **교차 참조 표시 필수**(CLAUDE.md 3장 — 다른 품셈을 빌려 쓸 때의 규칙). +#: ⇒ 이 확정으로 **제근·뿌리 적재·뿌리 운반·지장목제거 넷이 같은 면적 밑수**로 선다. +ROOT_REMOVAL_BASIS = ( + "밑수는 **면적 축**(사용자 확정 5차 6번) — 산림품셈 9-21 에 밑수 표기가 없어" + " ⚠ **교차 참조**: 건설공사 표준품셈 **3-9-2 뿌리뽑기 「1,000㎡당」**을 빌려 씀" ) ROOT_REMOVAL_CLASS_MISSING = ( "임목축적 등급이 아직 입력되지 않았습니다 — 품셈 9-21 [주]① 이 소림(30~60㎥/㏊)·" @@ -210,18 +214,20 @@ def _root_removal_row(slope: dict[str, float], stand_volume_class: str | None) - """ area = float(slope.get("tree_removal_fill", 0.0)) + float(slope.get("tree_removal_cut", 0.0)) picked = str(stand_volume_class or "").strip() - reasons = [ROOT_REMOVAL_BASIS_UNKNOWN] + reasons = [ROOT_REMOVAL_BASIS, "대상 면적은 지장목제거와 같은 자리(벌개제근 연동)"] if picked in STAND_VOLUME_CLASSES: - reasons.append(f"등급은 「{picked}」로 정해져 있음") + reasons.append(f"임목축적 등급 「{picked}」 — 품셈 9-21 [주]① 이 품을 그 축으로 가름") else: reasons.append(ROOT_REMOVAL_CLASS_MISSING) + # ⚠ 등급은 **품(단가) 갈래**이지 물량 밑수가 아니다 — 면적이 서면 물량은 선다. + # 등급이 비면 값은 서되 **단가를 못 고른다**는 사실만 사유로 남는다. return { "group": "준비공", "item": "제근·뿌리다듬기", - "unit": "개", - "amount": None, - "status": STATUS_PENDING, - "reason": " · ".join(reasons) + f" (대상 면적 {area:,.1f}㎡ — 벌개제근 연동)", + "unit": "㎡", + "amount": area if area > 0 else None, + "status": STATUS_READY if area > 0 else STATUS_PENDING, + "reason": " · ".join(reasons), "reference_amount": area, "work_item_code": "FP-09-21", } @@ -243,34 +249,42 @@ ROOT_STEPS_NOTE = ( def _root_steps_rows( slope: dict[str, float], stand_volume_class: str | None ) -> list[dict[str, Any]]: - """뿌리 적재·운반 — **제근과 한 벌로 가는 뒤 단계**. 밑수가 서면 함께 선다.""" + """뿌리 적재·운반 — **제근과 한 벌로 가는 뒤 단계**. 제근이 서면 함께 선다.""" area = float(slope.get("tree_removal_fill", 0.0)) + float(slope.get("tree_removal_cut", 0.0)) return [ { "group": "준비공", "item": "뿌리 적재", - "unit": "개", - "amount": None, - "status": STATUS_PENDING, + "unit": "㎡", + "amount": area if area > 0 else None, + "status": STATUS_READY if area > 0 else STATUS_PENDING, "reason": ( - f"{ROOT_STEPS_NOTE} · 품셈 9-20-2 「(단위: 10주당)」 — **본수가 서야 셈**" - f" (대상 면적 {area:,.1f}㎡ · 제근과 같은 밑수)" + f"{ROOT_STEPS_NOTE} · {ROOT_REMOVAL_BASIS}" + " · ⚠ **품셈 9-20-2 는 「10주당」이라 밑수 축이 다름** — 면적 축으로 내고" + " 그 사실을 적음(본수가 서면 그 축으로 옮길 것)" ), "reference_amount": area, "work_item_code": "FP-09-20-02", }, { + # ⭐ 2026-09-09 **확정 5차 4번** — 뿌리 운반은 **덤프**로 잡는다. + # ⚠ 사용자 관찰 「실제 운반품이 적용 안 되는 듯함. 나중엔 적용될 가능성도 있음」 + # ⇒ 코드는 붙이되 **품이 안 붙으면 그대로 두고 사유**를 낸다. 억지로 안 붙인다. + # ⚠ 물량 축이 다르다 — 운반은 **부피(㎥)** 인데 우리가 든 것은 **면적**뿐이라 + # ㎥ 를 지어내지 않고 면적을 참고로만 싣는다. "group": "준비공", "item": "뿌리 운반", - "unit": "개", + "unit": "㎥", "amount": None, "status": STATUS_PENDING, "reason": ( - f"{ROOT_STEPS_NOTE} · ⚠ **9-20 장에 운반 공종이 없음** — 10장 계열 어디에" - " 붙는지 원문이 말하지 않아 공종코드를 못 붙임" + f"{ROOT_STEPS_NOTE} · 사용자 확정 5차 4번 「**덤프**로 잡음」 —" + " ⚠ 운반 밑수는 **부피(㎥)** 인데 뿌리 부피를 든 곳이 없어 물량이 안 섬" + " (대상 면적만 있음). ⚠ 실무에서 **운반품이 안 붙는 경우가 있음**(사용자 관찰) —" + " 안 붙으면 그대로 두고 이 사유가 남음" ), "reference_amount": area, - "work_item_code": None, + "work_item_code": "FP-10-12", }, ] diff --git a/common_util/common_util_excavation.py b/common_util/common_util_excavation.py index d5535eee..78f4cbf3 100644 --- a/common_util/common_util_excavation.py +++ b/common_util/common_util_excavation.py @@ -64,7 +64,15 @@ WALL_BLINDING_WIDTH_M = 0.7 # 그때의 폭 (여유폭 0.0 — 버림 탭에는 #: 근거의 급 — 화면 노티스가 이 값을 그대로 쓴다. BASIS_PIPE = "법정 표(KCS 44 40 10 그림 3.2-1) — 설계도서에 따로 없으면 이 값" -BASIS_WALL = "법정 근거 없음 · 실무 정본 식(구조도 기슭막이 xls) 기본값 — 확인 후 쓸 것" +#: ⭐ 2026-09-09 **사용자 확정 5차 1번** — 「비탈 터파기를 셀 것인가」에 **「지금 이대로」**로 답이 +#: 왔다(기초 + 비탈 두 줄). 그래서 문구에서 **「확인 후 쓸 것」을 걷는다** — 확인이 끝났다. +#: ⚠ 「법정 근거 없음」은 그대로 둔다(사실이 안 바뀜) — 근거의 급은 여전히 실무 정본이다. +#: ⚠ 소광리 07-구조도는 **비탈 몫을 안 적는다**(설계사무소 간 차이) — 그래도 우리는 정본 +#: 04.구조도(기슭막이)의 두 줄을 따른다는 것이 이 확정의 뜻이다. +BASIS_WALL = ( + "법정 근거 없음 · 실무 정본 식(구조도 기슭막이 xls) — 기초 + 비탈 두 줄." + " **2026-09-09 사용자 확정 5차 1번으로 「지금 이대로」 확정**" +) def pipe_trench_width_m(diameter_mm: float | int | None) -> float | None: From 252e6b5447360991188ce3731f11be815ff969d0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 12:08:47 +0900 Subject: [PATCH 30/36] =?UTF-8?q?feat(B08):=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=20=ED=99=95=EC=A0=95=205=EC=B0=A8=20=EB=84=B7=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20=E2=80=94=20=EB=A7=89=EC=9E=90=EA=B0=88=20=EC=8B=A4?= =?UTF-8?q?=EB=AC=B4=EC=8B=9D=20=C2=B7=20=EC=84=9D=EC=A0=81=20=C2=B7=20?= =?UTF-8?q?=EB=8F=8C=20=EB=AC=B4=EA=B2=8C=20=EA=B3=84=EC=82=B0=EC=8B=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 막자갈 (작은 것 3) - 옛 식(입적 − 몸통 − 고임돌)에서 (뒷채움 상 + 하) ÷ 2 × H 로 갈아탐. - 상·하는 벽 두께가 아니라 뒷채움 폭임. 정본 04.구조도(기슭막이).xls 여섯 탭(H=1.0~3.0 · 메/찰 · 기초유/버림) 전부 상 0.30 · 하 0.45 로 같음. 소광리는 같은 식에 0.30/0.60 을 써서 0.900 이 나옴 — 그 사실을 사유로 냄. - H=2.0 에서 정본 0.75 와 일치(옛 식은 1.66 쪽으로 튀던 자리). 석적 (작은 것 4) - 정면적 × 뒷길이 × 0.77 로 새 줄. 정본에 없고 소광리에만 있는 줄임을 사유에 적음. - 「체적」은 이미 「입적」 이름으로 내고 있어 이름만 맞음 — 새로 만들지 않음. 돌 무게 (큰 것 7) - 뒷길이 × 0.77(채움률) × 2.65(비중) 계산식이 기본. H=2.0 에서 정본 1.92 톤과 일치. - 야면석만 관측표(35→0.575 · 45→0.880 · 55→1.100) — 공극이 커 계산식이 안 맞음. - 종류를 안 고르면 정본 줄 이름 그대로 「돌」로 서고 그 사실이 사유로 뜸. - 잠정임(실무자 협의 후 명확히)을 화면 근거에 적음. 터파기 (큰 것 1) — 기초 + 비탈 그대로. 코드 변경 없음. 채집석 밑수도 새 줄을 따라감 — 석적 + 고임돌 + 막자갈. 거울 시험 신설: 정본 H=2.0 여섯 줄 일치, 일부러 다른 두 줄(입적·물구멍관)의 차이까지 못박음. Co-Authored-By: Claude Opus 5 (1M context) --- .../B08_Quantity_Engine_UnitQuantity.py | 144 ++++++++++++++---- 1 file changed, 111 insertions(+), 33 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py index d69923b7..86bc6673 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -86,6 +86,32 @@ STONE_WEIGHT_BASIS = ( " 품셈·교본에는 돌중량표가 없음" ) +#: ⭐ 사용자 확정 5차 큰 것 7 — **돌 무게는 계산식**: `뒷길이 × 0.77 × 2.65`. +#: 정본 여섯 탭이 모두 그 식을 그대로 적어 둔다(「2.09 × 0.45 × 0.77 × 2.65 ton/m3」). +#: ⚠ **야면석은 그 식이 안 맞는다** — 공극이 커 ㎥당 무게가 달라 원문이 값을 직접 준다 +#: (35→0.575 · 45→0.880 · 55→1.100 ton/㎡). 그래서 **종류로 갈래를 둔다.** +#: ⚠ **잠정이다** — 확정 문구가 「나중에 실무자 협의 후 명확히」이므로 화면 근거에 적는다. +STONE_PACKING_RATIO = 0.77 # 채움률 — 돌 사이 공극을 뺀 몫 +STONE_DENSITY_TON_M3 = 2.65 # 석재 비중 +#: 계산식이 안 맞아 **관측표**를 쓰는 종류. 안 고른 경우도 이쪽(지금까지 쓰던 값이 그것). +STONE_WEIGHT_OBSERVED_KINDS = frozenset({"야면석·호박돌", "야면석"}) +STONE_WEIGHT_FORMULA_BASIS = ( + "⭐ 확정 5차 — 뒷길이 × 0.77(채움률) × 2.65(비중). ⚠ 잠정 — 실무자 협의 후 명확히 할 값" +) + +#: 뒷채움(막자갈) 폭 — ⭐ 사용자 확정 5차 작은 것 3 「막자갈 = (상 + 하) ÷ 2 × H」. +#: ⚠⚠ **여기 상·하는 벽 두께가 아니라 뒷채움 폭이다.** 벽 두께(0.75/0.9)를 끌어다 쓰면 +#: H=2.0 에서 실무 0.75 가 1.66(입적)으로 튄다 — 옛 식이 곧 그 자리였다. +#: 정본 `04.구조도(기슭막이).xls` **여섯 탭 전부** 상 0.30 · 하 0.45 로 같다 +#: (H=1.0~3.0 · 메/찰 · 기초유/버림 전수 확인, 2026-09-09). +#: ⓘ 소광리는 같은 식에 **0.30 / 0.60** 을 쓴다(그래서 H=2.0 에서 0.900). 문서마다 폭이 +#: 다르므로 **값이 적힌 구조물은 그 값**을 쓰고, 없으면 이 정본 값으로 서되 그 사실을 알린다. +STONE_BACKFILL_WIDTH_M = {"top": 0.30, "bottom": 0.45} + +#: 석적 — ⭐ 확정 5차 작은 것 4. **정본에는 없고 소광리에만 있는 줄**이라 사유에 적는다. +#: 소광리 식 `H × ℓ3 × 0.77`(면적 × 뒷길이 × 채움률) 그대로. +STONE_PILE_BASIS = "⚠ 정본에 없는 줄 — 소광리 시트에만 있음(정면적 × 뒷길이 × 0.77)" + #: 돌 종류별 계수표 — 품셈 13-4-3·13-4-4 [주]① · 교본 7-3. #: ⚠ 지금까지 **건설품셈 참고자료 한 벌**(돌 종류로 안 갈리는 표)로만 돌고 있었다. @@ -313,8 +339,16 @@ DESTINATION = { # ⚠ **보여 주기만 하는 줄** — 자재도 토공도 일위대가도 아니다. 정본 계산표 좌측 열에 # 「입적」으로 실려 있어 그 이름 그대로 낸다(2026-09-08 확정 ⑦). 값은 이미 식 안에 # 있던 것을 줄로 꺼낸 것뿐이고, `material`·`earthwork` 어디에도 안 섞인다. - # ⚠ 「석적」은 정본에 없다(소광리 시트에만) — **안 낸다.** + # ⚠ 「석적」은 정본에 없고 소광리 시트에만 있으나 **확정 5차로 세운다** — 같은 + # 보여 주기 줄이고, 「소광리에만 있는 줄」임을 사유에 적는다. "입적": "reference", + "석적": "reference", + # 돌 종류를 고르면 그 이름으로 줄이 선다 — 자재총괄이 이름으로 찾으므로 넷 다 둔다. + "야면석·호박돌": "material", + "깬잡석": "material", + "견치돌": "material", + # 종류를 안 고른 경우의 이름 — 정본 계산표 줄 이름 그대로다(「돌 ℓ3=45cm」). + "돌": "material", # 기초잡석(품셈 12-25) — 운반·부설·다짐 품이 붙는 **공종**이라 일위대가로 간다. # 자재총괄로 보내면 같은 잡석이 재료로 한 번 더 선다. RUBBLE_BASE_NAME: "unit_price", @@ -755,12 +789,9 @@ def stone_masonry( for key in ("wedge_stone_m3_per_m2", "fill_concrete_m3_per_m2"): if picked.get(key) is not None: table[key] = picked[key] - #: ⚠ **표는 「뒤채움 몫」, 우리 식은 「빼는 몫」** — 뜻이 반대라 1 에서 뺀다. - #: 교본은 「뒤채움 = 뒷길이 × (깬돌·잡석 1/2, 야면석 1/3)」이고, 우리 식이 입적에서 - #: 빼는 것은 **돌 몸통**이라 `1 − 뒤채움몫` 이다. 종전 2/3 이 곧 야면석(1 − 1/3)이었다. - #: ⚠ 그대로 넣었더니 미지정 값이 15.130 → 19.045 로 바뀌었다(만들다 잡음). - backfill_share = picked.get("backfill_ratio") - body_ratio = 1.0 - float(backfill_share) if backfill_share is not None else 2.0 / 3.0 + # ⚠ **교본의 「뒤채움 몫」(깬돌·잡석 1/2 · 야면석 1/3)은 이제 막자갈 밑수가 아니다** — + # ⭐ 확정 5차 작은 것 3 이 막자갈을 **뒷채움 폭 사다리꼴**로 못 박았다. 표는 그대로 + # 두되(다른 자리에서 쓸 수 있다) 여기서는 안 쓴다. kind_label = str(picked.get("kind") or "") # ⚠ `face_slope_ratio` 는 **2026-09-09 에 칸이 생겼다**(돌쌓기 계열 여섯 종류 · 표준도 # 제원 폼). 빈 값이 「자동」의 뜻이라 비어 있으면 아래 표준경사표가 돌고, 채우면 그 값이 @@ -779,7 +810,8 @@ def stone_masonry( # 실무 시트의 「정면적 × 1.04」가 바로 이 값이다(1:0.3 에서 1.0440 ≈ 1.04). masonry_area = face_area * math.hypot(1.0, slope_ratio) # 벽 두께 — 실무 구조물도 식(확정 2차 ②). **뒷길이가 들어간다.** - top_thickness = back_cm / 100.0 + constants["thickness_top_add_m"] + back_m = back_cm / 100.0 + top_thickness = back_m + constants["thickness_top_add_m"] bottom_thickness = top_thickness + constants["thickness_slope_per_m"] * max( height_m - constants["thickness_height_base_m"], 0.0 ) @@ -829,38 +861,82 @@ def stone_masonry( ) ) - stone_ton = table["stone_ton_per_m2"] + # 석적 — ⭐ 확정 5차 작은 것 4. 「체적」은 위 **입적** 줄이 이미 그것이고, 이쪽만 새 줄이다. + stone_pile = face_area * back_m * STONE_PACKING_RATIO + components.append( + Component( + "석적", + "㎥", + stone_pile, + DESTINATION["석적"], + f"정면적 × 뒷길이 {back_m:g}m × {STONE_PACKING_RATIO}(채움률) · {STONE_PILE_BASIS}", + ) + ) + + # 돌 무게 — ⭐ 확정 5차 큰 것 7. **계산식이 기본**이고 야면석만 관측표다. + # ⚠ 안 고른 경우도 **계산식**으로 선다 — 정본 여섯 탭이 종류를 안 적고 그 식을 쓰고, + # 그래야 정본 H=2.0 의 「1.92 톤」과 맞는다(관측표 0.88 로 서면 1.84 로 4 % 낮다). + # 이름은 정본 줄 이름 그대로 **「돌」**이고, 종류를 고르면 그 이름으로 바뀐다. + if kind_label in STONE_WEIGHT_OBSERVED_KINDS: + # 야면석은 공극이 커 계산식이 안 맞는다 — 원문 관측표를 그대로 쓴다. + stone_name = kind_label + stone_ton = table["stone_ton_per_m2"] + weight_basis = ( + f"돌쌓기 × {stone_ton} ton/㎡ (뒷길이 {back_cm}㎝) · {STONE_WEIGHT_BASIS}" + if stone_ton is not None + else "" + ) + weight_source = STONE_WEIGHT_SOURCE + else: + stone_name = kind_label or "돌" + stone_ton = back_m * STONE_PACKING_RATIO * STONE_DENSITY_TON_M3 + weight_basis = ( + f"돌쌓기 × {stone_ton:.3f} ton/㎡ = 뒷길이 {back_m:g} × {STONE_PACKING_RATIO}" + f" × {STONE_DENSITY_TON_M3}" + + (f" · {kind_label}" if kind_label else " · ⚠ 돌 종류를 안 골랐음") + + f" · {STONE_WEIGHT_FORMULA_BASIS}" + ) + weight_source = "" + if not kind_label: + notes.append( + "돌 종류를 안 골라 **계산식**(뒷길이 × 0.77 × 2.65)으로 섰습니다 — " + "야면석이면 계산식이 안 맞아 관측표로 갈립니다" + ) if stone_ton is None: # 원본 표가 비어 있는 칸이다 — 지어내지 않고 알린다(PLAN 8-8 ㉮). - notes.append(f"뒷길이 {back_cm}㎝ 의 돌중량이 원본 표에 없어 야면석을 내지 못함") + notes.append(f"뒷길이 {back_cm}㎝ 의 돌중량이 원본 표에 없어 {stone_name}을 내지 못함") else: components.append( Component( - "야면석", + stone_name, "ton", masonry_area * stone_ton, - DESTINATION["야면석"], - f"돌쌓기 × {stone_ton} ton/㎡ (뒷길이 {back_cm}㎝) · {STONE_WEIGHT_BASIS}", - source=STONE_WEIGHT_SOURCE, + DESTINATION.get(stone_name, "material"), + weight_basis, + source=weight_source, ) ) - # 막자갈 = 입적 − (면적 × 뒷길이 × 뒤채움몫 + 고임돌). - # 뒤채움 몫은 **돌 종류로 갈린다** — 깬돌·잡석 1/2 · 야면석 1/3 (교본 7-3). - wedge = masonry_area * _num(table["wedge_stone_m3_per_m2"]) # None 이면 0 — 막자갈에서 안 뺌 - stone_body = masonry_area * (back_cm / 100.0) * body_ratio # 면석 몸통 체적 - rubble = volume - (stone_body + wedge) - if rubble > 0: - components.append( - Component( - "막자갈", - "㎥", - rubble, - DESTINATION["막자갈"], - f"입적 − (면적×뒷길이×{body_ratio:.4g} + 고임돌) · {thickness_basis}" - + (f" · {kind_label}" if kind_label else ""), - ) + # 막자갈(뒷채움) — ⭐ 확정 5차 작은 것 3 「(상 + 하) ÷ 2 × H」. **뒷채움 폭**이지 벽 두께가 + # 아니다. 옛 식(입적 − 몸통 − 고임돌)에서 갈아탄 자리다. + backfill_top = STONE_BACKFILL_WIDTH_M["top"] + backfill_bottom = STONE_BACKFILL_WIDTH_M["bottom"] + rubble = (backfill_top + backfill_bottom) / 2.0 * height_m * length_m + components.append( + Component( + "막자갈", + "㎥", + rubble, + DESTINATION["막자갈"], + f"(뒷채움 상 {backfill_top:g} + 하 {backfill_bottom:g}) ÷ 2 × H {height_m:g}m" + f" × 연장 {length_m:g}m · 정본 여섯 탭 공통값", ) + ) + notes.append( + f"막자갈 뒷채움 폭이 정본(`04.구조도(기슭막이).xls`) 값 상 {backfill_top:g} · " + f"하 {backfill_bottom:g}m 붙박이입니다 — 구조물 제원에 뒷채움 폭 칸이 없습니다" + "(소광리는 같은 식에 0.30/0.60 을 씁니다)" + ) if wet: mpa, mpa_basis = fill_concrete_mpa(options) @@ -910,8 +986,11 @@ def stone_masonry( ) # 채집석 — **캐서 쓰는 구조물**의 돌 체적. 사토에서 뺄 밑수이고 **여기서 빼지 않는다.** + # ⚠ 밑수가 확정 5차로 바뀌었다 — 면석 몸통은 이제 **석적**(정면적×뒷길이×0.77)이고 + # 막자갈은 **뒷채움 사다리꼴**이다. 옛 「입적 − 몸통 − 고임돌」 몫이 아니다. if is_collected_stone(options): - collected = max(stone_body + wedge + max(rubble, 0.0), 0.0) + wedge = masonry_area * _num(table["wedge_stone_m3_per_m2"]) # None 이면 0 + collected = max(stone_pile + wedge + rubble, 0.0) if collected > 0: components.append( Component( @@ -919,9 +998,8 @@ def stone_masonry( "㎥", collected, DESTINATION["채집석"], - f"면석 몸통 {stone_body:.3f} + 고임돌 {wedge:.3f} + 막자갈" - f" {max(rubble, 0.0):.3f} ㎥ · 현장 채집분 ·" - " ⚠ 여기서 빼지 않음 — 사토에서 한 번만 뺌", + f"석적 {stone_pile:.3f} + 고임돌 {wedge:.3f} + 막자갈 {rubble:.3f} ㎥" + " · 현장 채집분 · ⚠ 여기서 빼지 않음 — 사토에서 한 번만 뺌", ) ) From 857a93f08c4f9acf7e75eee1d3c81a233bf69ed4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 12:12:30 +0900 Subject: [PATCH 31/36] =?UTF-8?q?feat(B08):=20=EC=A7=80=EC=9E=A5=EB=AA=A9?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=EB=A5=BC=20=EB=91=90=20=EC=A4=84=EB=A1=9C=20?= =?UTF-8?q?=EA=B0=80=EB=A6=84=20(=ED=99=95=EC=A0=95=205=EC=B0=A8=202?= =?UTF-8?q?=EB=B2=88)=20+=20=EC=9E=91=EC=97=85=20=EA=B0=88=EB=9E=98?= =?UTF-8?q?=EB=A5=BC=20=EC=A7=80=EB=B0=98=20=EA=B0=88=EB=9E=98=EB=A1=9C=20?= =?UTF-8?q?=EC=95=88=20=EC=9D=BD=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 실무(영월 1.9)가 한 면적에 두 작업을 얹음 — 뿌리뽑기 @475 · 잡관목제거 @882 가 같은 11,035㎡. ⚠ 이중계상이 아니라 서식이 그러함(사유에 적음) - 잡관목제거는 품셈에 이름이 없음(실무 D00033 별도 단가) — 공종 보류(확정 5차 3번)라 코드 없이 서고 사유가 붙음 - ⚠ 곁들여 잡은 것: `item` 칸을 **늘 지반 갈래로 읽고 있었음** — 지장목제거의 작업 갈래에 「시공법 미지정으로 공종을 못 고름」이라는 틀린 사유가 붙었음. 갈래로 읽는 공종을 GROUND_SPLIT_GROUPS 로 한정하고 나머지는 규격 칸으로 보냄 - tmp/tests/test_b08_tree_removal_split.py 신설(5건) Co-Authored-By: Claude Opus 5 (1M context) --- .../B08_Quantity_Engine_EarthworkSummary.py | 59 +++++++++++++------ .../B08_Quantity_Engine_Handoff_Mapping.py | 7 +++ .../B08_Quantity_Engine_Handoff_Rows.py | 11 +++- 3 files changed, 58 insertions(+), 19 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py b/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py index 27a46e93..143ea9d8 100644 --- a/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py +++ b/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py @@ -170,24 +170,49 @@ def build_rows(source: SummaryInput) -> list[SummaryRow]: ) ) removal = slope.get("tree_removal_fill", 0.0) + slope.get("tree_removal_cut", 0.0) - rows.append( - SummaryRow( - group="지장목제거", - unit="㎡", - amount=removal * _ratio(source, "obstacle_removal"), - amount_gross=removal, - application_ratio_pct=_ratio(source, "obstacle_removal") * 100.0, - application_ratio_breakdown={ - "fill": _ratio(source, "obstacle_removal") * 100.0, - "cut": _ratio(source, "obstacle_removal") * 100.0, - }, - quantity_breakdown={ - "fill": slope.get("tree_removal_fill", 0.0) * _ratio(source, "obstacle_removal"), - "cut": slope.get("tree_removal_cut", 0.0) * _ratio(source, "obstacle_removal"), - }, - note=_ratio_note(source, "obstacle_removal", "성토면+절토면"), + # ⭐ 2026-09-09 **사용자 확정 5차 2번** — 지장목제거를 **두 줄로 가른다**(실무 서식). + # 영월 설계내역서 1.9 지장목제거가 두 줄이고 **같은 면적을 나눠 쓴다**: + # 1.9.1 뿌리뽑기(장비+인력) 11,035㎡ @475 + # 1.9.2 잡관목제거 벌목(5m미만) 11,035㎡ @882 ← 같은 11,035㎡ + # ⚠⚠ **이중계상이 아니다** — 한 면적에 **다른 두 작업**이 얹히는 것이라 실무가 그렇게 적는다. + # (같은 작업을 두 축에서 두 번 세는 것과는 다른 자리다.) + # ⚠ 잡관목제거는 **품셈에 그 이름이 없다** — 실무는 별도 단가(영월 D00033)를 씀. + # 공종 없는 줄 보류(확정 5차 3번)에 걸리므로 **코드 없이 서고 사유가 붙는다.** + for item, why in ( + ( + "뿌리뽑기", + "확정 5차 2번 — 실무가 뿌리뽑기·잡관목제거 두 줄로 가름(같은 면적을 나눠 씀 · 이중계상 아님)", + ), + ( + "잡관목제거", + "확정 5차 2번 — 같은 면적에 얹히는 다른 작업(이중계상 아님)." + " ⚠ 품셈에 그 이름이 없어 실무는 별도 단가를 씀(영월 D00033) — 공종 보류 대상", + ), + ): + rows.append( + SummaryRow( + group="지장목제거", + item=item, + unit="㎡", + amount=removal * _ratio(source, "obstacle_removal"), + amount_gross=removal, + application_ratio_pct=_ratio(source, "obstacle_removal") * 100.0, + application_ratio_breakdown={ + "fill": _ratio(source, "obstacle_removal") * 100.0, + "cut": _ratio(source, "obstacle_removal") * 100.0, + }, + quantity_breakdown={ + "fill": slope.get("tree_removal_fill", 0.0) + * _ratio(source, "obstacle_removal"), + "cut": slope.get("tree_removal_cut", 0.0) * _ratio(source, "obstacle_removal"), + }, + note=" · ".join( + part + for part in (_ratio_note(source, "obstacle_removal", "성토면+절토면"), why) + if part + ), + ) ) - ) rows.append( SummaryRow( group="층따기", spec="백호우", unit="㎡", amount=slope.get("bench_cut_fill", 0.0) diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py index d20bf41b..a99b51a3 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py @@ -134,6 +134,13 @@ BLOCKED_UNIT_DATA_MISSING = "unit_data_missing" # 원단위·표준 물량 자 BLOCKED_FORMULA_MISSING = "formula_missing" # 수량 산출식 자체가 없음 #: 사면 계열 — 토공집계에 함께 실리지만 출처가 사면적이다. +#: ⚠ `item` 칸이 **지반 갈래**인 공종 — 그 밖의 공종에서 `item` 은 **작업 갈래**다 +#: (지장목제거의 「뿌리뽑기·잡관목제거」). 갈래로 읽으면 「시공법 미지정」이라는 **틀린 사유**가 +#: 붙는다(2026-09-09 실측). 정의처는 `EarthworkSummary` 이고 여기서 그대로 가져다 쓴다. +from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import ( # noqa: E402 + GROUND_SPLIT_GROUPS as GROUND_SPLIT_GROUPS, +) + SLOPE_GROUPS = frozenset({"성토면다짐", "초류종자살포", "지장목제거", "층따기", "면고르기"}) #: 집계 합계 줄 — 내역 줄이 아니라 검산용이다. diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py index 6967234a..61303b3d 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py @@ -14,6 +14,7 @@ from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import ( BLOCKED_FORMULA_MISSING, BLOCKED_INPUT_MISSING, BLOCKED_UNIT_DATA_MISSING, + GROUND_SPLIT_GROUPS, HAUL_SUMMARY_GROUPS, METHOD_TO_GROUND, NOTE_HAUL_IN_SUMMARY, @@ -114,7 +115,12 @@ def _earthwork_rows( group = str(row.get("group") or "") if not group: continue - ground = row.get("item") or None + # ⚠ `item` 이 **지반 갈래인 공종**은 정해져 있다(흙깎기·측구터파기·구조물터파기). + # 그 밖(지장목제거의 「뿌리뽑기·잡관목제거」 같은 **작업 갈래**)을 갈래로 읽으면 + # 「시공법 미지정으로 공종을 못 고름」이라는 **틀린 사유**가 붙는다(2026-09-09 실측). + is_ground_split = group in GROUND_SPLIT_GROUPS + ground = (row.get("item") or None) if is_ground_split else None + work_kind = None if is_ground_split else (row.get("item") or None) origin = ORIGIN_SLOPE if group in SLOPE_GROUPS else ORIGIN_EARTHWORK # ⚠ 운반은 **집계에도 오르고 운반표에도 오른다** — 내역 줄은 운반표 쪽 하나뿐이다. is_subtotal = group in SUBTOTAL_GROUPS or group in HAUL_SUMMARY_GROUPS @@ -142,7 +148,8 @@ def _earthwork_rows( { "work_item_code": code, "name": group, - "spec": str(row.get("spec") or ""), + # 작업 갈래가 있으면 **규격 칸**에 적는다 — 갈래 축(`ground_class`)이 아니다. + "spec": str(work_kind or row.get("spec") or ""), "unit": unit, "quantity": amount, # 반영률 — 곱하기는 여기가 끝이다. 받는 쪽은 적기만 한다. From 0e841b35f196608aaf83d7f1ef32ffaac3fdb6aa Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 12:15:56 +0900 Subject: [PATCH 32/36] =?UTF-8?q?feat(B09):=20=ED=99=95=EC=A0=95=205?= =?UTF-8?q?=EC=B0=A8=20=E2=80=94=20=EB=B9=84=ED=83=88=20=ED=84=B0=ED=8C=8C?= =?UTF-8?q?=EA=B8=B0=20=EB=85=B8=ED=8B=B0=EC=8A=A4=EB=A5=BC=20=EA=B1=B7?= =?UTF-8?q?=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 확정 5차 「비탈 터파기, 지금 이대로」 ⇒ 구조물터파기·되메우기·잔토처리 세 줄의 「확정 대기」 문구를 걷음. **값은 안 바뀜** — 11,256,879원이 확정된 값이 됨. ⚠ 표는 지우지 않고 **비워** 둠 — 같은 성격의 자리가 또 생기면 여기 적으면 됨. 적을 때 「왜 대기인지」와 「정해지면 얼마나 움직이는지」를 함께 적으라는 규칙을 주석에 남김. 검증: pytest 303 통과 · 본체 31,118,251원 그대로 Co-Authored-By: Claude Opus 5 (1M context) --- .../B09_Estimation_BillOfQuantities_Rows.py | 22 +++++-------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py index 9d997dbb..41354814 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py @@ -346,22 +346,12 @@ def _leaf_row( #: 수량 산식이 **사용자 확정을 기다리는** 공종 — 금액은 세우되 그 사실을 함께 싣는다. #: -#: ⚠ **값을 우리가 바꾸지 않는다.** 어느 쪽으로 갈지는 설계 판단이다(계획서 4-12 3단계). -#: -#: ⚠ **2026-09-09 문구를 고쳤다.** 처음에는 「실무는 기초만 세고 우리는 벽 전체를 센다」로 -#: 적었는데 **그것이 틀렸다** — 우리 식은 정본(기초 0.45 + 비탈) 그대로이고, 6.6배 차이는 -#: **소광리 시트가 비탈 터파기 줄을 안 적어서** 난 것이다. 다투는 자리는 「우리 식이 -#: 맞는가」가 아니라 **「비탈 터파기를 셀 것인가」**다. -_PENDING_FORMULA: dict[str, str] = { - "FP-09-13": ( - "⚠ 비탈 터파기를 셀지 확정 대기입니다 — 정본은 세고(기초 0.45 + 비탈) 다른 실무 " - "시트는 안 셉니다. 안 세는 쪽으로 확정되면 이 줄 금액이 약 1/7 로 줄어듭니다." - ), - "FP-09-14": ( - "⚠ 되메우기도 같은 확정에 걸립니다 — 비탈 터파기를 안 세면 되메울 부피도 함께 줍니다." - ), - "FP-09-15": ("⚠ 잔토처리도 같은 확정에 걸립니다 — 터파기와 되메우기의 차라 같은 뿌리입니다."), -} +#: ⚠ **2026-09-09 저녁 비었다.** 걸려 있던 셋(구조물터파기·되메우기·잔토처리)이 +#: **사용자 확정 5차로 닫혔다** — 「비탈 터파기, 지금 이대로」. 값은 안 바뀌었고 +#: 11,263,899원이 **확정된 값**이 됐다. +#: ⚠ **표를 지우지 않고 비워 둔다** — 같은 성격의 자리가 또 생기면 여기 적으면 된다. +#: 적을 때는 **왜 대기인지**와 **정해지면 얼마나 움직이는지**를 함께 적을 것. +_PENDING_FORMULA: dict[str, str] = {} from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note # noqa: E402 From 962bbfe9718662c95ecc46b18ec60af01744abe1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 12:16:09 +0900 Subject: [PATCH 33/36] =?UTF-8?q?feat(B08):=20=EC=9E=84=EB=AA=A9=ED=8C=8C?= =?UTF-8?q?=EC=87=84=20=EC=BC=9C=EB=8A=94=20=EC=B9=B8=20(=ED=99=95?= =?UTF-8?q?=EC=A0=95=205=EC=B0=A8=205=EB=B2=88)=20=E2=80=94=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=20=EA=BA=BC=EC=A7=90=20=C2=B7=20=EA=B7=BC=EC=A3=BC?= =?UTF-8?q?=EC=9D=B4=EC=8B=9D=EC=9D=80=20=EC=B9=B8=20=EC=97=86=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 「기본 안 셈」이되 「현장에 따라 파쇄가 적용될 필요 있음」이라 **임목파쇄만** 켤 수 있게 둠 - ⚠ 안 켠 프로젝트에는 **줄 자체가 없음** — 빈 칸이 「세야 함」으로 읽히지 않게 (부대시설은 법정 의무라 늘 서는 것과 다른 자리) - 켜도 부피는 지어내지 않음 — 넣어야 값이 서고 FP-08-11(이동식 임목 파쇄)이 붙음 - 근주이식(FP-14-02)은 **칸도 안 만듦** — 켤 자리가 없으면 물을 일도 없음 - 설정 `wood_chipping_enabled`·`wood_chipping_volume_m3` + 화면 칸 둘 - tmp/tests/test_b08_wood_chipping.py 신설(5건) Co-Authored-By: Claude Opus 5 (1M context) --- .../B08_Quantity_Engine_Preparation.py | 45 +++++++++++++++++++ B08_Quantity/B08_Quantity_Router_Earthwork.py | 7 +++ B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts | 4 ++ B08_Quantity/B08_Quantity_UI_Page.ts | 37 +++++++++++++++ common_util/common_util_project_settings.py | 5 +++ ui_template/ui_template_locale_b2.ts | 9 ++++ 6 files changed, 107 insertions(+) diff --git a/B08_Quantity/B08_Quantity_Engine_Preparation.py b/B08_Quantity/B08_Quantity_Engine_Preparation.py index fc1c3537..9c1a82c0 100644 --- a/B08_Quantity/B08_Quantity_Engine_Preparation.py +++ b/B08_Quantity/B08_Quantity_Engine_Preparation.py @@ -246,6 +246,48 @@ ROOT_STEPS_NOTE = ( ) +#: ⭐ 2026-09-09 **사용자 확정 5차 5번** — 근주이식·임목파쇄는 **기본 안 셈**. +#: ⚠ 다만 「**현장에 따라 파쇄가 적용될 필요 있음**」이라 **임목파쇄만 켤 수 있는 칸**을 둔다. +#: **기본은 꺼짐**이고, **근주이식은 칸도 안 만든다**(켤 자리가 없으면 물을 일도 없다). +#: ⚠ 켜도 **부피는 지어내지 않는다** — 실무는 부피(영월 78㎥ @56,124)로 세고, 그 부피를 +#: 우리가 든 곳이 없다. 켜면 줄이 서고 **수량 칸이 비어 사유로 드러난다.** +CHIPPING_ITEM = "임목파쇄" +CHIPPING_CODE = "FP-08-11" # 이동식 임목 파쇄 +CHIPPING_OFF_NOTE = ( + "기본 안 셈(확정 5차 5번) — 현장에 따라 필요하면 산출 조건에서 켤 것." + " 근주이식(FP-14-02)은 칸도 두지 않음" +) +CHIPPING_ON_NOTE = ( + "켜져 있음(확정 5차 5번) — ⚠ **파쇄할 부피(㎥)를 든 곳이 없어** 물량이 안 섬." + " 실무는 부피로 셈(영월 78㎥). 산출 조건에 부피를 넣으면 값이 섬" +) + + +def chipping_rows(enabled: Any, volume_m3: Any) -> list[dict[str, Any]]: + """임목파쇄 — **켰을 때만** 줄이 선다. 끄면 줄 자체를 안 낸다. + + ⚠ 끈 상태에서 줄을 세우면 **빈 칸이 「세야 함」으로 읽힌다**(부대시설과 다른 자리 — + 그쪽은 법정 의무라 줄이 늘 서고, 이쪽은 **셀지 말지가 설계 판단**이다). + """ + if not bool(enabled): + return [] + try: + amount = float(volume_m3) if volume_m3 not in (None, "") else None + except (TypeError, ValueError): + amount = None + return [ + { + "group": "준비공", + "item": CHIPPING_ITEM, + "unit": "㎥", + "amount": amount, + "status": STATUS_READY if amount and amount > 0 else STATUS_PENDING, + "reason": CHIPPING_ON_NOTE if not amount else "산출 조건에서 넣은 부피 (확정 5차 5번)", + "work_item_code": CHIPPING_CODE, + } + ] + + def _root_steps_rows( slope: dict[str, float], stand_volume_class: str | None ) -> list[dict[str, Any]]: @@ -588,6 +630,8 @@ def build_table( ancillary_counts: dict[str, Any] | None = None, topsoil_haul_distance_m: float | None = None, stand_volume_class: str | None = None, + chipping_enabled: Any = False, + chipping_volume_m3: Any = None, ) -> dict[str, Any]: """화면·인계가 그대로 쓰는 모양. **못 서는 줄도 목록에 남는다.**""" rows = ( @@ -599,6 +643,7 @@ def build_table( stand_volume_class, ) + erosion_rows(structures, names) + + chipping_rows(chipping_enabled, chipping_volume_m3) + ancillary_rows(ancillary_counts) ) return { diff --git a/B08_Quantity/B08_Quantity_Router_Earthwork.py b/B08_Quantity/B08_Quantity_Router_Earthwork.py index 8fd45e00..cc53eb50 100644 --- a/B08_Quantity/B08_Quantity_Router_Earthwork.py +++ b/B08_Quantity/B08_Quantity_Router_Earthwork.py @@ -142,6 +142,9 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse: settings.get("topsoil_haul_distance_m"), # 임목축적 등급 — 품셈 9-21 제근이 소·중·밀로 갈리는 축(본수가 아니다). settings.get("stand_volume_class"), + # 임목파쇄 — 기본 꺼짐. 켠 프로젝트에서만 줄이 선다(확정 5차 5번). + settings.get("wood_chipping_enabled"), + settings.get("wood_chipping_volume_m3"), ) method, method_is_default = concrete_placing_method(settings) # ⚠ 금액에 바로 걸리는 값이라 「기본값으로 돌고 있음」을 응답에 실어 화면이 띄우게 한다. @@ -409,6 +412,9 @@ class QuantitySettingsBody(BaseModel): stand_volume_class: str | None = None # 규준틀 개소당 재료 — `{자재명: 수량}`. 비우면 제안값(실무 관측)이 선다. frame_material: dict[str, Any] | None = None + # 임목파쇄 — 기본 꺼짐(확정 5차 5번). 켜면 줄이 서고, 부피를 넣으면 값이 선다. + wood_chipping_enabled: bool | None = None + wood_chipping_volume_m3: float | None = None #: `None` 이 「안 정함」을 뜻하는 칸 — 저장에서 **버리지 않고 그대로 덮어쓴다**. @@ -419,6 +425,7 @@ NULLABLE_SETTING_KEYS = ( "spoil_site_distance_m", "rubble_base_thickness_m", "topsoil_haul_distance_m", + "wood_chipping_volume_m3", ) diff --git a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts index 7eb74241..95c9548b 100644 --- a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts +++ b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts @@ -83,6 +83,10 @@ export interface QuantitySettings { stand_volume_class?: string | null; /** 규준틀 개소당 재료 — `{자재명: 수량}`. 비우면 제안값(실무 관측)이 선다. */ frame_material?: Record; + /** 임목파쇄 — 기본 꺼짐(확정 5차 5번). 켜야 줄이 선다. */ + wood_chipping_enabled?: boolean | null; + /** 파쇄 부피(㎥) — 켜도 이 값이 없으면 줄만 서고 사유가 남는다. */ + wood_chipping_volume_m3?: number | null; } export interface EarthworkTable { diff --git a/B08_Quantity/B08_Quantity_UI_Page.ts b/B08_Quantity/B08_Quantity_UI_Page.ts index d710a543..3c5bc69c 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.ts +++ b/B08_Quantity/B08_Quantity_UI_Page.ts @@ -94,6 +94,8 @@ async function saveQuantitySettings(projectId: string, draft: DraftSettings): Pr topsoil_haul_distance_m: draft.topsoil_haul_distance_m, stand_volume_class: draft.stand_volume_class, frame_material: draft.frame_material, + wood_chipping_enabled: draft.wood_chipping_enabled, + wood_chipping_volume_m3: draft.wood_chipping_volume_m3, // 개소는 **통째로** 보낸다 — 지운 항목까지 그대로 가야 되돌릴 길이 있다. ancillary_counts: draft.ancillary_counts, }), @@ -284,6 +286,9 @@ interface DraftSettings { stand_volume_class: string; // 규준틀 개소당 재료 — 비우면 **제안값(실무 관측)**이 선다. 값이 아니라 「고칠 수 있음」이 요점. frame_material: Record; + // 임목파쇄 — **기본 꺼짐**(확정 5차 5번). 켜야 줄이 선다. 근주이식은 칸 자체가 없다. + wood_chipping_enabled: boolean; + wood_chipping_volume_m3: number | null; // 자재별 관급/사급 — 표 안에서 줄마다 고른 값. material_supply: Record; dirty: boolean; @@ -588,6 +593,36 @@ function buildQuantitySidePanel( } panel.append(hintRow(L("B08_Quantity_Side_Frame_Hint"))); + // ── 임목파쇄 — ⚠ **기본 꺼짐**(확정 5차 5번). 「셀지 말지가 설계 판단」이라 켜야 줄이 선다. + // ⚠ 근주이식은 **칸 자체를 안 만든다** — 켤 자리가 없으면 물을 일도 없다. + panel.append(field(L("B08_Quantity_Side_Chipping"), "")); + panel.append( + selectField( + L("B08_Quantity_Chipping_Label"), + draft.wood_chipping_enabled ? "on" : "", + [ + { value: "", label: L("B08_Quantity_Chipping_Off") }, + { value: "on", label: L("B08_Quantity_Chipping_On") }, + ], + (value) => { + draft.wood_chipping_enabled = value === "on"; + draft.dirty = true; + }, + ), + ); + panel.append( + optionalNumberField( + L("B08_Quantity_Chipping_Volume"), + draft.wood_chipping_volume_m3, + "1", + (value) => { + draft.wood_chipping_volume_m3 = value; + draft.dirty = true; + }, + ), + ); + panel.append(hintRow(L("B08_Quantity_Side_Chipping_Hint"))); + // ── 콘크리트 타설 방식 — ⚠ **금액에 바로 걸리는 값**이라 잠정임을 조용히 두지 않는다 ── panel.append(field(L("B08_Quantity_Side_Placing"), "")); panel.append( @@ -846,6 +881,8 @@ export async function renderB08Quantity(root: HTMLElement): Promise { topsoil_haul_distance_m: (stored.topsoil_haul_distance_m as number | null) ?? null, stand_volume_class: (stored.stand_volume_class as string) ?? "", frame_material: { ...((stored.frame_material ?? {}) as Record) }, + wood_chipping_enabled: Boolean(stored.wood_chipping_enabled), + wood_chipping_volume_m3: (stored.wood_chipping_volume_m3 as number | null) ?? null, ancillary_counts: { ...((stored.ancillary_counts ?? {}) as Record), }, diff --git a/common_util/common_util_project_settings.py b/common_util/common_util_project_settings.py index 805a72fe..28a97660 100644 --- a/common_util/common_util_project_settings.py +++ b/common_util/common_util_project_settings.py @@ -139,6 +139,11 @@ def default_settings() -> dict[str, Any]: # 품셈 11-2·11-3 [주]④ 가 「재료량은 설계수량에 따른다」라 값을 안 주는 자리이고, # 세는 것 자체는 확정이라 **제안값 + 고칠 수 있게**로 둔다(확정 ⑨·⑩ 과 같은 틀). "frame_material": {}, + # 임목파쇄 — **기본 꺼짐**(확정 5차 5번). 현장에 따라 켤 수 있는 칸. + # ⚠ 근주이식은 칸도 두지 않는다 — 「셀지 말지가 설계 판단」이라 켤 자리가 없으면 + # 물을 일도 없다. 켜도 **부피는 지어내지 않는다**(아래 칸이 비면 줄만 서고 사유). + "wood_chipping_enabled": False, + "wood_chipping_volume_m3": None, "dataset_versions": {}, }, "estimation": { diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 7e43adb6..553bda58 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -686,6 +686,15 @@ export const ui_locales_b2 = { "제근(품셈 9-21)이 이 등급으로 갈립니다 — ⚠ 본수가 아니라 축적이고, 산림조사부·영림계획에서 옮겨 적는 값입니다", "Root removal (9-21) splits by this class — stand volume, not tree count; copied from the forest survey", ], + B08_Quantity_Side_Chipping: ["임목파쇄", "Wood Chipping"], + B08_Quantity_Chipping_Label: ["임목파쇄를 셀 것인가", "Count wood chipping"], + B08_Quantity_Chipping_Off: ["안 셈(기본)", "No (default)"], + B08_Quantity_Chipping_On: ["셈", "Yes"], + B08_Quantity_Chipping_Volume: ["파쇄 부피(㎥)", "Chipping volume (㎥)"], + B08_Quantity_Side_Chipping_Hint: [ + "기본은 안 셉니다(확정 5차 5번) — 현장에 따라 필요하면 켜세요. ⚠ 켜도 부피를 넣어야 값이 섭니다(실무는 부피로 셈)", + "Off by default — turn on per site. Volume must be entered for the row to carry a quantity", + ], B08_Quantity_Side_Frame: ["규준틀 개소당 재료", "Batter Board Materials (per unit)"], B08_Quantity_Side_Frame_Hint: [ "⚠ 채워진 값은 실무 관측값(제안값)이고 법정 기준이 아닙니다 — 품셈 11-2·11-3 [주]④ 는 「재료량은 설계수량에 따른다」로만 둡니다. 손율은 원문값(비탈 50% · 수평 80%)", From 92fd9092128013a299ff09f81d56b5e77a533c5c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 12:16:39 +0900 Subject: [PATCH 34/36] =?UTF-8?q?feat(B08):=20=EC=83=88=20=EC=B9=B8=20?= =?UTF-8?q?=EB=8B=A4=EC=84=AF=EC=9D=84=20=EA=B3=A8=EB=A7=89=EC=9D=B4=C2=B7?= =?UTF-8?q?=EA=B0=9C=EA=B1=B0=C2=B7=EB=AC=BC=EA=B5=AC=EB=A9=8D=EC=97=90=20?= =?UTF-8?q?=EB=AC=BC=EB=A6=BC=20=E2=80=94=20=EB=B0=A9=EC=88=98=EB=A1=9C?= =?UTF-8?q?=EA=B0=80=20=EC=A0=95=EB=A9=B4=EC=A0=81=EC=9D=84=20=EA=B0=80?= =?UTF-8?q?=EB=A6=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 골막이 (erosion_check) - spillway 「있음/없음」 — 「없음」이면 파형강관 0.5024㎡ 를 안 뺌. 정면적이 밑수라 일곱 줄이 +2.1~3.1 % 움직임. 「있음」이 정본과 같은 값(정본은 방수로 치수 0 인 개소에서도 뺐음). 안 고르면 정본 쪽으로 서고 사유가 뜸. - stone_kind · back_len_cm 를 읽음. 「칸이 없다」던 사유가 「안 골랐다」로 바뀜. 돌 무게 헬퍼 한 벌 (stone_weight_per_m2) - 확정 5차 큰 것 7 규칙을 돌쌓기·골막이가 같이 씀. 계산식이 기본, 야면석만 관측표. 두 벌로 두면 같은 확정이 구조물마다 갈림. 물구멍 헬퍼 한 벌 (weep_hole_spec) - weep_hole_diameter_mm · weep_hole_area_m2 를 읽고 비우면 Ø50 · 2㎡당. 지름은 규격(spec)으로 실어 단가가 갈리게 함. - 정본이 개소당 관 길이를 평균두께(0.83m)로 잡는 것을 주석에 남김 — 우리 상수 0.5m 와 갈리는 자리이고 미결임. 개거 — ditch_spec 칸이 와서 두 규격을 고를 수 있음. 시험 — 등록부 정책 예외에 새 칸 넷(ditch_spec · weep_hole 둘 · fill_slope_ratio)과 사토장 둘을 넣음. 613 통과. Co-Authored-By: Claude Opus 5 (1M context) --- .../B08_Quantity_Engine_UnitQuantity.py | 98 +++++++++++++------ ..._Quantity_Engine_UnitQuantity_Revetment.py | 58 ++++++----- 2 files changed, 103 insertions(+), 53 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py index 87e08afa..b89f9d6e 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -99,6 +99,55 @@ STONE_WEIGHT_FORMULA_BASIS = ( "⭐ 확정 5차 — 뒷길이 × 0.77(채움률) × 2.65(비중). ⚠ 잠정 — 실무자 협의 후 명확히 할 값" ) +#: 물구멍 칸 — 2026-09-09 에 등록부에 생겼다(랩탑 메인 `0b0763ed`). **빈 칸이 기본**이다. +#: ⚠ 기본 Ø50 은 **국가기준**이고 2㎡당 1개소는 실무 관측값이다(법은 「2~3㎡당 1개소 이상」). +WEEP_HOLE_DIAMETER_KEY = "weep_hole_diameter_mm" +WEEP_HOLE_AREA_KEY = "weep_hole_area_m2" +WEEP_HOLE_DIAMETER_DEFAULT_MM = 50 + + +def weep_hole_spec(options: dict[str, Any]) -> tuple[float, int, str]: + """(개소당 벽면적 ㎡, 관 지름 ㎜, 근거). 안 정하면 기본으로 서고 그 사실이 근거에 남는다.""" + area = _num(options.get(WEEP_HOLE_AREA_KEY), 0.0) + diameter = _num(options.get(WEEP_HOLE_DIAMETER_KEY), 0.0) + area_given = area > 0 + dia_given = diameter > 0 + if not area_given: + area = STONE_MASONRY["weep_hole_area_m2"] + if not dia_given: + diameter = WEEP_HOLE_DIAMETER_DEFAULT_MM + basis = ( + f"{area:g}㎡당 1개소 · Ø{int(diameter)}" + + ("" if area_given else " · ⚠ 면적 안 정함 — 실무 관측 2㎡(법은 2~3㎡당 1개소 이상)") + + ("" if dia_given else " · ⚠ 지름 안 정함 — 국가기준 Ø50") + ) + return area, int(diameter), basis + + +def stone_weight_per_m2( + back_cm: int, kind: str, observed: float | None +) -> tuple[str, float | None, str, str]: + """(줄 이름, ton/㎡, 근거, source) — ⭐ 확정 5차 큰 것 7. + + **계산식이 기본**이고 야면석만 관측표다. 돌쌓기·골막이가 같은 규칙을 쓰도록 여기 한 벌만 둔다. + """ + if kind in STONE_WEIGHT_OBSERVED_KINDS: + basis = ( + f"× {observed} ton/㎡ (뒷길이 {back_cm}㎝) · {STONE_WEIGHT_BASIS}" + if observed is not None + else "" + ) + return kind, observed, basis, STONE_WEIGHT_SOURCE + ton = back_cm / 100.0 * STONE_PACKING_RATIO * STONE_DENSITY_TON_M3 + basis = ( + f"× {ton:.3f} ton/㎡ = 뒷길이 {back_cm / 100.0:g} × {STONE_PACKING_RATIO}" + f" × {STONE_DENSITY_TON_M3}" + + (f" · {kind}" if kind else " · ⚠ 돌 종류를 안 골랐음") + + f" · {STONE_WEIGHT_FORMULA_BASIS}" + ) + return kind or "돌", ton, basis, "" + + #: 뒷채움(막자갈) 폭 — ⭐ 사용자 확정 5차 작은 것 3 「막자갈 = (상 + 하) ÷ 2 × H」. #: ⚠⚠ **여기 상·하는 벽 두께가 아니라 뒷채움 폭이다.** 벽 두께(0.75/0.9)를 끌어다 쓰면 #: H=2.0 에서 실무 0.75 가 1.66(입적)으로 튄다 — 옛 식이 곧 그 자리였다. @@ -874,35 +923,18 @@ def stone_masonry( ) ) - # 돌 무게 — ⭐ 확정 5차 큰 것 7. **계산식이 기본**이고 야면석만 관측표다. + # 돌 무게 — ⭐ 확정 5차 큰 것 7. **계산식이 기본**이고 야면석만 관측표다(헬퍼 한 벌). # ⚠ 안 고른 경우도 **계산식**으로 선다 — 정본 여섯 탭이 종류를 안 적고 그 식을 쓰고, # 그래야 정본 H=2.0 의 「1.92 톤」과 맞는다(관측표 0.88 로 서면 1.84 로 4 % 낮다). - # 이름은 정본 줄 이름 그대로 **「돌」**이고, 종류를 고르면 그 이름으로 바뀐다. - if kind_label in STONE_WEIGHT_OBSERVED_KINDS: - # 야면석은 공극이 커 계산식이 안 맞는다 — 원문 관측표를 그대로 쓴다. - stone_name = kind_label - stone_ton = table["stone_ton_per_m2"] - weight_basis = ( - f"돌쌓기 × {stone_ton} ton/㎡ (뒷길이 {back_cm}㎝) · {STONE_WEIGHT_BASIS}" - if stone_ton is not None - else "" + stone_name, stone_ton, weight_tail, weight_source = stone_weight_per_m2( + back_cm, kind_label, table["stone_ton_per_m2"] + ) + weight_basis = f"돌쌓기 {weight_tail}" if weight_tail else "" + if not kind_label: + notes.append( + "돌 종류를 안 골라 **계산식**(뒷길이 × 0.77 × 2.65)으로 섰습니다 — " + "야면석이면 계산식이 안 맞아 관측표로 갈립니다" ) - weight_source = STONE_WEIGHT_SOURCE - else: - stone_name = kind_label or "돌" - stone_ton = back_m * STONE_PACKING_RATIO * STONE_DENSITY_TON_M3 - weight_basis = ( - f"돌쌓기 × {stone_ton:.3f} ton/㎡ = 뒷길이 {back_m:g} × {STONE_PACKING_RATIO}" - f" × {STONE_DENSITY_TON_M3}" - + (f" · {kind_label}" if kind_label else " · ⚠ 돌 종류를 안 골랐음") - + f" · {STONE_WEIGHT_FORMULA_BASIS}" - ) - weight_source = "" - if not kind_label: - notes.append( - "돌 종류를 안 골라 **계산식**(뒷길이 × 0.77 × 2.65)으로 섰습니다 — " - "야면석이면 계산식이 안 맞아 관측표로 갈립니다" - ) if stone_ton is None: # 원본 표가 비어 있는 칸이다 — 지어내지 않고 알린다(PLAN 8-8 ㉮). notes.append(f"뒷길이 {back_cm}㎝ 의 돌중량이 원본 표에 없어 {stone_name}을 내지 못함") @@ -973,16 +1005,20 @@ def stone_masonry( # 지금 쓰는 13-4 계열에는 제잡비 행 자체가 없어 겹치지 않는다(전수 확인). # ⚠ 관종·지름은 미확정 — 법은 「지름 3~6㎝ 파이프」, 실무 관측은 Ø50. 규격이 정해지면 # 이름에 붙인다(`물구멍 Ø50`). 지어내지 않고 규격 없는 이름으로 둔다. + # ⚠ 「미확정」만 적으면 사용자가 무엇을 정해야 하는지 모른다 — + # **지금 무슨 값으로 돌고 있는지**를 함께 적는다(원단위 미확보와 같은 방식). + # ⓘ 정본은 개소당 관 길이를 **평균두께**로 잡는다(H=2.0 에서 0.83m). 우리는 상수 0.5m 라 + # 그만큼 짧게 선다 — 관은 벽을 가로지르므로 정본 쪽이 이치에 맞는다(미결). + hole_area, hole_dia, hole_basis = weep_hole_spec(options) components.append( Component( "물구멍관", "m", - masonry_area / constants["weep_hole_area_m2"] * constants["weep_hole_length_m"], + masonry_area / hole_area * constants["weep_hole_length_m"], DESTINATION["물구멍관"], - # ⚠ 「미확정」만 적으면 사용자가 무엇을 정해야 하는지 모른다 — - # **지금 무슨 값으로 돌고 있는지**를 함께 적는다(원단위 미확보와 같은 방식). - "돌쌓기 ÷ 2㎡/개소 × 0.5 m/개소 · ⚠ 잠정: 관 Ø 미정(법 3~6㎝ / 실무 Ø50) ·" - " 간격 2.0㎡당 1개소(법 2~3㎡당 1개소 이상)", + f"돌쌓기 ÷ {hole_area:g}㎡/개소 × {constants['weep_hole_length_m']:g} m/개소" + f" · {hole_basis}", + spec=f"Ø{hole_dia}", ) ) diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py index 8a0fdf5c..c8e16357 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py @@ -210,6 +210,8 @@ def erosion_check_dam( STONE_MASONRY, _back_length, fill_concrete_mpa, + stone_weight_per_m2, + weep_hole_spec, ) form = str(options.get("form") or "").strip() @@ -239,8 +241,8 @@ def erosion_check_dam( return [], [f"뒷길이 {back_cm}㎝ 는 품셈 표(25·30·35·45·55·60·75㎝)에 없어 계수가 없습니다"] if not any(options.get(key) is not None for key in ("back_len_cm", "stone_back_length_cm")): notes.append( - f"⚠ 골막이 제원에 **뒷길이 칸이 없어** 기본 {back_cm}㎝ 로 섰습니다 " - "— 그 값이 두께식·고임돌·야면석·채움콘크리트 계수를 모두 가릅니다" + f"⚠ 뒷길이를 안 골라 기본 {back_cm}㎝ 로 섰습니다 " + "— 그 값이 두께식·고임돌·돌 무게·채움콘크리트 계수를 모두 가릅니다" ) back_m = back_cm / 100.0 @@ -252,13 +254,21 @@ def erosion_check_dam( slope_note = f"1:{slope:g} — 정본 「반수면비탈」 붙박이(안 정함)" # ① 정면적(사다리꼴) − ② 방수로 파형강관 단면 + # ⚠ 「없음」이면 안 뺀다 — 정면적이 밑수라 **열한 줄이 통째로 움직인다.** + # ⓘ 정본은 방수로 치수가 0 인 개소에서도 뺐다. 그러므로 **정본과 같은 값은 「있음」**이고, + # 「없음」은 정본보다 크게 나온다. 안 고르면 정본 쪽(「있음」)으로 서고 그 사실을 알린다. trapezoid = _floor2((top_m + bottom_m) / 2 * height_m) + spillway = str(options.get("spillway") or "").strip() pipe = const["spillway_pipe_r_m"] ** 2 * const["spillway_pipe_pi"] - front_area = trapezoid - pipe - notes.append( - f"방수로 파형강관 {pipe:.4f}㎡ 를 정면적에서 뺐습니다 — 정본 붙박이(Ø0.8) " - "⚠ 「방수로 없음」을 고를 칸이 등록부에 없어 늘 뺍니다" - ) + if spillway == "없음": + front_area = trapezoid + notes.append("방수로 「없음」이라 파형강관 단면을 안 뺐습니다 — 정본보다 정면적이 큽니다") + else: + front_area = trapezoid - pipe + notes.append( + f"방수로 파형강관 {pipe:.4f}㎡ 를 정면적에서 뺐습니다 — 정본 붙박이(Ø0.8)" + + ("" if spillway else " · ⚠ 방수로를 안 골라 정본대로 뺐습니다") + ) masonry = _floor2(front_area * _round2(math.hypot(slope, 1.0))) top_t = _floor2(back_m + const["thickness_top_per_m"] * height_m) @@ -285,16 +295,20 @@ def erosion_check_dam( ), ] - stone_ton = coeff["stone_ton_per_m2"] + # 돌 무게 — ⭐ 확정 5차 큰 것 7. 돌쌓기와 **같은 헬퍼**를 쓴다(규칙이 하나여야 한다). + kind = str(options.get("stone_kind") or "").strip() + stone_name, stone_ton, weight_tail, _src = stone_weight_per_m2( + back_cm, kind, coeff["stone_ton_per_m2"] + ) if stone_ton is None: - notes.append(f"뒷길이 {back_cm}㎝ 에 야면석 중량 칸이 비어 있어 돌을 못 세웠습니다") + notes.append(f"뒷길이 {back_cm}㎝ 에 {stone_name} 중량 칸이 비어 있어 돌을 못 세웠습니다") else: rows.append( ( - "야면석", + stone_name, "ton", _ceil2(base_area * stone_ton), - f"(돌쌓기+돌붙임) {base_area:.4f}㎡ × {stone_ton} ton/㎡ (뒷길이 {back_cm}㎝)", + f"(돌쌓기+돌붙임) {base_area:.4f}㎡ {weight_tail}", ) ) wedge_per = coeff["wedge_stone_m3_per_m2"] @@ -312,15 +326,14 @@ def erosion_check_dam( "입적 − ((돌쌓기+돌붙임) × 뒷길이 × 2/3 + 고임돌)", ) ) + hole_area, hole_dia, hole_basis = weep_hole_spec(options) rows.append( ( "물구멍관", "m", - _round2( - masonry / STONE_MASONRY["weep_hole_area_m2"] * STONE_MASONRY["weep_hole_length_m"] - ), - f"돌쌓기 ÷ {STONE_MASONRY['weep_hole_area_m2']:g}㎡/개소 × " - f"{STONE_MASONRY['weep_hole_length_m']:g}m/개소", + _round2(masonry / hole_area * STONE_MASONRY["weep_hole_length_m"]), + f"돌쌓기 ÷ {hole_area:g}㎡/개소 × {STONE_MASONRY['weep_hole_length_m']:g}m/개소" + f" · {hole_basis} (Ø{hole_dia})", ) ) @@ -360,9 +373,10 @@ def erosion_check_dam( spec=f"{mpa}", ) ) - if not options.get("stone_kind"): + if not kind: notes.append( - "돌 종류 칸이 골막이 등록부에 없어 **야면석**으로 섰습니다 — 정본 탭이 그 돌입니다" + "돌 종류를 안 골라 **계산식**(뒷길이 × 0.77 × 2.65)으로 섰습니다 " + "— 정본 탭은 야면석이고, 야면석은 계산식이 안 맞아 관측표로 갈립니다" ) return components, notes @@ -389,8 +403,8 @@ def erosion_check_dam( #: 본체 표를 못 찾았으므로 **「원문에 없음」으로 두고 그 사실을 화면에 적는다** — #: 「콘크리트 개거인데 콘크리트가 0」이 우리 결함으로 읽히지 않게. #: -#: ⚠ **규격이 붙박이다** — 정본에 「150×200」 하나뿐이고 `open_ditch` 등록부에 **옵션이 하나도 -#: 없다**(규격 칸 없음). 칸이 생기면 이 표를 열면 된다. +#: ✅ **규격 칸이 생겼다**(2026-09-09 랩탑 메인 `0b0763ed`) — 두 규격을 고를 수 있고, +#: 비우면 「콘크리트 개거 150×200」으로 선다. OPEN_DITCH_FORMS: dict[str, dict[str, Any]] = { "콘크리트 개거 150×200": { "rows": ( @@ -500,8 +514,8 @@ def open_ditch(length_m: float, options: dict[str, Any]) -> tuple[list[Component for name, unit, per_m, basis in table["rows"] ] notes: list[str] = [] - if "ditch_spec" not in options: - notes.append(f"규격이 「{spec}」 붙박이입니다 — 개거 제원에 규격 칸이 없습니다") + if not options.get("ditch_spec"): + notes.append(f"규격을 안 골라 「{spec}」으로 섰습니다 — 제원에서 고를 수 있습니다") if spec == "콘크리트 개거 150×200": notes.append( "⚠ 이 표에는 **콘크리트 본체 줄이 없습니다** — 정본 원문 그대로입니다" From db4acbf1acc338cfc3cc3bace15380a0212df1dd Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 12:32:43 +0900 Subject: [PATCH 35/36] =?UTF-8?q?fix(B08):=20=EC=B1=84=EC=9B=80=EC=BD=98?= =?UTF-8?q?=ED=81=AC=EB=A6=AC=ED=8A=B8=20=EA=B7=BC=EA=B1=B0=EC=97=90=20?= =?UTF-8?q?=EB=8F=8C=20=EC=A2=85=EB=A5=98=20=ED=91=9C=EC=8B=9C=20+=20?= =?UTF-8?q?=EA=B3=A8=EB=A7=89=EC=9D=B4=20=EC=A3=BC=EC=84=9D=20=EC=A4=84?= =?UTF-8?q?=EB=B0=94=EA=BF=88=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 채움콘크리트 계수가 돌 종류로 갈리는데(깬돌 0.2 · 야면석 0.15) 근거 문구에 종류가 안 적히던 자리 — 값은 바뀌는데 왜 바뀌었는지가 화면에 안 보였음. 고임돌은 이미 적고 있었으므로 같은 규칙으로 맞춤. - 병합 해결 과정에서 골막이 두께식 주석 두 줄이 한 줄로 붙은 것 복구. Co-Authored-By: Claude Opus 5 (1M context) --- B08_Quantity/B08_Quantity_Engine_UnitQuantity.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py index b89f9d6e..edf519cb 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -252,7 +252,8 @@ def stone_coefficients(options: dict[str, Any], back_cm: int) -> tuple[dict[str, #: ⚠⚠ **둘 다 뒷길이(ℓ3) 기반이다** — C26 의 「0.45」는 상수가 아니라 `P6/100`(뒷길이 45㎝)을 #: 읽은 값이었다(2026-09-09 랩탑 보조가 셀 참조로 확인). 앞서 여기 「0.45 + 0.1H」로 적어 #: **상수처럼 보이게** 두었는데, 그렇게 굳으면 **뒷길이를 바꿔도 골막이 값이 안 움직이는** -#: 결함이 조용히 남는다.#: ⚠ 상수만 보고 골막이에 가져다 쓰면 **값이 나오므로 아무 시험도 안 잡는다.** +#: 결함이 조용히 남는다. +#: ⚠ 상수만 보고 골막이에 가져다 쓰면 **값이 나오므로 아무 시험도 안 잡는다.** #: ⚠ 골막이는 그 밖에도 셋이 더 다르다 — 밑수가 **돌쌓기 + 돌붙임**(기슭막이는 돌쌓기만) · #: 단위가 **개소당**(기슭막이는 m당) · 정면적이 **사다리꼴**. STONE_MASONRY = { @@ -979,8 +980,12 @@ def stone_masonry( "㎥", masonry_area * _num(table["fill_concrete_m3_per_m2"]), DESTINATION["채움콘크리트"], + # ⚠ 돌 종류로 계수가 갈리는데(깬돌 0.2 · 야면석 0.15) 근거에 종류가 안 + # 적히던 자리다 — 값은 바뀌는데 **왜 바뀌었는지가 안 보였다**(고임돌은 + # 적고 있었다). 2026-09-09 맞춤. f"돌쌓기 × {table['fill_concrete_m3_per_m2']} ㎥/㎡ (뒷길이 {back_cm}㎝)" - f" · {mpa_basis}", + + (f" · {kind_label}" if kind_label else "") + + f" · {mpa_basis}", spec=f"{mpa}", ) ) From ed65eac74ef6dfe47c6d72639d30f0ecad3870f5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 12:37:56 +0900 Subject: [PATCH 36/36] =?UTF-8?q?feat(B08):=20=EB=92=B7=EC=B1=84=EC=9B=80?= =?UTF-8?q?=20=ED=8F=AD=20=EA=B7=BC=EA=B1=B0=20=EA=B5=AC=EA=B0=84=20?= =?UTF-8?q?=EB=B0=96=EC=9D=B4=EB=A9=B4=20=EC=82=AC=EC=9C=A0=EB=A5=BC=20?= =?UTF-8?q?=EB=83=84=20=E2=80=94=20=EB=86=92=EC=9D=80=20=EB=B2=BD=EC=9D=B4?= =?UTF-8?q?=20=ED=92=88=EC=85=88=20=EB=B2=94=EC=9C=84=20=EC=95=84=EB=9E=98?= =?UTF-8?q?=EB=A1=9C=20=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)