From 4ca757fadc53c9a5500666a80c9bcd72e705d6c8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 13 Sep 2026 22:46:56 +0900 Subject: [PATCH 1/6] =?UTF-8?q?knowledge(=EC=B6=95C):=2014=EC=9E=A5=20?= =?UTF-8?q?=EC=A0=95=EC=A0=95=20=E2=80=94=20variant=5Fkey=20=EB=8A=94=20?= =?UTF-8?q?=EC=B2=AB=20=EC=97=B4=2024=EA=B0=9C=EB=BF=90=EC=9D=B4=EB=9D=BC?= =?UTF-8?q?=20=EC=A0=95=EB=B3=B8=EC=9C=BC=EB=A1=9C=20=EB=AA=BB=20=EC=94=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0154EMdwPYNPuKc9eZv8SLEX --- docs/raw/verification/2026-09-13_축C_명세.md | 25 +++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/docs/raw/verification/2026-09-13_축C_명세.md b/docs/raw/verification/2026-09-13_축C_명세.md index dca9d4ae..ecac87a9 100644 --- a/docs/raw/verification/2026-09-13_축C_명세.md +++ b/docs/raw/verification/2026-09-13_축C_명세.md @@ -590,7 +590,30 @@ IF 36(비교식 11) · VLOOKUP 13(**전부 근사**) · SUM 47. | 공종 축 ↔ 자원 축 | **서로 다른 표를 잇는 것** | ⛔ 안 됨. 코드로만 | | `#갈래` | **한 공종 안의 조건 딱지**(토질·암종·규격) | ⚠ 지금은 이름으로 맞추고 있어 약함 | -**⇒ 판정 — 마스터의 `variant_key` 를 정본으로 삼을 것.** +### ⚠⚠ 2026-09-13 정정 — 위 판정이 틀렸음 + +**`variant_key` 를 정본으로 못 씀.** 브레인이 「456표에 다 있다」만 보고 「쓸 수 있다」로 넘겼으나, +그 값은 **「표 첫 열 값 24개까지」**(`Build_WorkItemMaster.variant_axis`)임. + +``` +단가표 갈래 제목 294개 중 그 목록에 있는 것 86개 (약 1/3) +나머지 208 — 머리행에 갈래가 있는 표(벌목 「5m이상~8m미만」) + 두 단 머리(「굴착기(무한궤도)0.2·밀」) + 구간 이름 어긋남(표 「0.3~0.7m이하」 ↔ 목록 「0.3m미만」) + 첫 열이 자원 이름인 표 +``` + +⇒ 지금 정본으로 읽으면 **3분의 2가 폴백으로 떨어짐.** + +**고쳐 세운 판정 둘** + +1. **지금은 B09 단가표 제목을 정본으로 둘 것**(모양별 읽기가 이미 만든 것). + `find_variant_code` 의 **느슨한 맞춤**(수의 짝·구간 고르기)이 쓰일 때 **로그로 남길 것** — + **조용히 다른 갈래에 붙는 자리가 바로 거기임** +2. **마스터 생성기가 표 모양(첫 열 / 머리행 / 두 단)대로 `variant_key` 를 다시 뽑게** 할 것 — + **1장 마스터 일감**. 그것이 서면 그때 정본을 옮김 + +### (지난 판정) 마스터의 `variant_key` 를 정본으로 삼을 것 — ⛔ 위 정정으로 뒤집힘 - 마스터 **456표 전부에 `variant_key` 가 이미 있음**(브레인 확인). 그런데 **B09 가 안 읽고** 변형 문자열에서 매번 다시 만들어 씀 From c34ab96763bbd7062635fcde455c630f812ae1c7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 13 Sep 2026 22:54:34 +0900 Subject: [PATCH 2/6] =?UTF-8?q?knowledge(=EC=B6=95C):=20=EA=B7=9C=EC=A0=95?= =?UTF-8?q?=20=EC=97=86=EB=8A=94=20=EB=8B=A8=EC=88=98=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=EB=8A=94=20=EC=86=8C=EC=88=98=202=EC=9E=90=EB=A6=AC=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=EA=B0=92=20=E2=80=94=20=EB=B2=95=EC=A0=81=20?= =?UTF-8?q?=EA=B7=BC=EA=B1=B0=20=EC=97=86=EC=9D=8C=20=ED=99=95=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0154EMdwPYNPuKc9eZv8SLEX --- docs/raw/verification/2026-09-13_축C_명세.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/raw/verification/2026-09-13_축C_명세.md b/docs/raw/verification/2026-09-13_축C_명세.md index ecac87a9..fccec589 100644 --- a/docs/raw/verification/2026-09-13_축C_명세.md +++ b/docs/raw/verification/2026-09-13_축C_명세.md @@ -305,6 +305,19 @@ B08 인계본 `ground_class_aliases` — **B09 가 안 읽음**)을 한 벌로 | **단가산출 성분단가** | 소수 1자리 절사 | 211건 중 137건 설명 | | 내부 계산 | 은행가(최근접-짝수) | STmate 내부. ⚠ 뽑은 엑셀은 사사오입이라 20개 경계 중 10개가 갈림 | +### ⭐ 규정이 없는 자리 — 소수점 2자리가 기본값 (2026-09-13 사용자 확정) + +**단수처리에 법적 근거가 없음이 확인됐음** — 기재부 **예정가격작성기준 전 조문에 +원단위 절사·관급 올림 조문이 없고**, 국고금관리법 §47 과 **발주청 지침 영역**임(안티그래비티 조사). + +``` +규정이 있는 자리 그 규정을 따름 (품셈·발주청 지침·STmate 실증) +규정이 없는 자리 소수점 2자리 · 반올림 ← 기본값. 사용자가 칸으로 바꿈 +``` + +⇒ STmate 관행(절사·은행가)을 따라도 되는 근거가 생겼음. **발주청 지침이 오면 그것으로 덮음.** +⚠ **붙박이로 박지 말 것** — 사용자가 바꿀 칸으로 둘 것. + **식을 접지 말 것 둘** - **노임 시간당 환산** — 식 문자열을 **좌→우 순차 평가**. From 70885683eba85febc6bd4f51fe1fe9839b2610da Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 13 Sep 2026 22:57:57 +0900 Subject: [PATCH 3/6] =?UTF-8?q?knowledge(=EC=B6=95C):=20=EC=B8=B5=20?= =?UTF-8?q?=EC=B0=A8=EB=A1=80=20=EC=A0=95=EC=A0=95=20=E2=80=94=20D=20?= =?UTF-8?q?=EA=B0=80=20=EB=A8=BC=EC=A0=80=20=EC=84=9C=EA=B3=A0=20B=20?= =?UTF-8?q?=EA=B0=80=20D=20=EB=A5=BC=20=EC=B0=B8=EC=A1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0154EMdwPYNPuKc9eZv8SLEX --- docs/raw/verification/2026-09-13_축C_명세.md | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/docs/raw/verification/2026-09-13_축C_명세.md b/docs/raw/verification/2026-09-13_축C_명세.md index fccec589..e9f1be84 100644 --- a/docs/raw/verification/2026-09-13_축C_명세.md +++ b/docs/raw/verification/2026-09-13_축C_명세.md @@ -202,9 +202,22 @@ AR-<종류>-<8자리 16진> 카탈로그에 없는 자원 | **자원 종류** (`resource_kind`) | `labor` · `material` · `machine` | 품셈 줄이 무엇을 부르나 | | **단가 층** (`PriceKind`) | `M·L·S·X·B·D·W` | 단가가 어느 겹에 사나 | -단가 층 넷의 차례 (STC `COSTN` 186행 실측) — -`S 중기취득가(천원) → X 시간당 중기사용료 → B 일위대가 → D 단가산출`, -`L 노임`·`M 자재`는 카탈로그 층(상세 없이 값이 바로 있음). +### ⚠⚠ 층 차례 — 2026-09-13 정정. 화살표 방향이 모호했음 + +**조립(참조) 차례는 아래가 맞음.** 앞서 적은 `… → B → D` 는 **거꾸로 읽힐 수 있었음.** + +``` +L 노임 · M 자재 (카탈로그 — 상세 없이 값이 바로 있음) + ↓ +S 중기취득가(천원) → X 시간당 중기사용료 + ↓ +D 단가산출 (Q 식으로 시간당 단가 ÷ Q) + ↓ +B 일위대가 (D 와 카탈로그를 물어 조립) +``` + +⇒ **D 가 먼저 서고 B 가 D 를 참조함.** 우리 코드는 지금 **D 가 B 를 참조해 뒤집혀 있음**(랩탑 서브 실측). +⚠ 차례가 뒤집히면 **위상순서·재귀·이중계상 판정이 다 영향받음** — 값 정밀도 고치기와 **따로 뗄 것.** --- From 219c7068a95cfb372c3e40e8dc358c066ee8cbd5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 13 Sep 2026 23:14:13 +0900 Subject: [PATCH 4/6] =?UTF-8?q?knowledge(=EC=B6=95C):=20=EB=85=B8=EC=9E=84?= =?UTF-8?q?=20=ED=99=98=EC=82=B0=20=EC=9E=90=EB=A6=BF=EC=88=98=EB=8A=94=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=E2=80=94=2018/18=20=EC=9B=90=20=EB=8B=A8=EC=9C=84=EB=8A=94=20?= =?UTF-8?q?=ED=8B=80=EB=A6=B0=20=EC=A0=84=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0154EMdwPYNPuKc9eZv8SLEX --- docs/raw/verification/2026-09-13_축C_명세.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/raw/verification/2026-09-13_축C_명세.md b/docs/raw/verification/2026-09-13_축C_명세.md index e9f1be84..44419341 100644 --- a/docs/raw/verification/2026-09-13_축C_명세.md +++ b/docs/raw/verification/2026-09-13_축C_명세.md @@ -335,7 +335,15 @@ B08 인계본 `ground_class_aliases` — **B09 가 안 읽음**)을 한 벌로 - **노임 시간당 환산** — 식 문자열을 **좌→우 순차 평가**. 계수로 미리 곱하면 `267,360 × 0.20833… = 55,699.99…` → **55,699** 로 1원 틀림. - 좌→우는 모든 단계가 정수라 **55,700**. 18건 중 계수 선계산은 14/18 만 맞음 + 좌→우는 모든 단계가 정수라 **55,700**. + + ⚠⚠ **2026-09-13 정정 — 「18/18 원 단위」는 틀렸음**(랩탑 서브 실측). + ``` + 다섯 건 원 미만 절사 + 2025 울진소광 0.1 원 미만 절사 (57,077.2 · 49,479.1 · 35,608.3) + ``` + ⇒ **자릿수가 프로젝트마다 다름.** 실무 설정값임. + **프로젝트 설정 칸으로 둘 것 · 기본 0**(원 단위). 붙박이로 박지 말 것 - **단가산출 Q 식** — `Q = 3600 × q × K × f × E / Cm`. ⚠ **Q 를 소수 2자리로 먼저 확정한 뒤 나눌 것**(`55,700/15.708 = 3,545.96` ≠ `55,700/15.71 = 3,545.5`) From dfa99ed594b22df8cc175b7e30bf919f0345d1b8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 13 Sep 2026 23:39:02 +0900 Subject: [PATCH 5/6] =?UTF-8?q?knowledge(=EC=B6=95C):=20D=20=EB=8A=94=20Q?= =?UTF-8?q?=20=EC=93=B0=EB=8A=94=20=EC=9E=90=EB=A6=AC=EC=97=90=EB=A7=8C=20?= =?UTF-8?q?=EC=84=AC=20=C2=B7=20=EB=82=B4=EC=97=AD=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=B6=95=EC=9D=80=20B=20=ED=95=98=EB=82=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0154EMdwPYNPuKc9eZv8SLEX --- docs/raw/verification/2026-09-13_축C_명세.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/raw/verification/2026-09-13_축C_명세.md b/docs/raw/verification/2026-09-13_축C_명세.md index 44419341..4cd301b1 100644 --- a/docs/raw/verification/2026-09-13_축C_명세.md +++ b/docs/raw/verification/2026-09-13_축C_명세.md @@ -219,6 +219,21 @@ B 일위대가 (D 와 카탈로그를 물어 조립) ⇒ **D 가 먼저 서고 B 가 D 를 참조함.** 우리 코드는 지금 **D 가 B 를 참조해 뒤집혀 있음**(랩탑 서브 실측). ⚠ 차례가 뒤집히면 **위상순서·재귀·이중계상 판정이 다 영향받음** — 값 정밀도 고치기와 **따로 뗄 것.** +### D 는 **Q 를 쓰는 자리에만** 섬 (2026-09-13 실측) + +``` +D 가 서는 자리 Q 식으로 시간당 단가를 나누는 기계 공종 + (굴착기 · 도자 · 직접 작업량/다짐 · 암 잎) +D 가 없는 자리 사람 품만 있는 공종 · 자원축 표 시간(Q 아님) → B 가 X·카탈로그를 바로 물음 +``` + +- `D-<공종>[#갈래]` · **D→X 수량 `1/Q × 몫`** · **B→D 수량 1** +- STmate 한 `D` 에 **`Q` 가 여럿일 수 있음**(기계조합 호표) +- ⚠ **「단산 N」 번호는 실제 `D` 가 든 줄에만.** 단가산출이 없는 줄에 번호를 붙이면 **없는 근거를 가리킴** +- ⚠ **내역이 부르는 코드 축은 `B` 하나로 둠.** STmate 는 순수 기계 공종에서 내역이 `D` 를 바로 부르나 + (봉화 「토사절취 … 산근 19호표」), 금액은 같고(`B→D` 수량 1) **축이 하나여야 인계·골든셋·이중계상이 단순함.** + ⇒ **골든셋 대조에서 STmate 의 `D` 줄과 우리 `B` 줄을 짝으로 알아보게** 해 둘 것 + --- ## 4. 일위대가 — 잇는 표 From 7d856f37a364c91d7ee2c37521960bc4175c520c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 00:55:41 +0900 Subject: [PATCH 6/6] =?UTF-8?q?feat(b08,b09):=20=ED=8F=90=EA=B8=B0?= =?UTF-8?q?=EB=AC=BC=EC=B2=98=EB=A6=AC=EB=B9=84=20=E2=80=94=20=EA=B2=BD?= =?UTF-8?q?=EB=B9=84=20=EB=B9=84=EB=AA=A9(=EC=9D=BC=EB=B0=98=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EB=B9=84=C2=B7=EC=9D=B4=EC=9C=A4=20=EB=B0=91=EC=88=98?= =?UTF-8?q?)=20=C2=B7=20=EC=9E=84=EB=AA=A9=ED=8F=90=EA=B8=B0=EB=AC=BC=20?= =?UTF-8?q?=ED=86=A4=20=C2=B7=20=EB=B6=84=EB=A6=AC=EB=B0=9C=EC=A3=BC=20?= =?UTF-8?q?=EC=B9=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 원가계산서: 폐기물처리비를 경비 줄로(예정가격작성기준 제19조③18호) · 법정경비 뒤라 그 밑수엔 안 섞임 - 분리발주 칸(기본 아님) — 켜면 총원가 밖 · 총공사비에만 더함 - 준비공 「임목폐기물 처리」 톤: WA = 0.5·π·(B/2)²·h·1.3·W1·N · WR = WA × 15/85 (한국건설기술연구원 2012) - 조사값 넷(1,000㎡당 본수·흉고직경·수고·단위체적중량) 칸 · 기본값 없음 · 5톤·100톤 경계 알림 - 수동 처리단가 빨간 테두리 + 「미확정 N건」 · 내역엔 안 서고 제외 사유 「경비」 - 시험: 승률 구조(경비·일반관리비·이윤 밑수 증가 · 법정경비 불변 · 분리발주) · 실정보고 부피 대조 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- .../B08_Quantity_Engine_Handoff_Rows_Prep.py | 12 +- .../B08_Quantity_Engine_Preparation.py | 8 + ...8_Quantity_Engine_Preparation_TreeWaste.py | 150 ++++++++++++++++++ B08_Quantity/B08_Quantity_Router_Earthwork.py | 17 ++ B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts | 6 + B08_Quantity/B08_Quantity_UI_Page.ts | 16 ++ .../B08_Quantity_UI_Side_TreeWaste.ts | 101 ++++++++++++ B08_Quantity/B08_Quantity_UI_SummaryGrid.ts | 12 ++ B09_Estimation/B09_Estimation_Engine_Cost.py | 33 ++-- B09_Estimation/B09_Estimation_Router.py | 3 + common_util/common_util_project_settings.py | 7 + resources/tester/test_b08_tree_waste.py | 66 ++++++++ .../tester/test_b09_waste_disposal_cost.py | 92 +++++++++++ ui_template/ui_template_locale_b2.ts | 21 +++ 14 files changed, 531 insertions(+), 13 deletions(-) create mode 100644 B08_Quantity/B08_Quantity_Engine_Preparation_TreeWaste.py create mode 100644 B08_Quantity/B08_Quantity_UI_Side_TreeWaste.ts create mode 100644 resources/tester/test_b08_tree_waste.py create mode 100644 resources/tester/test_b09_waste_disposal_cost.py diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows_Prep.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows_Prep.py index 8a65dd2c..fa938f37 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows_Prep.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows_Prep.py @@ -48,6 +48,8 @@ def _preparation_rows(preparation_table: dict[str, Any]) -> list[dict[str, Any]] status = str(row.get("status") or "") amount = row.get("amount") ready = status == PREP_READY and amount is not None + # 값은 서도 **내역 줄이 아닌 줄**(경비 — 임목폐기물 처리). 막힘이 아니라 가는 곳이 다름. + billable = ready and row.get("bill") is not False rows.append( { "work_item_code": row.get("work_item_code"), @@ -88,8 +90,14 @@ def _preparation_rows(preparation_table: dict[str, Any]) -> list[dict[str, Any]] "spec_class_basis": str(row.get("reason") or ""), "composite_not_ready": None, # 값이 없는 줄은 **내역에 세우지 않는다** — 0 원 줄을 만들면 더 나쁘다. - "in_bill": ready, - "in_bill_reason": "" if ready else str(row.get("reason") or status), + "in_bill": billable, + "in_bill_reason": ( + "" + if billable + else str(row.get("bill_reason") or "") + if ready + else str(row.get("reason") or status) + ), "origin": ORIGIN_PREPARATION, } ) diff --git a/B08_Quantity/B08_Quantity_Engine_Preparation.py b/B08_Quantity/B08_Quantity_Engine_Preparation.py index 743111f4..e759e2b6 100644 --- a/B08_Quantity/B08_Quantity_Engine_Preparation.py +++ b/B08_Quantity/B08_Quantity_Engine_Preparation.py @@ -56,6 +56,7 @@ from B08_Quantity.B08_Quantity_Engine_Preparation_Status import ( # noqa: E402 STATUS_PENDING, STATUS_READY, ) +from B08_Quantity.B08_Quantity_Engine_Preparation_TreeWaste import tree_waste_rows # noqa: E402 __all__ = ["ANCILLARY_ITEMS", "ancillary_rows"] # 갈라 나간 뒤에도 여기서 읽을 수 있게 @@ -667,6 +668,9 @@ def build_table( chipping_volume_m3: Any = None, road_surface_area_m2: float | None = None, topsoil_road_only: bool = False, + tree_waste: dict[str, Any] | None = None, + tree_waste_unit_price_krw_per_ton: Any = None, + waste_separate_order: Any = False, ) -> dict[str, Any]: """화면·인계가 그대로 쓰는 모양. **못 서는 줄도 목록에 남는다.**""" rows = ( @@ -681,6 +685,10 @@ def build_table( ) + erosion_rows(structures, names) + chipping_rows(chipping_enabled, chipping_volume_m3) + # 임목폐기물 — 톤 · 비목은 경비(내역 줄 아님 · 2026-09-14 판정). + + tree_waste_rows( + slope_totals, tree_waste, tree_waste_unit_price_krw_per_ton, waste_separate_order + ) + ancillary_rows(ancillary_counts) ) return { diff --git a/B08_Quantity/B08_Quantity_Engine_Preparation_TreeWaste.py b/B08_Quantity/B08_Quantity_Engine_Preparation_TreeWaste.py new file mode 100644 index 00000000..cd967487 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_Preparation_TreeWaste.py @@ -0,0 +1,150 @@ +"""준비공 — 임목폐기물 처리 한 줄 (톤) · 2026-09-14 판정 「폐기물처리비」. + +⭐ 수량 산식 — 한국건설기술연구원 「건설현장 임목폐기물의 재활용 및 자원화 기술개발」(2012) 3.2 + 지상부 WA = k × π × (B/2)² × h × (1+p) × W1 × N (kg) + 뿌리부 WR = WA × 뿌리 분배비 ÷ 지상 분배비 = WA × 15/85 (일반 수목류, 같은 보고서 표) + k 수간 형상계수 0.5 · p 지엽 보합률(임목 0.3) — **산식 상수**라 칸이 없다. + B 평균 흉고직경 · h 평균 수고 · W1 수목 단위체적중량 · N 본수 — + **현장 조사값**이라 칸으로 받는다. + N = 표본지 1,000㎡ 실측 본수 × 대상 면적 ÷ 1,000 (실정보고 예 「134/1000 × 7,450」 모양). + 대상 면적 = 벌목·지장목제거 면적(같은 나무를 벤 자리). + +⚠ 금액은 **처리단가가 없어** 못 선다(KCRA 중간처리단가 미확보) — 수동 단가를 넣으면 + 금액이 서되 「미확정」으로 센다(빨간 테두리). 비목은 **경비**라 내역 줄이 아니고 + 원가계산서 폐기물처리비 줄로 간다(예정가격작성기준 제19조③18호). +""" + +from __future__ import annotations + +import math +from typing import Any + +from B08_Quantity.B08_Quantity_Engine_Preparation_Status import STATUS_PENDING, STATUS_READY + +TREE_WASTE_ITEM = "임목폐기물 처리" +STEM_FORM_FACTOR = 0.5 # k +FOLIAGE_ALLOWANCE = 0.3 # p — 임목(고립목은 1.0) +ROOT_SHARE, ABOVE_SHARE = 15.0, 85.0 # 일반 수목류 분배비 + +#: 칸 이름 · 화면 이름 · 단위 — 모두 채워야 수량이 선다. +TREE_WASTE_INPUTS: tuple[tuple[str, str], ...] = ( + ("stems_per_1000m2", "표본지 1,000㎡당 본수"), + ("dbh_cm", "평균 흉고직경(㎝)"), + ("height_m", "평균 수고(m)"), + ("unit_weight_kg_m3", "단위체적중량(㎏/㎥)"), +) +TREE_WASTE_BASIS = ( + "한국건설기술연구원(2012) 3.2 임목 발생량 — k 0.5 · p 0.3(임목)" + " · 뿌리 15 : 지상 85(일반 수목류)" + " · N = 표본지 1,000㎡ 본수 × 벌목·지장목제거 면적" +) +#: 톤 경계 — 화면이 알릴 뿐 값을 바꾸지 않는다. +REPORT_THRESHOLD_TON = 5.0 +SEPARATE_ORDER_THRESHOLD_TON = 100.0 +EXPENSE_NOTE = ( + "비목은 경비(예정가격작성기준 제19조③18호) — 내역 줄이 아니라 원가계산서 폐기물처리비로 감" +) + + +def _positive(value: Any) -> float | None: + try: + number = float(value) + except (TypeError, ValueError): + return None + return number if number > 0 else None + + +def tree_waste_tons(area_m2: float, inputs: dict[str, Any]) -> tuple[float, float] | None: + """(지상부 톤, 뿌리부 톤). 칸이 하나라도 비거나 면적이 0 이면 `None`.""" + values = {key: _positive(inputs.get(key)) for key, _ in TREE_WASTE_INPUTS} + if area_m2 <= 0 or any(value is None for value in values.values()): + return None + stems = values["stems_per_1000m2"] * area_m2 / 1000.0 + diameter = values["dbh_cm"] / 100.0 + above_kg = ( + STEM_FORM_FACTOR + * math.pi + * (diameter / 2.0) ** 2 + * values["height_m"] + * (1.0 + FOLIAGE_ALLOWANCE) + * values["unit_weight_kg_m3"] + * stems + ) + return above_kg / 1000.0, above_kg * ROOT_SHARE / ABOVE_SHARE / 1000.0 + + +def tree_waste_rows( + slope_totals: dict[str, float] | None, + inputs: dict[str, Any] | None, + unit_price_krw_per_ton: Any = None, + separate_order: Any = False, +) -> list[dict[str, Any]]: + """임목폐기물 처리 한 줄 — 값이 없어도 줄은 선다(무엇을 넣으면 풀리는지 적음).""" + slope = slope_totals or {} + area = float(slope.get("tree_removal_fill", 0.0)) + float(slope.get("tree_removal_cut", 0.0)) + inputs = inputs or {} + base = { + "group": "준비공", + "item": TREE_WASTE_ITEM, + "unit": "톤", + "work_item_code": None, + # 경비 — 내역에 세우지 않는다(인계가 이 칸을 본다). + "bill": False, + "bill_reason": EXPENSE_NOTE, + "separate_order": bool(separate_order), + } + tons = tree_waste_tons(area, inputs) + if tons is None: + missing = [label for key, label in TREE_WASTE_INPUTS if _positive(inputs.get(key)) is None] + why = ( + f"현장 조사값이 비어 있음 — {' · '.join(missing)}" + if missing + else "벌목·지장목제거 면적이 0 ㎡ — 사면표가 아직 안 섬" + ) + return [ + { + **base, + "amount": None, + "status": STATUS_PENDING, + "reason": f"{why}. 산식: {TREE_WASTE_BASIS}", + "reference_amount": area, + } + ] + above, root = tons + total = above + root + notes = [ + f"지상부 WA {above:,.2f}톤 + 뿌리부 WR {root:,.2f}톤(WA × 15/85)", + TREE_WASTE_BASIS, + EXPENSE_NOTE, + ] + if total >= SEPARATE_ORDER_THRESHOLD_TON: + notes.append( + "⚠ 100톤 이상 — 건설폐기물이면 분리발주 대상(업무처리지침). 다만 임목폐기물은" + " 건설폐기물이 아닌 사업장 일반폐기물로 분류됨(같은 지침) — 산출 조건에서 고를 것" + ) + elif total >= REPORT_THRESHOLD_TON: + notes.append("5톤 이상 — 배출자 신고 대상 사업장(폐기물관리법 시행령 제2조8호)") + price = _positive(unit_price_krw_per_ton) + row = { + **base, + "amount": total, + "status": STATUS_READY, + "reference_amount": area, + "above_ground_ton": above, + "root_ton": root, + "unit_price_krw_per_ton": price, + "amount_krw": math.floor(total * price) if price else None, + # 수동 단가로 선 금액 — 화면 「미확정 N건」·빨간 테두리. + "unconfirmed": 1 if price else 0, + } + if price: + notes.append( + f"처리비 {total:,.2f}톤 × {price:,.0f}원 = {math.floor(total * price):,}원" + " (⚠ 수동 단가 — 미확정 · KCRA 중간처리단가 미확보)" + ) + else: + notes.append( + "처리단가 없음(KCRA 중간처리단가 미확보) — 산출 조건에 수동으로 넣으면 금액이 섬" + ) + row["reason"] = " · ".join(notes) + return [row] diff --git a/B08_Quantity/B08_Quantity_Router_Earthwork.py b/B08_Quantity/B08_Quantity_Router_Earthwork.py index 78d5b6f5..9bff3a17 100644 --- a/B08_Quantity/B08_Quantity_Router_Earthwork.py +++ b/B08_Quantity/B08_Quantity_Router_Earthwork.py @@ -174,6 +174,10 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse: road_surface_area(slopes), # 대상 — 기본 노면 + 절토(별표2 문언) · 노면만은 설계자가 고름(2026-09-14 판정). topsoil_target(settings) == "road_only", + # 임목폐기물 — 현장 조사값 · 수동 처리단가 · 분리발주(2026-09-14 판정). + settings.get("tree_waste") or {}, + settings.get("tree_waste_unit_price_krw_per_ton"), + settings.get("waste_separate_order"), ) method, method_is_default = concrete_placing_method(settings) # ⚠ 금액에 바로 걸리는 값이라 「기본값으로 돌고 있음」을 응답에 실어 화면이 띄우게 한다. @@ -456,6 +460,10 @@ class QuantitySettingsBody(BaseModel): wood_chipping_volume_m3: float | None = None # 노체다짐 — 기본 꺼짐(9-16-2 [주]⑤ 조건부). 켜면 토공집계에 별도 줄. subgrade_compaction_enabled: bool | None = None + # 임목폐기물 — 현장 조사값 넷(통째로 갈아 끼움) · 수동 처리단가 · 분리발주. + tree_waste: dict[str, float | None] | None = None + tree_waste_unit_price_krw_per_ton: float | None = None + waste_separate_order: bool | None = None # 토량환산계수(다짐) — `{갈래: {"compacted": C, "reason": 사유}}`. # ⚠ **기본값을 복사해 넣지 않는다** — 안 고른 갈래는 키가 없어야 정본이 선다. # 빈 dict 는 「전부 기본값으로 되돌림」이라 통째로 갈아 끼운다. @@ -474,6 +482,7 @@ NULLABLE_SETTING_KEYS = ( "topsoil_haul_distance_m", "wood_chipping_volume_m3", "dozer_haul_limit_m", + "tree_waste_unit_price_krw_per_ton", ) @@ -515,6 +524,13 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) - method = values["concrete_placing_method"] # 「안 정함」으로 되돌릴 수 있어야 한다 — 빈 값이면 지운다(8-22 ② 와 같은 자리). values["concrete_placing_method"] = method if method in CONCRETE_PLACING_METHODS else None + if "tree_waste" in values: + # 양수만 남긴다 — 빈 칸은 키가 없어야 「안 넣음」으로 읽힌다. + values["tree_waste"] = { + key: float(value) + for key, value in (values["tree_waste"] or {}).items() + if isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0 + } if "topsoil_target" in values: # 기본(노면 + 절토)은 저장하지 않는다 — 「안 정함」과 같게 둬 법 문언이 선다. target = values["topsoil_target"] @@ -554,6 +570,7 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) - "material_supply", "concrete_placing_method", "topsoil_target", + "tree_waste", "ancillary_counts", # 고른 계수를 **기본값으로 되돌릴 길**이 있어야 한다 — 병합이면 못 지운다. "conversion_factors_override", diff --git a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts index 36c58ed7..22c46169 100644 --- a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts +++ b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts @@ -108,6 +108,12 @@ export interface QuantitySettings { wood_chipping_volume_m3?: number | null; /** 노체다짐 — 기본 꺼짐(9-16-2 [주]⑤ 조건부). 켜면 토공집계에 별도 줄. */ subgrade_compaction_enabled?: boolean | null; + /** 임목폐기물 조사값 넷 — 비면 톤이 안 선다. */ + tree_waste?: Record; + /** 처리단가(원/톤) — 수동 · 미확정. */ + tree_waste_unit_price_krw_per_ton?: number | null; + /** 폐기물 분리발주 — 기본 아님. */ + waste_separate_order?: boolean | null; } /** 갈래 하나의 「무엇을 골랐나」. 서버 `earthwork_conversion_choices` 와 짝이다. */ diff --git a/B08_Quantity/B08_Quantity_UI_Page.ts b/B08_Quantity/B08_Quantity_UI_Page.ts index 9125dedf..c23e6924 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.ts +++ b/B08_Quantity/B08_Quantity_UI_Page.ts @@ -40,6 +40,7 @@ import { } from "./B08_Quantity_UI_MaterialGrid"; import { renderStructureSheets } from "./B08_Quantity_UI_StructureSheet"; import { renderStructureSummary } from "./B08_Quantity_UI_StructureSummary"; +import { appendTreeWasteFields } from "./B08_Quantity_UI_Side_TreeWaste"; /** locale 헬퍼 */ function L(key: keyof typeof ui_locales): string { @@ -109,6 +110,10 @@ async function saveQuantitySettings(projectId: string, draft: DraftSettings): Pr wood_chipping_enabled: draft.wood_chipping_enabled, wood_chipping_volume_m3: draft.wood_chipping_volume_m3, subgrade_compaction_enabled: draft.subgrade_compaction_enabled, + // 조사값은 **통째로** — 지운 칸까지 가야 되돌릴 길이 있다. + tree_waste: draft.tree_waste, + tree_waste_unit_price_krw_per_ton: draft.tree_waste_unit_price_krw_per_ton, + waste_separate_order: draft.waste_separate_order, // 개소는 **통째로** 보낸다 — 지운 항목까지 그대로 가야 되돌릴 길이 있다. ancillary_counts: draft.ancillary_counts, // 토량환산계수 — 고른 갈래만 담긴다. 빈 dict 는 「전부 기본값으로 되돌림」이다. @@ -300,6 +305,10 @@ interface DraftSettings { wood_chipping_volume_m3: number | null; // 노체다짐 — **기본 꺼짐**(9-16-2 [주]⑤ 조건부). 켜면 토공집계에 별도 줄(수량 = 성토량). subgrade_compaction_enabled: boolean; + // 임목폐기물 — 조사값 넷 · 수동 처리단가(미확정) · 분리발주(기본 아님). + tree_waste: Record; + tree_waste_unit_price_krw_per_ton: number | null; + waste_separate_order: boolean; // 자재별 관급/사급 — 표 안에서 줄마다 고른 값. material_supply: Record; // 갈래별 토량환산계수(다짐) — `compacted` 가 `null` 이면 「안 고름」이라 기본값이 선다. @@ -703,6 +712,9 @@ function buildQuantitySidePanel( ); panel.append(hintRow(L("B08_Quantity_Side_SubgradeCompaction_Hint"))); + // ── 임목폐기물 — 조사값 넷 · 수동 처리단가 · 분리발주(2026-09-14). 칸은 따로 뺀 파일에서. + appendTreeWasteFields(panel, draft, { field, optionalNumberField, selectField, hintRow }); + // ── 콘크리트 타설 방식 — ⚠ **금액에 바로 걸리는 값**이라 잠정임을 조용히 두지 않는다 ── panel.append(field(L("B08_Quantity_Side_Placing"), "")); panel.append( @@ -1001,6 +1013,10 @@ export async function renderB08Quantity(root: HTMLElement): Promise { wood_chipping_enabled: Boolean(stored.wood_chipping_enabled), wood_chipping_volume_m3: (stored.wood_chipping_volume_m3 as number | null) ?? null, subgrade_compaction_enabled: Boolean(stored.subgrade_compaction_enabled), + tree_waste: { ...((stored.tree_waste ?? {}) as Record) }, + tree_waste_unit_price_krw_per_ton: + (stored.tree_waste_unit_price_krw_per_ton as number | null) ?? null, + waste_separate_order: Boolean(stored.waste_separate_order), ancillary_counts: { ...((stored.ancillary_counts ?? {}) as Record), }, diff --git a/B08_Quantity/B08_Quantity_UI_Side_TreeWaste.ts b/B08_Quantity/B08_Quantity_UI_Side_TreeWaste.ts new file mode 100644 index 00000000..52362f39 --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_Side_TreeWaste.ts @@ -0,0 +1,101 @@ +/* ============================================================================= + * B08_Quantity_UI_Side_TreeWaste.ts + * 산출 조건 「임목폐기물」 구획 — 현장 조사값 넷 · 수동 처리단가 · 분리발주(2026-09-14 판정). + * + * 페이지 본문(`B08_Quantity_UI_Page.ts`)이 700줄을 넘어 새 칸을 이 파일로 뺌(CLAUDE.md 4장). + * ⚠ 톤은 서버가 셈 — 여기는 값만 캐시에 쌓고 [저장]에서 정본으로 감(5장). + * ========================================================================== */ + +import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +export interface TreeWasteDraft { + tree_waste: Record; + tree_waste_unit_price_krw_per_ton: number | null; + waste_separate_order: boolean; + dirty: boolean; +} + +/** 페이지의 칸 만들기 도우미 — 같은 모양을 쓰려고 받아 씀. */ +export interface SideFieldHelpers { + field(label: string, value: string): HTMLElement; + optionalNumberField( + label: string, + value: number | null, + step: string, + onInput: (value: number | null) => void, + ): HTMLElement; + selectField( + label: string, + value: string, + options: { value: string; label: string }[], + onChange: (value: string) => void, + ): HTMLElement; + hintRow(text: string): HTMLElement; +} + +/** 조사값 칸 — 서버 `TREE_WASTE_INPUTS` 와 같은 키. */ +const INPUTS: [string, keyof typeof ui_locales, string][] = [ + ["stems_per_1000m2", "B08_Quantity_TreeWaste_Stems", "1"], + ["dbh_cm", "B08_Quantity_TreeWaste_Dbh", "0.1"], + ["height_m", "B08_Quantity_TreeWaste_Height", "0.1"], + ["unit_weight_kg_m3", "B08_Quantity_TreeWaste_UnitWeight", "10"], +]; + +/** 수동 단가 칸 — 값이 있으면 빨간 테두리(미확정). */ +function markManual(row: HTMLElement, value: number | null): void { + const input = row.querySelector("input"); + if (input) input.style.outline = value ? "2px solid var(--color-danger, #d9534f)" : ""; +} + +export function appendTreeWasteFields( + panel: HTMLElement, + draft: TreeWasteDraft, + h: SideFieldHelpers, +): void { + panel.append(h.field(L("B08_Quantity_Side_TreeWaste"), "")); + for (const [key, label, step] of INPUTS) { + panel.append( + h.optionalNumberField(L(label), draft.tree_waste[key] ?? null, step, (value) => { + draft.tree_waste[key] = value; + draft.dirty = true; + }), + ); + } + panel.append(h.hintRow(L("B08_Quantity_Side_TreeWaste_Hint"))); + + const price = h.optionalNumberField( + L("B08_Quantity_TreeWaste_Price"), + draft.tree_waste_unit_price_krw_per_ton, + "1000", + (value) => { + draft.tree_waste_unit_price_krw_per_ton = value; + draft.dirty = true; + markManual(price, value); + }, + ); + markManual(price, draft.tree_waste_unit_price_krw_per_ton); + panel.append(price); + const priceHint = h.hintRow(L("B08_Quantity_TreeWaste_Price_Hint")); + priceHint.classList.add("b08-quantity__hint--warn"); + panel.append(priceHint); + + panel.append( + h.selectField( + L("B08_Quantity_TreeWaste_Separate"), + draft.waste_separate_order ? "on" : "", + [ + { value: "", label: L("B08_Quantity_TreeWaste_Separate_Off") }, + { value: "on", label: L("B08_Quantity_TreeWaste_Separate_On") }, + ], + (value) => { + draft.waste_separate_order = value === "on"; + draft.dirty = true; + }, + ), + ); + panel.append(h.hintRow(L("B08_Quantity_TreeWaste_Separate_Hint"))); +} diff --git a/B08_Quantity/B08_Quantity_UI_SummaryGrid.ts b/B08_Quantity/B08_Quantity_UI_SummaryGrid.ts index 7845acce..a3942681 100644 --- a/B08_Quantity/B08_Quantity_UI_SummaryGrid.ts +++ b/B08_Quantity/B08_Quantity_UI_SummaryGrid.ts @@ -236,6 +236,8 @@ export interface PreparationRow { reason: string; reference_amount?: number; work_item_code: string | null; + /** 수동 단가로 선 금액 수 — 「미확정 N건」·빨간 테두리(임목폐기물 처리). */ + unconfirmed?: number; } export interface PreparationTable { @@ -260,6 +262,14 @@ export function renderPreparationGrid( const caption = document.createElement("p"); caption.className = "b08-grid__caption"; caption.textContent = `${table.row_count}줄 · 값을 낼 근거가 아직 없는 줄 ${table.pending_count}개`; + const unconfirmed = table.rows.reduce((sum, row) => sum + (row.unconfirmed ?? 0), 0); + if (unconfirmed) { + const badge = document.createElement("span"); + badge.style.cssText = + "margin-left:8px;padding:0 6px;border-radius:8px;color:#fff;background:var(--color-danger,#d9534f);font-size:12px"; + badge.textContent = `미확정 ${unconfirmed}건`; + caption.append(badge); + } wrap.append(caption); const scroller = document.createElement("div"); @@ -291,6 +301,8 @@ export function renderPreparationGrid( if (row.reference_amount) { note.append(document.createTextNode(` (참고 면적 ${num(row.reference_amount, 1)})`)); } + // 수동 단가로 선 금액이 든 줄 — 빨간 테두리(미확정). + if (row.unconfirmed) note.style.outline = "2px solid var(--color-danger, #d9534f)"; tr.append(note); // 값을 못 세운 줄의 「수량」은 **막힘** — 근거가 오면 채워질 자리라 제외과 갈라 보인다. markRow(tr, PREPARATION_KEYS, row.amount === null ? { 3: "blocked" } : undefined); diff --git a/B09_Estimation/B09_Estimation_Engine_Cost.py b/B09_Estimation/B09_Estimation_Engine_Cost.py index 2f749d3a..e48df4ae 100644 --- a/B09_Estimation/B09_Estimation_Engine_Cost.py +++ b/B09_Estimation/B09_Estimation_Engine_Cost.py @@ -88,10 +88,14 @@ class CostInput: #: 이윤 수동 조정액 — 설계자 명시 입력일 때만. 프로그램이 스스로 채우지 않는다. profit_adjustment_krw: Decimal = _ZERO - #: 폐기물처리비 — 요율이 아니라 **실비**. 총원가 밖, 관급자재대와 나란히. - #: TODO(미결 PLAN 8-14·9-6): 자리 확정 대기 (사용자 「실무자 확인 후 재공유」). - #: 실무 근거는 거창 원가계산서의 `총공사비 = 도급액 + 관급자재대 + 폐기물처리비` 한 줄뿐. + #: 폐기물처리비 — 요율이 아니라 **실비**(수량 × 처리단가). + #: ⭐ 2026-09-14 판정 — **비목은 경비**(예정가격작성기준 제19조③18호)라 순공사원가에 들고 + #: **일반관리비·이윤 밑수에 든다.** 빠지면 총액이 조용히 작아진다. + #: ⚠ 법정경비 밑수(직접공사비·노무비)에는 안 넣는다 — 그 밑수는 요율 데이터가 정한 합이다. waste_disposal_krw: Decimal = _ZERO + #: 분리발주 — 켜면 공사와 따로 발주하는 용역이라 **총원가 밖**, 총공사비에만 더한다 + #: (거창 원가계산서 `총공사비 = 도급액 + 관급자재대 + 폐기물처리비` 모양). 기본 꺼짐. + waste_separate_order: bool = False #: 환경보전비 공종 (`rate_environment.all_work_types` 의 값). #: TODO(미결 PLAN 9-6): 임도가 「도로 0.9 %」인지 「기타 토목 0.8 %」인지 미확정. @@ -353,12 +357,14 @@ def _calculate_with_scale( scale_reference=scale, ) statutory = statutory_expenses(dataset, data, ctx, result, emit) + # 폐기물처리비 — 분리발주가 아니면 경비 한 줄. 법정경비 **뒤**에 둬 그 밑수에 안 섞인다. + waste_in_expense = _ZERO if data.waste_separate_order else _waste_line(data, emit) - expense_total = data.direct_expense_krw + statutory + expense_total = data.direct_expense_krw + statutory + waste_in_expense emit( key="expense", name="경비", - base_label="직접경비(산출경비)+법정경비", + base_label="직접경비(산출경비)+법정경비" + ("+폐기물처리비" if waste_in_expense else ""), base=expense_total, amount=expense_total, ) @@ -413,13 +419,14 @@ def _calculate_with_scale( ) owner_total = _owner_supplied_line(data, emit) - waste = _waste_line(data, emit) + # 분리발주면 공사 원가가 아니다 — 총원가 밖에 서고 총공사비에만 더한다. + waste = _waste_line(data, emit) if data.waste_separate_order else _ZERO grand_total = contract_amount + owner_total + waste emit( key="grand_total", name="총공사비", - base_label="도급공사비+관급자재대" + ("+폐기물처리비" if waste else ""), + base_label="도급공사비+관급자재대" + ("+폐기물처리비(분리발주)" if waste else ""), base=grand_total, amount=grand_total, ) @@ -436,7 +443,7 @@ def _calculate_with_scale( "vat": vat, "contract_amount": contract_amount, "owner_supplied_material_total": owner_total, - "waste_disposal": waste, + "waste_disposal": waste_in_expense + waste, "grand_total": grand_total, } return result @@ -508,16 +515,20 @@ def _owner_supplied_line(data: CostInput, emit) -> Decimal: def _waste_line(data: CostInput, emit) -> Decimal: - """폐기물처리비 — 요율이 아니라 실비. 설계자 입력이 있을 때만 줄이 선다.""" + """폐기물처리비 — 요율이 아니라 실비. 금액이 있을 때만 줄이 선다.""" if not data.waste_disposal_krw: return _ZERO return emit( key="waste_disposal", name="폐기물처리비", - base_label="설계자 입력(실비)", + base_label="수량 × 처리단가(실비)", base=data.waste_disposal_krw, amount=floor_won(data.waste_disposal_krw), - note="⚠ 자리 미확정 — 실무 관측 한 줄이 유일한 근거 (PLAN 8-14)", + note=( + "분리발주 — 총원가 밖 별도 표기" + if data.waste_separate_order + else "경비(예정가격작성기준 제19조③18호) — 일반관리비·이윤 밑수에 듦" + ), ) diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index 8d84d9f4..9d98763a 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -82,6 +82,8 @@ class CostRequest(BaseModel): estimated_price_krw: Decimal | None = None profit_adjustment_krw: Decimal = Field(default=Decimal(0), ge=0) waste_disposal_krw: Decimal = Field(default=Decimal(0), ge=0) + #: 폐기물 분리발주 — 기본 아님(경비로 원가에 듦). 켜면 총원가 밖. + waste_separate_order: bool = False environment_work_type: str = "civil_road" equipment_guarantee_work_type: str = "civil_general" @@ -116,6 +118,7 @@ class CostRequest(BaseModel): estimated_price_krw=self.estimated_price_krw, profit_adjustment_krw=self.profit_adjustment_krw, waste_disposal_krw=self.waste_disposal_krw, + waste_separate_order=self.waste_separate_order, environment_work_type=self.environment_work_type, equipment_guarantee_work_type=self.equipment_guarantee_work_type, subcontract_guarantee_variant=self.subcontract_guarantee_variant, diff --git a/common_util/common_util_project_settings.py b/common_util/common_util_project_settings.py index 0b312aee..fe9bd9f1 100644 --- a/common_util/common_util_project_settings.py +++ b/common_util/common_util_project_settings.py @@ -160,6 +160,13 @@ def default_settings() -> dict[str, Any]: # 노체다짐(9-16-2) — **기본 꺼짐**(2026-09-13 판정). [주]⑤ 「대규모 성토지로서 # 층다짐이 필요한 경우 적용」 조건부라 늘 서지 않는다. 켜면 토공집계에 별도 줄. "subgrade_compaction_enabled": False, + # 임목폐기물 — 현장 조사값 넷 `{stems_per_1000m2, dbh_cm, height_m, unit_weight_kg_m3}`. + # ⚠ 기본값을 두지 않는다 — 비면 톤이 안 선다(수종·임분마다 달라 지어내지 않음). + "tree_waste": {}, + # 처리단가(원/톤) — KCRA 중간처리단가 미확보라 **수동**. 넣으면 금액이 서되 미확정. + "tree_waste_unit_price_krw_per_ton": None, + # 폐기물 분리발주 — 기본 아님(경비로 원가에 듦 · 2026-09-14 판정). + "waste_separate_order": False, "dataset_versions": {}, }, "estimation": { diff --git a/resources/tester/test_b08_tree_waste.py b/resources/tester/test_b08_tree_waste.py new file mode 100644 index 00000000..d0aff832 --- /dev/null +++ b/resources/tester/test_b08_tree_waste.py @@ -0,0 +1,66 @@ +"""임목폐기물 처리 — 톤으로 서는가 · 경비라 내역에 안 서는가 (2026-09-14 판정). + +⚠ 산식 — 한국건설기술연구원(2012) 3.2: WA = 0.5 × π × (B/2)² × h × 1.3 × W1 × N · WR = WA × 15/85. +⚠ 실정보고 예(p26) 「V1 = 0.5×π×(0.16/2)²×10.0×1.3×134/1000×7,450 = 130.40㎥」 로 + 지상부 부피를 대 봄. +""" + +from __future__ import annotations + +import math +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_Preparation_TreeWaste import ( # noqa: E402 + tree_waste_rows, + tree_waste_tons, +) + +SLOPE = {"tree_removal_fill": 4000.0, "tree_removal_cut": 3450.0} # 7,450㎡ +FIELD = {"stems_per_1000m2": 134, "dbh_cm": 16, "height_m": 10.0, "unit_weight_kg_m3": 1000} + + +def test_지상부는_실정보고_부피와_같다() -> None: + """W1 = 1,000㎏/㎥ 로 두면 지상부 톤 = 지상부 부피(㎥) — 실정보고 130.40㎥.""" + above, root = tree_waste_tons(7450.0, FIELD) + # 실정보고는 π 를 3.14 로 셈 — 같은 π 로 되돌리면 130.40 과 같다. + assert above * 3.14 / math.pi == pytest.approx(130.40, abs=0.01) + assert root == pytest.approx(above * 15 / 85) + + +def test_칸이_비면_톤이_안_서고_무엇이_빈지_적는다() -> None: + row = tree_waste_rows(SLOPE, {**FIELD, "height_m": None})[0] + assert row["amount"] is None and "평균 수고" in row["reason"] + assert row["reference_amount"] == 7450.0 + + +def test_단가가_없으면_금액이_안_서고_있으면_미확정() -> None: + no_price = tree_waste_rows(SLOPE, FIELD)[0] + assert no_price["unit"] == "톤" and no_price["amount"] > 0 + assert no_price["amount_krw"] is None and no_price["unconfirmed"] == 0 + priced = tree_waste_rows(SLOPE, FIELD, 60000)[0] + assert priced["unconfirmed"] == 1 + assert priced["amount_krw"] == int(priced["amount"] * 60000) + + +def test_톤_경계를_알린다() -> None: + big = tree_waste_rows(SLOPE, FIELD)[0] # 약 153톤 + assert big["amount"] >= 100 and "100톤 이상" in big["reason"] + small = tree_waste_rows({"tree_removal_fill": 500.0}, FIELD)[0] # 약 10톤 + assert 5 <= small["amount"] < 100 and "5톤 이상" in small["reason"] + + +def test_경비라_내역에_안_서고_제외_사유가_간다() -> None: + from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff + from B08_Quantity.B08_Quantity_Engine_Preparation import build_table + + table = build_table(SLOPE, tree_waste=FIELD, tree_waste_unit_price_krw_per_ton=60000) + handed = {r["name"]: r for r in build_handoff(preparation_table=table)["work_items"]} + row = handed["임목폐기물 처리"] + assert row["in_bill"] is False and row["blocked_kind"] is None + assert "경비" in row["in_bill_reason"] diff --git a/resources/tester/test_b09_waste_disposal_cost.py b/resources/tester/test_b09_waste_disposal_cost.py new file mode 100644 index 00000000..dca10d08 --- /dev/null +++ b/resources/tester/test_b09_waste_disposal_cost.py @@ -0,0 +1,92 @@ +"""폐기물처리비 자리 — 경비 비목 · 일반관리비·이윤 밑수에 듦 (2026-09-14 판정). + +⚠ 겨누는 것은 **구조**다 — 내역 금액을 박지 않는다(다른 창 절사로 몇 원 움직여도 안 깨지게). + ① 분리발주 아님(기본): 경비 한 줄 → 순공사원가·일반관리비 밑수·이윤 밑수가 **그만큼** 는다 + ② 법정경비 밑수에는 안 섞인다 — 법정경비 줄은 그대로 + ③ 분리발주: 총원가 밖 · 총공사비에만 더한다 +근거 — 예정가격작성기준 제19조③18호(경비 세비목 「폐기물처리비」). +""" + +from __future__ import annotations + +import sys +from decimal import Decimal +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B09_Estimation.B09_Estimation_Engine_Cost import CostInput, calculate_cost # noqa: E402 + +WASTE = Decimal(1_000_000) +#: 규모 구간을 못 박는다 — 폐기물 몫으로 구간이 바뀌면 율이 달라져 구조를 못 잰다. +BASE = dict( + direct_material_krw=Decimal(30_000_000), + direct_labor_krw=Decimal(40_000_000), + direct_expense_krw=Decimal(10_000_000), + estimated_price_krw=Decimal(100_000_000), +) +#: 폐기물이 들면 움직여야 하는 줄 — 나머지(법정경비 등)는 한 푼도 안 움직여야 함. +MOVES = { + "waste_disposal", + "expense", + "net_construction_cost", + "general_overhead", + "profit_before_adjustment", + "profit", + "total_cost", + "vat", + "contract_amount", + "grand_total", +} + + +def _run(waste: Decimal, separate: bool = False): + return calculate_cost( + CostInput(**BASE, waste_disposal_krw=waste, waste_separate_order=separate) + ) + + +def test_기본은_경비로_들어_일반관리비_이윤_밑수가_는다() -> None: + before, after = _run(Decimal(0)), _run(WASTE) + assert after.line("waste_disposal").amount_krw == WASTE + assert after.amount("expense") - before.amount("expense") == WASTE + assert after.amount("net_construction_cost") - before.amount("net_construction_cost") == WASTE + over = ( + after.line("general_overhead").base_amount_krw + - before.line("general_overhead").base_amount_krw + ) + assert over == WASTE + overhead_gain = after.amount("general_overhead") - before.amount("general_overhead") + assert overhead_gain > 0 + profit_base_gain = ( + after.line("profit_before_adjustment").base_amount_krw + - before.line("profit_before_adjustment").base_amount_krw + ) + assert profit_base_gain == WASTE + overhead_gain + assert "폐기물처리비" in after.line("expense").base_label + + +def test_법정경비_줄은_안_움직인다() -> None: + before, after = _run(Decimal(0)), _run(WASTE) + for line in before.lines: + if line.key not in MOVES: + assert after.amount(line.key) == line.amount_krw, line.key + + +def test_경비_줄보다_앞에_선다() -> None: + keys = [line.key for line in _run(WASTE).lines] + assert keys.index("waste_disposal") < keys.index("expense") < keys.index("general_overhead") + + +def test_분리발주면_총원가_밖_총공사비에만() -> None: + none, separate = _run(Decimal(0)), _run(WASTE, separate=True) + assert separate.amount("total_cost") == none.amount("total_cost") + assert separate.amount("grand_total") - none.amount("grand_total") == WASTE + keys = [line.key for line in separate.lines] + assert keys.index("waste_disposal") > keys.index("contract_amount") + assert separate.totals["waste_disposal"] == WASTE + + +def test_금액이_없으면_줄이_안_선다() -> None: + assert not _run(Decimal(0)).has("waste_disposal") diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 14cbc9b1..4d85a535 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -711,6 +711,27 @@ export const ui_locales_b2 = { "기본은 안 셉니다(확정 5차 5번) — 현장에 따라 필요하면 켜세요. ⚠ 켜도 부피를 넣어야 값이 섭니다(실무는 부피로 셈)", "Off by default — turn on per site. Volume must be entered for the row to carry a quantity", ], + B08_Quantity_Side_TreeWaste: ["임목폐기물", "Tree Waste"], + B08_Quantity_TreeWaste_Stems: ["표본지 1,000㎡당 본수", "Stems per 1,000 ㎡ (sample plot)"], + B08_Quantity_TreeWaste_Dbh: ["평균 흉고직경(㎝)", "Mean DBH (cm)"], + B08_Quantity_TreeWaste_Height: ["평균 수고(m)", "Mean tree height (m)"], + B08_Quantity_TreeWaste_UnitWeight: ["단위체적중량(㎏/㎥)", "Unit weight (kg/㎥)"], + B08_Quantity_Side_TreeWaste_Hint: [ + "현장 조사값 넷을 넣으면 준비공 「임목폐기물 처리」가 톤으로 섭니다. 산식: 지상부 WA = 0.5 × π × (흉고직경/2)² × 수고 × (1+0.3) × 단위체적중량 × 본수 · 뿌리부 WR = WA × 15/85 · 본수 = 1,000㎡당 본수 × 벌목·지장목제거 면적 — 한국건설기술연구원(2012) 3.2. 단위체적중량 참고: 소나무·침엽수 1,210~1,250 · 단풍·산벚 1,250~1,300 · 느티 1,300~1,340 · 상수리·졸참 1,340 이상(조경설계요람). 기본값 없음 — 수종·임분마다 다름", + "Enter the four field-survey values to raise the tree-waste row in tonnes. WA = 0.5 × π × (DBH/2)² × height × 1.3 × unit weight × stems; roots WR = WA × 15/85 (KICT 2012). No defaults — values differ by species and stand", + ], + B08_Quantity_TreeWaste_Price: ["처리단가(원/톤) — 수동", "Disposal price (KRW/t) — manual"], + B08_Quantity_TreeWaste_Price_Hint: [ + "⚠ 공표 단가 없음(한국건설자원협회 중간처리단가 미확보) — 넣으면 금액이 서되 「미확정」으로 셉니다(빨간 테두리). 비목은 경비 — 원가계산서 폐기물처리비 줄로 가고 일반관리비·이윤 밑수에 듭니다(예정가격작성기준 제19조③18호)", + "No published price — a manual price raises the amount as unconfirmed (red outline). Cost category is expense, inside the overhead and profit bases", + ], + B08_Quantity_TreeWaste_Separate: ["분리발주", "Separate disposal contract"], + B08_Quantity_TreeWaste_Separate_Off: ["분리발주 아님(기본)", "No (default)"], + B08_Quantity_TreeWaste_Separate_On: ["분리발주", "Yes"], + B08_Quantity_TreeWaste_Separate_Hint: [ + "기본은 분리발주 아님 — 원가계산서 경비에 듭니다. 켜면 총원가 밖에 서고 총공사비에만 더합니다. 참고: 건설폐기물 위탁처리 100톤 이상은 분리발주 대상(건설폐기물 업무처리지침)이나, 같은 지침은 임목폐기물을 건설폐기물이 아닌 사업장 일반폐기물로 분류함 · 5톤 이상은 배출자 신고 대상(폐기물관리법 시행령 제2조8호)", + "Default: not separate — included in the expense category. When on, it sits outside total cost. Note: the 100 t separate-order rule is for construction waste; tree waste is classified as business general waste. 5 t or more requires a discharge report", + ], B08_Quantity_Side_SubgradeCompaction: ["노체다짐", "Subgrade Compaction"], B08_Quantity_SubgradeCompaction_Label: ["노체다짐을 셀 것인가", "Count subgrade compaction"], B08_Quantity_Side_SubgradeCompaction_Hint: [