From 9158a886426192c93d4b57f965a33dacb8de46d6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 01:57:00 +0900 Subject: [PATCH 01/16] =?UTF-8?q?knowledge(=EC=B6=95C):=20=EC=A7=91?= =?UTF-8?q?=EA=B3=84=ED=91=9C=20=EB=8B=A8=EA=B0=80=20=EC=97=B4=C2=B7?= =?UTF-8?q?=EC=A4=91=EA=B8=B0=20=EB=B0=98=EC=98=AC=EB=A6=BC=20=EC=A0=95?= =?UTF-8?q?=EC=A0=95=20=E2=80=94=20=EA=B3=A8=EB=93=A0=EC=85=8B=20=EC=8B=A4?= =?UTF-8?q?=EC=B8=A1?= 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 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/raw/verification/2026-09-13_축C_명세.md b/docs/raw/verification/2026-09-13_축C_명세.md index 4cd301b1..2b3a1862 100644 --- a/docs/raw/verification/2026-09-13_축C_명세.md +++ b/docs/raw/verification/2026-09-13_축C_명세.md @@ -328,7 +328,8 @@ B08 인계본 `ground_class_aliases` — **B09 가 안 읽음**)을 한 벌로 | 자리 | 규칙 | 근거 | | --- | --- | --- | | **호표 안 성분 소계** | **절사** | 중기사용료 398건 100% · 일위대가 345건 94.2% | -| **집계표 행 금액** | `ROUND` | 자원·일위대가·중기 집계표 | +| **집계표 행 금액** | `ROUND` | 자원 셋(재료비·노무비·경비) **279/279** | +| **중기시간금액집계표** | **성분마다 반올림한 뒤 합** | **131/144**. ⚠ 합계를 한 번에 반올림하면 107/144 로 떨어짐 | | **내역에 실릴 때** | 정수 절사 | 착공내역서 178건 100% | | **단가산출 성분단가** | 소수 1자리 절사 | 211건 중 137건 설명 | | 내부 계산 | 은행가(최근접-짝수) | STmate 내부. ⚠ 뽑은 엑셀은 사사오입이라 20개 경계 중 10개가 갈림 | From 7be245db2bcbe284a7465df9637d544630065535 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 07:48:34 +0900 Subject: [PATCH 02/16] =?UTF-8?q?knowledge(=EC=B6=95C=20=EB=AA=85=EC=84=B8?= =?UTF-8?q?):=20=ED=92=88=EC=85=88=20=ED=8C=90=20=EA=B7=9C=EC=B9=99=C2=B7?= =?UTF-8?q?=EB=B0=98=EC=98=AC=EB=A6=BC=20=EA=B3=A0=EC=8B=9C=20=EA=B7=BC?= =?UTF-8?q?=EA=B1=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 2장 — FP- 는 산림사업 표준품셈 한 판, 건설품셈은 기계 층만. 고시 2025-82호 총칙 7 조문 근거 - 7장 — 금액의 단위표준 표 다섯 줄과 실측 대조. 넷은 맞고 「총액 1,000원 미만버림」만 미확인 - 7장 — 수량의 단위표준 [주] 셋. 「규정 없는 자리 소수 2자리」는 이 표에도 없는 자리에만 - 막히면 산림품셈을 먼저 볼 것 — 덤프 운반이 건설 8-2-8 만 보다 막혔던 자리 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0154EMdwPYNPuKc9eZv8SLEX --- docs/raw/verification/2026-09-13_축C_명세.md | 43 ++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/docs/raw/verification/2026-09-13_축C_명세.md b/docs/raw/verification/2026-09-13_축C_명세.md index 2b3a1862..bcb21055 100644 --- a/docs/raw/verification/2026-09-13_축C_명세.md +++ b/docs/raw/verification/2026-09-13_축C_명세.md @@ -90,6 +90,24 @@ FP-<절>-<절>-<절> 예) FP-04-02-02 (벌목, 5m 미만) - 정본은 **품셈 절 번호**. 표의 행은 그 절의 **조건별 변형**(`variant` 칸)임 - **이름은 표시용, 잇기는 코드로.** 이름으로 맞추는 코드가 남아 있으면 그것이 곧 결함임 + +### ⭐ 어느 품셈 판인가 — 2026-09-14 확인 (조문 근거 있음) + +**`FP-` 는 산림사업 표준품셈 한 판임.** 건설공사 표준품셈은 **공종 축이 아니라 기계 층에만** 들어옴. + +``` +공종 축 477/477 FP- · 원천 pum_forest_2026.json (산림청고시 2025-82호, 2026-01-01) +기계 층 pum_const_2026.json · mach_base_2026.json (건설품셈 제8장 — 취득가·손료계수·연료·운전원) +빌려 온 몇 곳 「교차 참조」 딱지를 붙임 (예: 뿌리뽑기 밑수 3-9-2) +``` + +**근거 — 같은 고시 총칙 7**: 「본 품셈에 명시되지 않은 품은 타 부문(토목, 건축, 전기, 기계 등)의 +표준품셈에 명시된 부분의 품을 적용하고, **타 부문과 유사한 공종의 품은 본 품셈을 우선하여 적용**한다.」 + +⇒ 지금 얼개가 이 조문 그대로임. **건설품셈 절번호로 선 공종 줄은 0 건**(소스에 「8-2-8」 참조 0건). +⚠ 자료를 찾을 때 건설품셈만 보면 「값이 없다」고 잘못 결론 내기 쉬움 — 덤프 운반이 그랬음 +(건설 8-2-8 에는 단위중량·적재용량 표가 없으나 **산림 10-12 [주]에 토사 1.9 · 암 2.4 ton/㎥ · 덤프 15 ton 이 있었음**). +**막히면 산림품셈 쪽을 먼저 볼 것.** - 부모·잎이 있음. ⚠ **「부모에 수량 금지」는 반쪽만 맞았음** — 2026-09-13 정정. **부모가 두 모양이고 규칙이 다름**(랩탑 메인 실측). @@ -334,6 +352,31 @@ B08 인계본 `ground_class_aliases` — **B09 가 안 읽음**)을 한 벌로 | **단가산출 성분단가** | 소수 1자리 절사 | 211건 중 137건 설명 | | 내부 계산 | 은행가(최근접-짝수) | STmate 내부. ⚠ 뽑은 엑셀은 사사오입이라 20개 경계 중 10개가 갈림 | +### ⭐⭐ 2026-09-14 — 위 실측이 **고시 조문과 그대로 맞았음** + +**산림청고시 제2025-82호 「산림사업 표준품셈」 1-2-2 「2. 금액의 단위표준」**: + +| 종목 | 단위 | 지위 | 비고 | 우리 실측과 | +| --- | --- | --- | --- | --- | +| 설계서의 **총액** | 원 | **1,000** | 미만버림 | ⚠ **아직 확인 안 됨** — 데스크탑 서브가 재는 중 | +| 설계서의 소계 | 원 | 1 | 미만버림 | ✅ | +| 설계서의 **금액란** | 원 | 1 | 미만버림 | ✅ 「내역에 실릴 때 정수 절사」와 같음 | +| **일위대가표의 계금** | 원 | 1 | 미만버림 | ✅ 「호표 안 성분 소계 절사」와 같음 | +| **일위대가표의 금액란** | 원 | **0.1** | 미만버림 | ✅ 「단가산출 성분단가 소수 1자리 절사」와 같음 | + +⇒ **STmate 를 재서 얻은 규칙이 법 조문이었음.** 경쟁사를 베낀 것이 아니라 둘 다 같은 고시를 따른 것임. + +**같은 고시 1-2-2 「1. 수량의 단위표준」 [주]**: + +``` +① 설계서 수량의 단위와 소수자리 표시는 본 표에 따르며, 반올림하여 적용 +② 품목이 제시한 소숫자리가 본 표와 다르면 항목 쪽을 우선 +③ 본 표에 없는 품은 유사품의 규격·단위수량을 참고, C.G.S 단위 원칙 +``` + +⇒ 아래 「규정이 없는 자리 = 소수 2자리」는 **이 표에도 없는 자리에만** 적용할 것. +표에 실린 품목(관류 길이 m 소수 2 · 수로연장 m 소수 1 · 옹벽 ㎡ 소수 1 …)은 **표가 규정임.** + ### ⭐ 규정이 없는 자리 — 소수점 2자리가 기본값 (2026-09-13 사용자 확정) **단수처리에 법적 근거가 없음이 확인됐음** — 기재부 **예정가격작성기준 전 조문에 From b84ee14bea12fb3ebadf08924f80d80d5a53ce45 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 07:48:39 +0900 Subject: [PATCH 03/16] auto: 2026-09-14 07:48 (EOMSANGDON-HOME) --- main.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/main.py b/main.py index 98f805d1..a682f00f 100644 --- a/main.py +++ b/main.py @@ -65,6 +65,12 @@ from B08_Quantity.B08_Quantity_Router_Earthwork import router as b08_earthwork_r from B08_Quantity.B08_Quantity_Router_Material import router as b08_material_router from B08_Quantity.B08_Quantity_Router_StructureSheet import router as b08_structure_sheet_router from B09_Estimation.B09_Estimation_Router import router as b09_estimation_router +from B09_Estimation.B09_Estimation_Router_Contract import router as b09_contract_router +from B09_Estimation.B09_Estimation_Router_Execution import router as b09_execution_router +from B09_Estimation.B09_Estimation_Router_Progress import router as b09_progress_router +from B09_Estimation.B09_Estimation_Router_CostSheet import router as b09_cost_sheet_router +from B09_Estimation.B09_Estimation_Router_Edits import router as b09_edits_router +from B09_Estimation.B09_Estimation_Router_Factors import router as b09_factors_router from common_util.common_util_audit import note_api_call, record_call_burst from common_util.common_util_auth import ( require_company, @@ -636,6 +642,12 @@ app.include_router(b08_earthwork_router, dependencies=protected_with_company) app.include_router(b08_material_router, dependencies=protected_with_company) app.include_router(b08_structure_sheet_router, dependencies=protected_with_company) app.include_router(b09_estimation_router, dependencies=protected_with_company) +app.include_router(b09_cost_sheet_router, dependencies=protected_with_company) +app.include_router(b09_contract_router, dependencies=protected_with_company) +app.include_router(b09_execution_router, dependencies=protected_with_company) +app.include_router(b09_progress_router, dependencies=protected_with_company) +app.include_router(b09_edits_router, dependencies=protected_with_company) +app.include_router(b09_factors_router, dependencies=protected_with_company) # 개발 전용 잠금 해제 — 다른 라우터와 **같은 보호**를 받는다(로그인·회사·프로젝트 접근). # 그 위에 서버가 환경까지 한 번 더 본다. app.include_router(dev_unlock_router, dependencies=protected_with_company) From 808cf0f289433ecbec1d3d7bc3e2e092d6d7b606 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 07:49:02 +0900 Subject: [PATCH 04/16] =?UTF-8?q?fix(b09):=20=EA=B8=B0=EA=B3=84=20?= =?UTF-8?q?=EC=B9=B4=ED=83=88=EB=A1=9C=EA=B7=B8=20=EC=9D=B4=EB=A6=84=20?= =?UTF-8?q?=EB=90=98=EC=82=B4=EB=A6=BC=20=E2=80=94=20=ED=91=9C=20=EB=A8=B8?= =?UTF-8?q?=EB=A6=AC=EA=B8=80=20=EB=B6=99=EC=9D=80=205=EA=B1=B4=20=C2=B7?= =?UTF-8?q?=20=EB=B9=88=20=EC=9D=B4=EB=A6=84=2029=EA=B1=B4(=EC=9B=90?= =?UTF-8?q?=EC=B2=9C=20=EC=AA=BD=20=EB=84=98=EA=B9=80=20=ED=8C=8C=EC=8B=B1?= =?UTF-8?q?=20=EB=B3=91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 쪽 끝 기종 이름에 다음 쪽 머리글·다음 분류 이름이 붙고, 다음 분류 첫 기종들은 이름이 비던 병 - 이름·규격은 건설공사 표준품셈 제8장 기종 목록 줄에서 읽음 · 취득가·손료계수는 원천 그대로 - 대형 브레이커 되살림 표 옆에 같은 약속으로 둠(원천이 실으면 지움) - 자원 축 781/386 · 범위 81공종 상태 전후 같음 · 시험 4건 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- B09_Estimation/B09_Estimation_MachineCost.py | 31 ++++++++++ .../tester/test_b09_machine_catalog_names.py | 60 +++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 resources/tester/test_b09_machine_catalog_names.py diff --git a/B09_Estimation/B09_Estimation_MachineCost.py b/B09_Estimation/B09_Estimation_MachineCost.py index ca1615f1..3c1b5576 100644 --- a/B09_Estimation/B09_Estimation_MachineCost.py +++ b/B09_Estimation/B09_Estimation_MachineCost.py @@ -132,6 +132,32 @@ MASHED_MACHINE_FIXES: dict[str, tuple[str, str]] = { #: (0230) 표의 「시간당 계」 — 규격이 달라도 같은 값이다(원문 여섯 줄 모두 6,601). MASHED_LOSS_COEFFICIENT = Decimal("0.0006601") +#: ⚠ **같은 파싱 병의 나머지** (2026-09-14 · 축 C 1장 ① 브레인 차례). +#: 원천이 쪽 끝 기종의 이름 칸에 **다음 쪽 머리글 + 다음 분류 이름**을 붙여 넣어 ① 그 기종은 +#: 이름에 표 머리글이 붙고 규격이 비고 ② 다음 분류 첫 기종들은 **이름이 빔**. 이름으로 찾아지지 +#: 않고 화면에도 빈 이름으로 선다. 이름·규격은 **건설공사 표준품셈 제8장 표**(`pum_const_2026.json` +#: 기종 목록 줄 · 붙은 꼬리 「(분류번호) 이름」)에서 읽음 — 취득가·손료계수는 원천 그대로. +#: ⚠ 원천이 바로 실으면 이 표는 지운다(위 표와 같은 약속). +GLUED_MACHINE_FIXES: dict[str, tuple[str, str]] = { + "5220-0015": ("소형브레이커(전기식)", "1.5㎾"), + "6532-0220": ("진동파일 해머(유압식)", "162㎾"), + "7101-0450": ("고성능 착정기", "335.70㎾"), + "7120-0746": ("버킷식준설기", "7.46kW"), + "7995-0050": ("배관파이프", "ø50-2.6m"), +} +#: 이름이 빈 분류 — 분류번호(코드 앞 넷) → 원문 이름. 규격은 원천 값 그대로. +EMPTY_NAME_BY_GROUP: dict[str, str] = { + "0240": "유압식 진동콤팩터(굴착기 부착용)", + "3611": "콘크리트 피니셔(중앙분리대용)", + "5330": "드릴웨곤", + "6540": "워터젯트", + "6802": "파일천공전용장비", + "7202": "자동세륜기(롤 타입)", + "7930": "모터", + "8201": "3D GNSS 머신 가이던스(굴착기용)", + "9070": "이우선(비자항)", +} + class MachineCostError(LookupError): """기계경비를 세울 수 없는 경우. 0 으로 때우지 않고 멈춘다.""" @@ -205,6 +231,11 @@ def load_machine_catalog(file_name: str = "mach_base_2026.json") -> MachineCatal # 뭉개진 줄 — 원문으로 이름·규격을 되살리고 손료계수를 채운다. row = {**row, "machine_name": fixed[0], "specification": fixed[1]} coefficient = {**coefficient, "loss_coefficient_per_hour": MASHED_LOSS_COEFFICIENT} + elif code in GLUED_MACHINE_FIXES: + name, spec = GLUED_MACHINE_FIXES[code] + row = {**row, "machine_name": name, "specification": spec} + elif not str(row.get("machine_name") or "").strip() and code[:4] in EMPTY_NAME_BY_GROUP: + row = {**row, "machine_name": EMPTY_NAME_BY_GROUP[code[:4]]} catalog.machines[code] = MachineSpec( machine_code=code, name=row["machine_name"], diff --git a/resources/tester/test_b09_machine_catalog_names.py b/resources/tester/test_b09_machine_catalog_names.py new file mode 100644 index 00000000..5feaf783 --- /dev/null +++ b/resources/tester/test_b09_machine_catalog_names.py @@ -0,0 +1,60 @@ +"""기계 카탈로그 이름 — 원천 파싱 병(쪽 끝 이름에 다음 쪽 머리글이 붙고 다음 분류 이름이 빔) 되살림. + +2026-09-14 축 C 1장 ① (브레인 차례) · 원문 = 건설공사 표준품셈 제8장 기종 목록 줄. + ① 표 머리글(「시 간 당」·「(10-7)」)이 붙은 이름이 하나도 없음 + ② 빈 이름이 하나도 없음 + ③ 되살린 이름·규격이 원문 차례와 같음 · 취득가는 원천 그대로 +""" + +from __future__ import annotations + +import json +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_MachineCost import load_machine_catalog # noqa: E402 + +CATALOG = load_machine_catalog() + + +def test_표_머리글이_붙은_이름이_없다() -> None: + glued = [ + m.machine_code + for m in CATALOG.machines.values() + if "시 간 당" in m.name or "(10-7)" in m.name + ] + assert glued == [] + + +def test_빈_이름이_없다() -> None: + assert [m.machine_code for m in CATALOG.machines.values() if not m.name.strip()] == [] + + +def test_되살린_이름과_규격() -> None: + expected = { + "5220-0015": ("소형브레이커(전기식)", "1.5㎾"), + "7101-0450": ("고성능 착정기", "335.70㎾"), + "0240-0007": ("유압식 진동콤팩터(굴착기 부착용)", "0.7"), + "3611-0142": ("콘크리트 피니셔(중앙분리대용)", "105.9"), + "6802-0100": ("파일천공전용장비", "100"), + "9070-0020": ("이우선(비자항)", ""), + } + for code, (name, spec) in expected.items(): + machine = CATALOG.machines[code] + assert (machine.name, machine.specification) == (name, spec), code + + +def test_취득가는_원천_그대로() -> None: + raw = json.loads( + (ROOT / "resources/data_cost_input_value/mach_base_2026.json").read_text(encoding="utf-8") + ) + prices = { + r["machine_code"]: r["price_thousand_krw"] + for r in raw["variables"]["mach_price"]["records"] + } + for code in ("5220-0015", "0240-0007", "6802-0040", "7930-0100"): + assert CATALOG.machines[code].price_thousand_krw == Decimal(str(prices[code])) From b97cb4e2e656fab84b42294beba52bd6ebed9a52 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 07:52:55 +0900 Subject: [PATCH 05/16] =?UTF-8?q?feat(b08):=20=EA=B3=84=EA=B3=A1=20?= =?UTF-8?q?=ED=86=B5=EA=B3=BC=20=EC=8B=9C=EC=84=A4=EB=8F=84=20=EC=9B=90?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=EC=A0=84=EA=B0=9C=EB=A1=9C=20=E2=80=94=20?= =?UTF-8?q?=EA=B4=80=20=EC=9C=A0=EC=9E=85=C2=B7=EC=9C=A0=EC=B6=9C=EB=B6=80?= =?UTF-8?q?=20=EA=B8=B0=EC=8A=AD=EB=A7=89=EC=9D=B4=C2=B7=EC=A7=91=EC=88=98?= =?UTF-8?q?=EC=A0=95=C2=B7=EB=8F=85=EB=A6=BD=20=EA=B8=B0=EC=8A=AD=EB=A7=89?= =?UTF-8?q?=EC=9D=B4=C2=B7=EB=AC=BC=EB=84=98=EC=9D=B4=20(A1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 전개가 structures.json 만 읽던 구멍 — pipe_points.json 도 읽음(관 자체는 관 줄이 셈) - 관 부설(품셈 12-11 m당)에 기슭막이 몫 없음 → 기슭막이는 전개 한 곳에서만 · 사유에 적음 - 안 적힌 벽 칸은 등록부 기본값(횡단도 그림과 같은 값) + 「기본값으로 섰음」 사유 - 구조물 목록에 남은 관 지점 종류 옛 저장분은 안 셈(두 번 방지) - 기슭막이 공종 = 형태로 돌쌓기 찰·메 코드 · 독립 기슭막이 한쪽 설치에 두 칸이 다르면 사유만 - 산출식 없는 세월교가 「터파기 줄 없음」 거짓 사유를 안 내게 · 구조물도 제원 저장이 관 지점 시설엔 안 먹힘을 알림 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- .../B08_Quantity_Engine_Handoff_Rows.py | 5 + .../B08_Quantity_Engine_Handoff_Trench.py | 3 +- B08_Quantity/B08_Quantity_Engine_Pipe.py | 139 +++++++++++++++++- .../B08_Quantity_Engine_UnitQuantity.py | 36 +++-- B08_Quantity/B08_Quantity_Router_Material.py | 23 ++- .../B08_Quantity_Router_StructureSheet.py | 7 + .../work_item_mapping_2026-01-01.json | 17 ++- .../tester/test_b08_facility_structures.py | 86 +++++++++++ 8 files changed, 295 insertions(+), 21 deletions(-) create mode 100644 resources/tester/test_b08_facility_structures.py diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py index b0fe9ba5..68adef0a 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py @@ -378,6 +378,11 @@ def _structure_rows( "큰돌쌓기 쌓기 방식이 아직 입력되지 않았습니다 — 구조물 상세 입력에서 " "메쌓기·찰쌓기 중 하나를 고르면 공종이 정해집니다" ) + if entry.get("class_from") == "form": + # 기슭막이 — **형태**가 공종을 가름(돌쌓기 찰·메). 그 밖 형태는 전개 사유가 막음. + form = str((structure.get("options") or {}).get("form") or "").strip() + code = (entry.get("form_codes") or {}).get(form) + class_basis = f"형태 「{form}」 → {code}" if code else "" if code and entry.get("class_from") == "back_length": class_key, class_basis = masonry_class(structure.get("options") or {}) diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Trench.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Trench.py index 7acc15f2..55ccbc65 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Trench.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Trench.py @@ -243,7 +243,8 @@ def structure_earthwork_rows( backfill += amount elif name == "잔토처리": spoil += amount - if not any( + # 성분이 아예 없는 구조물(세월교 등 산출식 없음)은 제 줄이 이미 사유로 막힘 — 여기 안 적음. + if structure.get("components") and not any( str(component.get("name") or "") == "터파기" and float(component.get("amount") or 0.0) > 0 for component in structure.get("components") or [] diff --git a/B08_Quantity/B08_Quantity_Engine_Pipe.py b/B08_Quantity/B08_Quantity_Engine_Pipe.py index 812b3649..e0b5ca9e 100644 --- a/B08_Quantity/B08_Quantity_Engine_Pipe.py +++ b/B08_Quantity/B08_Quantity_Engine_Pipe.py @@ -16,9 +16,9 @@ 기슭막이가 같은 파일에 있다. `pipe_diameter_mm` 유무로 가르면 **관경 미지정 관을 놓친다** (실측: `5601e828` 11점 중 2점이 `facility: ford_bridge`). -⚠ **유출·유입부 기슭막이는 여기서 세지 않는다.** - 관 옵션(`outlet_revet_*`)이 정본이고 구조물 목록에서는 빠졌다(2026-08-28 이관). - 구조물 쪽으로 또 세면 이중계상이다. +⚠ **유출·유입부 기슭막이는 관 줄에 넣지 않는다 — `facility_structures` 가 원단위 전개로 한 번 셈.** + 관 옵션(`outlet_revet_*`)이 정본이다(2026-08-28 이관). 관 부설(품셈 12-11 m당: 관·기초콘크리트· + 거푸집)에 기슭막이 몫이 없어 겹치지 않는다. 2026-09-14 까지는 **어느 쪽도 안 세고 있었다**(A1). ⚠ **터파기·되메우기를 관 줄에 붙이지 않는다.** 관 부설과 굴착이 각각 오면 **같은 굴착을 두 번** 센다(B09 ㉡ 가드와 같은 자리). @@ -192,3 +192,136 @@ def build_rows( "ready_count": sum(1 for row in rows if row["in_bill"]), "length_total_m": round(sum(row["quantity"] for row in rows if row["in_bill"]), 3), } + + +INLET_BASIN = "집수정" +#: 기슭막이 벽 칸 — 관·독립 기슭막이 모두 `{쪽}_revet_{칸}` · 독립의 옛 저장분은 `{칸}`(B06 `_revet_side`). +REVET_FIELDS = ( + ("form", "형태"), + ("height_m", "높이"), + ("length_m", "길이"), + ("before_m", ""), + ("after_m", ""), +) +#: (저장 채널, 관에 딸린 줄 이름 꼬리, 독립 기슭막이 줄 이름 꼬리) +REVET_ROLES = ( + ("inlet", "유입부 기슭막이", "유입 칸 벽"), + ("outlet", "유출부 기슭막이", "유출 칸 벽"), +) +NOTE_DEFAULT_WALL = ( + "⚠ 시설 지점에 {filled} 을 안 적어 등록부 기본값으로 섰음 — 횡단도 그림과 같은 값" +) +NOTE_SIDE_UNKNOWN = ( + "⚠ 설치 측 「{side}」인데 두 칸(유입·유출) 값이 달라 어느 칸이 그쪽인지 서버가 못 가림" + "(횡단 지형이 정함) — 값을 안 세움 · 두 칸을 같게 적으면 섬" +) + + +def _blank(value: Any) -> bool: + return value is None or value == "" + + +def _num_or_zero(value: Any) -> float: + try: + return float(value) + except (TypeError, ValueError): + return 0.0 + + +def _revet_values( + options: dict[str, Any], role: str, defaults: dict[str, Any], legacy: bool +) -> tuple[dict[str, Any], list[str]]: + """벽 한쪽 제원과 등록부 기본값으로 채운 칸 — B06 횡단 그림(`_side_spec`)과 같은 채움.""" + values: dict[str, Any] = {} + filled: list[str] = [] + for name, label in REVET_FIELDS: + value = options.get(f"{role}_revet_{name}") + if _blank(value) and legacy: + value = options.get(name) + if _blank(value): + value = defaults.get(name if legacy else f"{role}_revet_{name}") + if label and value is not None: + filled.append(f"{label} {value}") + values[name] = value + return values, filled + + +def facility_structures(points: list[dict[str, Any]]) -> list[dict[str, Any]]: + """계곡 통과 시설(`pipe_points.json`) → 원단위 전개가 읽는 구조물 줄 (A1, 2026-09-14). + + ⚠ 관 자체는 안 냄 — `build_rows` 가 관 연장으로 셈. + ⚠ 안 적힌 벽 칸은 **등록부 기본값** — 횡단 그림이 쓰는 값과 같고, 그 사실을 줄 사유로 붙임. + ⚠ 집수정·기슭막이 터파기·되메우기는 전개 성분(`destination: earthwork`)이라 토공집계로만 감. + """ + from B05_Profile.B05_Profile_Structures_Schema import structure_type_map + from B08_Quantity.B08_Quantity_Engine_StructureSummary import pipe_row_id + from B08_Quantity.B08_Quantity_Engine_UnitQuantity import attachments_of, child_row + + types = structure_type_map() + + def defaults(type_id: str) -> dict[str, Any]: + found = types.get(type_id) + return {option.key: option.default for option in found.options} if found else {} + + rows: list[dict[str, Any]] = [] + for point in points or []: + facility = str(point.get("facility") or FACILITY_PIPE) + chainage = float(point.get("chainage_m") or 0.0) + options = dict(point.get("options") or {}) + base = { + "structure_id": pipe_row_id(point), + "type_id": facility, + "chainage_m": chainage, + "start_m": point.get("start_m") if point.get("start_m") is not None else chainage, + "end_m": point.get("end_m") if point.get("end_m") is not None else chainage, + "options": options, + } + if facility not in (FACILITY_PIPE, "revetment"): + rows.append(base) # BOX암거·물넘이·세월교 — 전개식·관측값이 없으면 사유로 섬 + continue + legacy = facility == "revetment" # 독립 기슭막이 — 벽 칸 밖의 제원(뒷길이 등)도 벽이 씀 + wall_defaults = defaults(facility) + roles = list(REVET_ROLES) + inlet_notes: list[str] = [] + if not legacy: + parent = {**base, "type_id": "pipe"} + children = [r for r in attachments_of(parent) if r["type_id"] != "pipe_inlet_basin"] + inlet = str(options.get("inlet_type") or wall_defaults.get("inlet_type") or "") + if inlet == INLET_BASIN: + # 형식을 안 골랐어도 줄은 세움 — 관측표가 「형식을 골라야 섬」 사유를 냄. + children.append(child_row(parent, "pipe_inlet_basin", "유입부 집수정")) + roles = roles[1:] + elif options.get("inlet_basin_form"): + inlet_notes.append( + f"⚠ 집수정 형식({options['inlet_basin_form']})이 적혀 있으나 유입구 구조가 " + f"「{inlet}」라 집수정은 안 셈 — 유입구를 「집수정」으로 바꾸면 섬" + ) + rows.extend(children) + walls = [] + for role, pipe_label, own_label in roles: + values, filled = _revet_values(options, role, wall_defaults, legacy) + notes = list(inlet_notes) if role == "inlet" else [] + if filled: + notes.append(NOTE_DEFAULT_WALL.format(filled=" · ".join(filled))) + walls.append([role, own_label if legacy else pipe_label, values, notes, False]) + side = str(options.get("side") or "") if legacy else "" + if side in ("좌", "우"): + # 좌·우가 유입/유출 어느 칸인지는 **횡단 지형**이 정함 — 두 칸이 같을 때만 한 벽으로 셈. + same = walls[0][2] == walls[1][2] + walls = [[walls[0][0], f"{side} 벽", walls[0][2], walls[0][3], not same]] + if not same: + walls[0][3].append(NOTE_SIDE_UNKNOWN.format(side=side)) + foundation = options.get("foundation" if legacy else "revet_foundation") + kept = {k: v for k, v in options.items() if not k.startswith(("inlet_", "outlet_"))} + for role, label, values, notes, withheld in walls: + before, after = _num_or_zero(values["before_m"]), _num_or_zero(values["after_m"]) + row = child_row(base, "revetment", label, f"{role}_revet") + row.update( + start_m=chainage - before, + end_m=chainage + after, + options={**(kept if legacy else {}), **values, "foundation": foundation}, + notes=notes, + withheld=withheld, + ) + rows.append(row) + return rows diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py index b5769d65..dcd043c2 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -198,24 +198,26 @@ def wing_wall_double_count(options: dict[str, Any]) -> str | None: return WING_WALL_DOUBLE_COUNT if options.get("inlet_basin_form") else None +def child_row(structure: dict[str, Any], type_id: str, label: str, suffix: str = "") -> dict: + """딸린 줄 한 벌 — 이름에 「어디에 딸렸나」가 남음(`expand` 가 부모 이름 · 꼬리로 붙임).""" + return { + **structure, + "structure_id": f"{structure.get('structure_id')}-{suffix or type_id}", + "type_id": type_id, + "attachment_of": structure.get("structure_id"), + "attachment_parent_type": structure.get("type_id"), + "attachment_label": label, + } + + def attachments_of(structure: dict[str, Any]) -> list[dict[str, Any]]: """구조물에 딸린 **별도 줄**을 만든다. 제원은 원본을 그대로 물려준다(치수 두 벌 금지).""" - rows: list[dict[str, Any]] = [] options = structure.get("options") or {} - for type_id, gate_key, label in ATTACHMENTS.get(str(structure.get("type_id") or ""), ()): - if not options.get(gate_key): - continue # 그 부속이 없는 배치다 — 빈 줄을 만들지 않는다 - rows.append( - { - **structure, - "structure_id": f"{structure.get('structure_id')}-{type_id}", - "type_id": type_id, - "attachment_of": structure.get("structure_id"), - "attachment_parent_type": structure.get("type_id"), - "attachment_label": label, - } - ) - return rows + return [ + child_row(structure, type_id, label) + for type_id, gate_key, label in ATTACHMENTS.get(str(structure.get("type_id") or ""), ()) + if options.get(gate_key) # 그 부속이 없는 배치다 — 빈 줄을 만들지 않는다 + ] def _observed_components( @@ -288,6 +290,10 @@ def expand( end_m=end if structure.get("end_m") is not None else None, options=dict(options), ) + # 줄을 만든 쪽이 붙인 사유(관 지점 시설의 기본값 · 못 가른 자리) — `withheld` 면 값을 안 세움. + result.notes.extend(str(note) for note in structure.get("notes") or []) + if structure.get("withheld"): + return result withheld = EXPANDER_WITHHELD.get(type_id) if withheld: result.notes.append(f"전개식 미확보 — {withheld}") diff --git a/B08_Quantity/B08_Quantity_Router_Material.py b/B08_Quantity/B08_Quantity_Router_Material.py index acfb3708..2b767788 100644 --- a/B08_Quantity/B08_Quantity_Router_Material.py +++ b/B08_Quantity/B08_Quantity_Router_Material.py @@ -58,7 +58,17 @@ router = APIRouter(prefix="/api/projects", tags=["B08 Quantity"]) def _collect_structures( project_root: str, ) -> tuple[list[dict[str, Any]], dict[str, str], list[str]]: - """전개 대상 구조물·타입명·건너뛴 사유를 함께 낸다.""" + """전개 대상 구조물·타입명·건너뛴 사유를 함께 낸다. + + ⭐ 정본 둘을 다 읽음(A1, 2026-09-14) — `structures.json` + `pipe_points.json`(계곡 통과 시설). + 앞서 뒤엣것을 안 읽어 집수정·날개벽·관 유입/유출 기슭막이·독립 기슭막이·물넘이포장이 + 원단위·인계·내역에 한 줄도 안 섰음. + ⚠ 관 지점 종류(`managed_by`)가 `structures.json` 에 옛 저장분으로 남아 있어도 안 셈 — + 관 지점 정본이 주인이라 두 번 세지 않음(구조물 집계표와 같은 규칙). + """ + from B08_Quantity.B08_Quantity_Engine_Pipe import facility_structures + from common_util.common_util_drainage_pipes import pipe_points_path_in, read_pipe_points_file + _revision, items = load_structures(project_root) types = structure_type_map() targets: list[dict[str, Any]] = [] @@ -72,6 +82,11 @@ def _collect_structures( skipped.append(f"{type_id}: 레지스트리에 없는 타입") continue names[type_id] = definition.name + if definition.managed_by: + skipped.append( + f"{definition.name}: 관 지점 정본이 주인 — 구조물 목록 옛 저장분은 안 셈" + ) + continue if definition.design_owner: skipped.append( f"{definition.name}: {definition.design_owner} 가 이미 셈 — 중복 계상 방지" @@ -86,6 +101,12 @@ def _collect_structures( # 여기서 빼지 않으면 **같은 시설이 두 줄로** 나간다. continue targets.append(payload) + points = read_pipe_points_file(pipe_points_path_in(Path(project_root))) + for row in facility_structures([point.as_dict() for point in points]): + for type_id in (row["type_id"], row.get("attachment_parent_type")): + if type_id in types: + names[type_id] = types[type_id].name + targets.append(row) return targets, names, sorted(set(skipped)) diff --git a/B08_Quantity/B08_Quantity_Router_StructureSheet.py b/B08_Quantity/B08_Quantity_Router_StructureSheet.py index e268d3c8..d47daa53 100644 --- a/B08_Quantity/B08_Quantity_Router_StructureSheet.py +++ b/B08_Quantity/B08_Quantity_Router_StructureSheet.py @@ -259,6 +259,13 @@ async def put_structure_sheet_spec( try: revision, stored = await asyncio.to_thread(load_structures, project_root) updated, changed = apply_spec(stored, member_ids, spec) + outside = member_ids - {str(item.structure_id) for item in stored} + if outside: + # 관 지점 시설(A1)은 `pipe_points.json` 이 정본 — 조용히 안 먹히지 않게 알림. + notes.append( + f"관 지점 시설 {len(outside)}개소(배수관 기슭막이 등)는 이 화면에서 제원을 못 적음" + " — B05 배수 시설·구조물 집계표에서 고칠 것" + ) new_revision = await asyncio.to_thread( save_structures, project_root, updated, base_revision=payload.base_revision ) diff --git a/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json b/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json index 86ceb393..7c163680 100644 --- a/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json +++ b/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json @@ -220,6 +220,21 @@ "work_item_code": "FP-12-15", "master_name": "집수정" }, + { + "type_id": "revetment", + "work_item_code": null, + "class_from": "form", + "form_codes": { + "돌쌓기(찰)": "FP-13-04-05", + "돌쌓기(메)": "FP-13-04-02" + }, + "class_note": "기슭막이는 **형태가 돌쌓기면 돌쌓기 식**(`_UnitQuantity_Revetment`) — 공종도 돌쌓기 찰·메와 같음(실무 정본 탭 「돌기슭막이(H=2.0m, 찰쌓기, 기초유)」). 그 밖 형태(콘크리트·돌망태·통나무·바자)는 전개식이 없어 사유로 막힘. 관 유입·유출부 기슭막이와 독립 기슭막이가 이 줄로 섬(A1, 2026-09-14).", + "secondary_axes": [ + "stone_kind" + ], + "billing_component": "돌쌓기", + "variant_axis": "back_len_cm" + }, { "type_id": "ford_pavement", "work_item_code": "FP-12-06", @@ -414,7 +429,7 @@ "diameter_option_key": "pipe_diameter_mm", "variant_axis": "pipe_diameter_mm", "facility_rule": "⚠ `pipe_points.json` 은 **계곡 통과 시설 전부의 정본**이다(배관·BOX암거·물넘이·세월교·독립 기슭막이). `facility` 가 `pipe` 인 점만 배관이다 — `pipe_diameter_mm` 유무로 가르면 **관경 미지정 관을 놓친다**(2026-09-08 랩탑 창).", - "revetment_note": "⚠ 유출·유입부 기슭막이는 **관 옵션(`inlet_revet_*`·`outlet_revet_*`)이 정본**이다. 레지스트리 `revetment` 타입이 `managed_by: pipe_points` 라 구조물 목록에서 빠졌으므로 **구조물 쪽으로 또 세지 않는다**(2026-08-28 이관).", + "revetment_note": "⚠ 유출·유입부 기슭막이는 **관 옵션(`inlet_revet_*`·`outlet_revet_*`)이 정본**이다. 레지스트리 `revetment` 타입이 `managed_by: pipe_points` 라 구조물 목록에서 빠졌다(2026-08-28 이관). ⭐ **관 줄에는 안 넣고 원단위 전개가 한 번 셈** — B08 이 관 지점을 읽어 「배수관 · 유입부/유출부 기슭막이」 줄로 세움(`Engine_Pipe.facility_structures`, A1 2026-09-14). 관 부설(품셈 12-11 m당: 관·기초콘크리트·거푸집)에 기슭막이 몫이 없어 겹치지 않음. 그전에는 **어느 쪽도 안 세고 있었음**.", "not_ready": { "흄관 밑수 두 벌": "`FP-12-11-02` 는 밑수가 「1 m」와 「1 개소」 두 벌이다(표가 둘). B09 가 `#갈래` 로 두 표를 각각 세우므로 B08 은 `variant_value` 로 어느 쪽인지 보내면 된다.", "터파기·되메우기": "⚠ 관 부설과 터파기·되메우기가 각각 오면 **같은 굴착을 두 번 셀 수 있다**(B09 ㉡ 가드). 관 줄에는 지금 터파기를 붙이지 않는다." diff --git a/resources/tester/test_b08_facility_structures.py b/resources/tester/test_b08_facility_structures.py new file mode 100644 index 00000000..8a684a5a --- /dev/null +++ b/resources/tester/test_b08_facility_structures.py @@ -0,0 +1,86 @@ +"""계곡 통과 시설(`pipe_points.json`)이 원단위 전개·인계에 선다 (A1, 2026-09-14). + +⚠ 앞서 전개가 `structures.json` 만 읽어 관 유입·유출부 기슭막이·집수정·독립 기슭막이가 + 원단위·내역에 한 줄도 안 섰음. 관 줄(품셈 12-11 m당)에는 기슭막이 몫이 없어 **한 번만** 셈. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B05_Profile.B05_Profile_Structures_Schema import StructureInstance # noqa: E402 +from B08_Quantity import B08_Quantity_Router_Material as material # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_Pipe import build_rows, facility_structures # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402 + +NAMES = {"pipe": "배수관", "revetment": "기슭막이", "ford_bridge": "세월교"} + + +def _table(points: list[dict]) -> dict: + return build_table(facility_structures(points), NAMES) + + +def test_관_하나에_기슭막이_둘이_기본값_사유와_함께_서고_관_줄에는_없다() -> None: + point = {"chainage_m": 100.0, "options": {"pipe_diameter_mm": 1000}} + table = _table([point]) + names = [s["name"] for s in table["structures"]] + assert names == ["배수관 · 유입부 기슭막이", "배수관 · 유출부 기슭막이"] + inlet, outlet = table["structures"] + assert inlet["options"]["form"] == "돌쌓기(찰)" and outlet["options"]["form"] == "돌쌓기(메)" + assert all(s["components"] and s["length_m"] == 10 for s in table["structures"]) + assert "등록부 기본값으로 섰음" in inlet["notes"][0] + # 관 줄은 관 연장만 — 기슭막이가 거기 섞이지 않음(두 번 안 셈). + pipe = build_rows([point], [{"chainage_m": 100.0, "design": {"pipe_length_m": 8}}]) + assert [row["quantity"] for row in pipe["rows"]] == [8.0] + + work = {row["name"]: row for row in build_handoff(unit_quantity_table=table)["work_items"]} + assert work["배수관 · 유입부 기슭막이"]["work_item_code"] == "FP-13-04-05" + assert work["배수관 · 유출부 기슭막이"]["work_item_code"] == "FP-13-04-02" + assert ( + work["배수관 · 유입부 기슭막이"]["in_bill"] + and work["배수관 · 유입부 기슭막이"]["unit"] == "㎡" + ) + + +def test_유입구가_집수정이면_집수정_줄이_사유로_서고_유입_기슭막이는_없다() -> None: + table = _table([{"chainage_m": 5.0, "options": {"inlet_type": "집수정"}}]) + basin, outlet = table["structures"] + assert basin["type_id"] == "pipe_inlet_basin" and not basin["components"] and basin["notes"] + assert outlet["name"] == "배수관 · 유출부 기슭막이" + + +def test_유입구가_기슭막이인데_집수정_형식이_남아_있으면_집수정은_안_세고_알린다() -> None: + options = {"inlet_type": "기슭막이", "inlet_basin_form": "□형(기본형)"} + inlet = _table([{"chainage_m": 5.0, "options": options}])["structures"][0] + assert inlet["type_id"] == "revetment" and "집수정은 안 셈" in inlet["notes"][0] + + +def test_독립_기슭막이_한쪽이면_두_칸이_같을_때만_세고_다르면_사유() -> None: + same = {"facility": "revetment", "chainage_m": 50.0, "options": {"side": "좌"}} + rows = _table([same])["structures"] + assert [r["name"] for r in rows] == ["기슭막이 · 좌 벽"] and rows[0]["components"] + differ = {**same, "options": {"side": "우", "inlet_revet_height_m": 3.0}} + row = _table([differ])["structures"][0] + assert not row["components"] and "서버가 못 가림" in row["notes"][-1] + + +def test_산출식_없는_세월교는_사유로_서고_터파기_빠짐_줄을_안_만든다() -> None: + table = _table([{"facility": "ford_bridge", "chainage_m": 30.0, "options": {}}]) + assert "산출식이 아직 없습니다" in table["structures"][0]["notes"][0] + rows = build_handoff(unit_quantity_table=table)["work_items"] + assert not any("터파기가 안 선 구조물" in str(row.get("spec_detail")) for row in rows) + + +def test_구조물_목록의_관_지점_종류_옛_저장분은_안_셈(monkeypatch, tmp_path) -> None: + old = StructureInstance( + structure_id="old", type_id="revetment", placement="point", chainage_m=10.0 + ) + monkeypatch.setattr(material, "load_structures", lambda root: (1, [old])) + targets, _names, skipped = material._collect_structures(str(tmp_path)) + assert targets == [] and any("관 지점 정본이 주인" in note for note in skipped) From f3630ab1e7e20fab95453fb6ef9ee83dda556df4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 08:03:12 +0900 Subject: [PATCH 06/16] =?UTF-8?q?feat(b09):=20=EA=B7=9C=EA=B2=A9=EC=9D=B4?= =?UTF-8?q?=20=EC=97=B4=EB=A1=9C=20=EC=84=A0=20=ED=91=9C(=EA=B4=80?= =?UTF-8?q?=EB=B6=80=EC=84=A4=2012-11)=20=E2=80=94=20=EA=B7=9C=EA=B2=A9=20?= =?UTF-8?q?=EC=B9=B8=C2=B7=EC=9B=90=EB=AC=B8=20=EB=B9=84=EA=B3=A0=EB=A5=BC?= =?UTF-8?q?=20=EC=9D=BD=EC=96=B4=20=EC=A4=84=EC=9D=80=20=EC=84=9C=EA=B3=A0?= =?UTF-8?q?=20=EB=AA=BB=20=EB=84=A3=EB=8A=94=20=EC=A4=84=EC=9D=80=20?= =?UTF-8?q?=EA=B9=8C=EB=8B=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 원문 비고 「별산」·「별도계산」·「설계수량」(〃·병합 칸 포함)·「필요시적용」은 안 넣는 줄로 까닭을 남김 - 규격 칸(1:2)과 관경 열(∅800mm)을 조인 키로 — 접합몰탈·고무링이 자재 줄로 섬(단가 층은 아직 없음) - 크레인은 기종이 여럿이라 고르지 않음 — 「규격 미정 · 원문 규격 10ton/5ton」(브레인 판정 대기) - 옆 칸 규격 「40.64㎝」 = 카탈로그 절단기 40.64(기계 운전 자료가 없어 층은 못 섬 — 사유로 드러남) - 카탈로그 밖 자원 목록에 고무링 ∅800/1000/1200mm · 지수활제 보탬(원문 F0346 · 단가 없음) - 범위 81공종 상태 전후 같음(못 붙은 줄의 까닭만 바뀜) · 시험 4건 · 전체 1749 통과 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- .../B09_Estimation_ResourceAxis_ChooseOne.py | 73 +++++++++++++++---- .../B09_Estimation_ResourceAxis_Sources.py | 3 +- .../resource_catalog_ext_2026-01-01.json | 52 +++++++++++++ .../tester/test_b09_spec_column_table.py | 62 ++++++++++++++++ 4 files changed, 173 insertions(+), 17 deletions(-) create mode 100644 resources/tester/test_b09_spec_column_table.py diff --git a/B09_Estimation/B09_Estimation_ResourceAxis_ChooseOne.py b/B09_Estimation/B09_Estimation_ResourceAxis_ChooseOne.py index bcdb40ee..e1627f85 100644 --- a/B09_Estimation/B09_Estimation_ResourceAxis_ChooseOne.py +++ b/B09_Estimation/B09_Estimation_ResourceAxis_ChooseOne.py @@ -269,34 +269,75 @@ def match_spec_column_table( if len(specs) < 2 or not all(_SPEC_HEAD.match(spec) for spec in specs): return False + from B09_Estimation.B09_Estimation_ResourceAxis_Join import resolve_family, unmatched_reason + unit = table.get("basis_unit") or "" work_item_code = str(node.get("work_item_code", "")) table_id = str(table.get("pum_table_id", "")) form = str(table.get("pum_form", "")) made = 0 + previous_note = "" + + def unmatched(cell: str, reason: str) -> None: + result.unmatched.append( + UnmatchedRow( + work_item_code=work_item_code, pum_table_id=table_id, cell=cell, reason=reason + ) + ) + for index, row in enumerate(rows[1:], start=1): cells = [_clean(cell) for cell in row] - if not cells: + if not cells or not cells[0]: continue - name, spec_text = split_name_and_spec(cells[0]) - entry = catalog.resolve(name, spec_text) or catalog.resolve(name, "") - if entry is None: - result.unmatched.append( - UnmatchedRow( - work_item_code=work_item_code, - pum_table_id=table_id, - cell=cells[0], - reason="카탈로그에 없는 이름 (규격이 열로 선 표) — 0 으로 때우지 않습니다.", - ) + # 줄 모양 — | 구분 | 규격 | 단위 | 관경별 값 … | 비고 | (2026-09-14 · 원문 12-11 표 머리) + values = [ + value for value in (_cell_amount(cell) for cell in cells[1:]) if value is not None + ] + note = cells[-1] if len(cells) > 1 and _cell_amount(cells[-1]) is None else "" + note = previous_note if note == "〃" else note + spec_cell = cells[1] if len(cells) > 1 and _cell_amount(cells[1]) is None else "" + if not values and not note and previous_note.replace(" ", "") == "설계수량": + note = previous_note # 원문 병합 칸 — 바로 위 줄 비고를 따름(흄관 거푸집) + previous_note = note + tight_note = note.replace(" ", "") + # ⚠ 원문 비고가 「이 일위대가 밖」인 줄 — 못 찾은 게 아니라 안 넣는 것 · 까닭을 남김. + if tight_note in ("별산", "별도계산"): + unmatched( + cells[0], f"「{note}」 — 원문 비고대로 이 일위대가 밖에서 따로 셈 · 여기 안 넣음" ) continue - values = [_cell_amount(cell) for cell in cells[1:]] - values = [value for value in values if value is not None] - if len(values) < len(specs): - # 값이 빈 규격이 있다 — 「별산」 줄이거나 표가 덜 찼다. 짐작해 채우지 않는다. + if tight_note == "설계수량": + unmatched(cells[0], "「설계수량」 — 원문 비고대로 설계 물량으로 따로 셈 · 여기 안 넣음") continue - for spec_name, amount in zip(specs, values[-len(specs) :]): + if len(values) < len(specs): + why = f"「{note}」 — " if note else "" + unmatched(cells[0], f"{why}관경별 값이 비어 짐작해 채우지 않음") + continue + name, inline_spec = split_name_and_spec(cells[0]) + row_specs = [text for text in (spec_cell, inline_spec) if text] + entries: list[CatalogEntry] = [] + for spec_name in specs: + entry = ( + next( + ( + found + for text in (*row_specs, spec_name) + if (found := catalog.resolve(name, text)) is not None + ), + None, + ) + or catalog.resolve(name, "") + or resolve_family(catalog, cells[0], cells) + ) + if entry is None: + break + entries.append(entry) + if len(entries) < len(specs): + origin = f" · 원문 규격 {spec_cell}" if spec_cell else "" + unmatched(cells[0], f"{unmatched_reason(catalog, name)}{origin} (규격이 열로 선 표)") + continue + for spec_name, amount, entry in zip(specs, values[-len(specs) :], entries): result.rows.append( ResourceRow( work_item_code=work_item_code, diff --git a/B09_Estimation/B09_Estimation_ResourceAxis_Sources.py b/B09_Estimation/B09_Estimation_ResourceAxis_Sources.py index c15d7c09..f696c0c6 100644 --- a/B09_Estimation/B09_Estimation_ResourceAxis_Sources.py +++ b/B09_Estimation/B09_Estimation_ResourceAxis_Sources.py @@ -168,7 +168,8 @@ def spec_candidates(cells: list[str]) -> list[str]: if spec: found.append(spec) continue - token = _RE_SIZE_TOKEN.fullmatch(text.rstrip("㎥㎡톤tonm³")) + # 「40.64㎝」(절단기 12-11-2) — 카탈로그는 수만 적음(7620-0003 절단기 40.64). + token = _RE_SIZE_TOKEN.fullmatch(text.rstrip("㎥㎡톤tonm³㎝")) if token: found.append(token.group(0)) return found diff --git a/resources/data_resource_catalog/resource_catalog_ext_2026-01-01.json b/resources/data_resource_catalog/resource_catalog_ext_2026-01-01.json index cd7c679b..a780760c 100644 --- a/resources/data_resource_catalog/resource_catalog_ext_2026-01-01.json +++ b/resources/data_resource_catalog/resource_catalog_ext_2026-01-01.json @@ -259,6 +259,58 @@ "F0243" ] } + }, + { + "code": "AR-M-423cfb79", + "kind": "material", + "name": "고무링", + "spec": "∅800mm", + "unit": "개", + "source": { + "pum_edition": "2026-01-01", + "pum_table_ids": [ + "F0346" + ] + } + }, + { + "code": "AR-M-18c0ce85", + "kind": "material", + "name": "고무링", + "spec": "∅1000mm", + "unit": "개", + "source": { + "pum_edition": "2026-01-01", + "pum_table_ids": [ + "F0346" + ] + } + }, + { + "code": "AR-M-d9d5aa16", + "kind": "material", + "name": "고무링", + "spec": "∅1200mm", + "unit": "개", + "source": { + "pum_edition": "2026-01-01", + "pum_table_ids": [ + "F0346" + ] + } + }, + { + "code": "AR-M-0cc44237", + "kind": "material", + "name": "지수활제", + "spec": "", + "unit": "g", + "source": { + "pum_edition": "2026-01-01", + "pum_table_ids": [ + "F0346" + ] + } } ] } diff --git a/resources/tester/test_b09_spec_column_table.py b/resources/tester/test_b09_spec_column_table.py new file mode 100644 index 00000000..270a8ba6 --- /dev/null +++ b/resources/tester/test_b09_spec_column_table.py @@ -0,0 +1,62 @@ +"""규격이 열로 선 표(관부설 12-11) — 규격 칸·비고를 읽어 줄은 서고 못 넣는 줄은 까닭으로 드러남. + +2026-09-14 축 C 1장 ② (브레인 차례 · 판정 불필요). 원문 = 산림사업 표준품셈 12-11-1·2·3. + ① 원문 비고 「별산」·「별도계산」·「설계수량」(〃 · 병합 칸 포함)은 **안 넣는 줄**로 까닭을 남김 + ② 규격 칸(「1:2」)과 관경 열(「∅800mm」)이 조인 키 — 접합몰탈·고무링이 자재 줄로 섬 + ③ 기종이 여럿인 크레인은 **고르지 않고** 「규격 미정 · 원문 규격 10ton」 + ④ 옆 칸 규격 「40.64㎝」 = 카탈로그 절단기 40.64 +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B09_Estimation.B09_Estimation_ResourceAxis import ( # noqa: E402 + build_resource_axis, + load_combined_catalog, + load_work_item_master, +) + +AXIS = build_resource_axis(load_work_item_master(), load_combined_catalog()) + + +def _unmatched(code: str) -> dict[str, str]: + return {u.cell: u.reason for u in AXIS.unmatched if u.work_item_code == code} + + +def _rows(code: str) -> list: + return [r for r in AXIS.rows if r.work_item_code == code] + + +def test_원문_비고대로_안_넣는_줄은_까닭을_남긴다() -> None: + vr = _unmatched("FP-12-11-01") + assert "별도계산" in vr["VR관"] and "설계수량" in vr["기초콘크리트"] + assert "설계수량" in vr["거 푸 집"] # 「〃」 + hume = _unmatched("FP-12-11-02") + assert "별산" in hume["흄 관"] and "설계수량" in hume["거 푸 집"] # 병합 칸 + corrugated = _unmatched("FP-12-11-03") + assert "필요시적용" in corrugated["커플링밴드"] and "설계수량" in corrugated["모래부설"] + + +def test_규격_칸과_관경_열이_조인_키() -> None: + rings = { + r.variant: r.resource_spec for r in _rows("FP-12-11-01") if r.resource_name == "고무링" + } + assert rings == {"∅800mm": "∅800mm", "∅1000mm": "∅1000mm", "∅1200mm": "∅1200mm"} + mortar = [r for r in _rows("FP-12-11-02") if r.resource_name == "접합몰탈"] + assert {r.resource_spec for r in mortar} == {"1:2"} and len(mortar) == 3 + + +def test_크레인은_고르지_않고_원문_규격을_보인다() -> None: + reason = _unmatched("FP-12-11-03")["크레인"] + assert "규격 미정" in reason and "원문 규격 5ton" in reason + assert not any(r.resource_name.startswith("크레인") for r in _rows("FP-12-11-03")) + + +def test_절단기_40_64cm_는_카탈로그_40_64() -> None: + cutters = [r for r in _rows("FP-12-11-02") if r.resource_name == "절단기"] + assert [(r.resource_code, str(r.amount)) for r in cutters] == [("7620-0003", "0.93")] From fd324ce53181dad574e343fb7d36c70f5cb40c72 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 08:03:13 +0900 Subject: [PATCH 07/16] auto: 2026-09-14 08:03 (ESD_LAPTOP) --- docs/raw/verification/2026-09-13_축C_명세.md | 43 ++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/docs/raw/verification/2026-09-13_축C_명세.md b/docs/raw/verification/2026-09-13_축C_명세.md index 2b3a1862..bcb21055 100644 --- a/docs/raw/verification/2026-09-13_축C_명세.md +++ b/docs/raw/verification/2026-09-13_축C_명세.md @@ -90,6 +90,24 @@ FP-<절>-<절>-<절> 예) FP-04-02-02 (벌목, 5m 미만) - 정본은 **품셈 절 번호**. 표의 행은 그 절의 **조건별 변형**(`variant` 칸)임 - **이름은 표시용, 잇기는 코드로.** 이름으로 맞추는 코드가 남아 있으면 그것이 곧 결함임 + +### ⭐ 어느 품셈 판인가 — 2026-09-14 확인 (조문 근거 있음) + +**`FP-` 는 산림사업 표준품셈 한 판임.** 건설공사 표준품셈은 **공종 축이 아니라 기계 층에만** 들어옴. + +``` +공종 축 477/477 FP- · 원천 pum_forest_2026.json (산림청고시 2025-82호, 2026-01-01) +기계 층 pum_const_2026.json · mach_base_2026.json (건설품셈 제8장 — 취득가·손료계수·연료·운전원) +빌려 온 몇 곳 「교차 참조」 딱지를 붙임 (예: 뿌리뽑기 밑수 3-9-2) +``` + +**근거 — 같은 고시 총칙 7**: 「본 품셈에 명시되지 않은 품은 타 부문(토목, 건축, 전기, 기계 등)의 +표준품셈에 명시된 부분의 품을 적용하고, **타 부문과 유사한 공종의 품은 본 품셈을 우선하여 적용**한다.」 + +⇒ 지금 얼개가 이 조문 그대로임. **건설품셈 절번호로 선 공종 줄은 0 건**(소스에 「8-2-8」 참조 0건). +⚠ 자료를 찾을 때 건설품셈만 보면 「값이 없다」고 잘못 결론 내기 쉬움 — 덤프 운반이 그랬음 +(건설 8-2-8 에는 단위중량·적재용량 표가 없으나 **산림 10-12 [주]에 토사 1.9 · 암 2.4 ton/㎥ · 덤프 15 ton 이 있었음**). +**막히면 산림품셈 쪽을 먼저 볼 것.** - 부모·잎이 있음. ⚠ **「부모에 수량 금지」는 반쪽만 맞았음** — 2026-09-13 정정. **부모가 두 모양이고 규칙이 다름**(랩탑 메인 실측). @@ -334,6 +352,31 @@ B08 인계본 `ground_class_aliases` — **B09 가 안 읽음**)을 한 벌로 | **단가산출 성분단가** | 소수 1자리 절사 | 211건 중 137건 설명 | | 내부 계산 | 은행가(최근접-짝수) | STmate 내부. ⚠ 뽑은 엑셀은 사사오입이라 20개 경계 중 10개가 갈림 | +### ⭐⭐ 2026-09-14 — 위 실측이 **고시 조문과 그대로 맞았음** + +**산림청고시 제2025-82호 「산림사업 표준품셈」 1-2-2 「2. 금액의 단위표준」**: + +| 종목 | 단위 | 지위 | 비고 | 우리 실측과 | +| --- | --- | --- | --- | --- | +| 설계서의 **총액** | 원 | **1,000** | 미만버림 | ⚠ **아직 확인 안 됨** — 데스크탑 서브가 재는 중 | +| 설계서의 소계 | 원 | 1 | 미만버림 | ✅ | +| 설계서의 **금액란** | 원 | 1 | 미만버림 | ✅ 「내역에 실릴 때 정수 절사」와 같음 | +| **일위대가표의 계금** | 원 | 1 | 미만버림 | ✅ 「호표 안 성분 소계 절사」와 같음 | +| **일위대가표의 금액란** | 원 | **0.1** | 미만버림 | ✅ 「단가산출 성분단가 소수 1자리 절사」와 같음 | + +⇒ **STmate 를 재서 얻은 규칙이 법 조문이었음.** 경쟁사를 베낀 것이 아니라 둘 다 같은 고시를 따른 것임. + +**같은 고시 1-2-2 「1. 수량의 단위표준」 [주]**: + +``` +① 설계서 수량의 단위와 소수자리 표시는 본 표에 따르며, 반올림하여 적용 +② 품목이 제시한 소숫자리가 본 표와 다르면 항목 쪽을 우선 +③ 본 표에 없는 품은 유사품의 규격·단위수량을 참고, C.G.S 단위 원칙 +``` + +⇒ 아래 「규정이 없는 자리 = 소수 2자리」는 **이 표에도 없는 자리에만** 적용할 것. +표에 실린 품목(관류 길이 m 소수 2 · 수로연장 m 소수 1 · 옹벽 ㎡ 소수 1 …)은 **표가 규정임.** + ### ⭐ 규정이 없는 자리 — 소수점 2자리가 기본값 (2026-09-13 사용자 확정) **단수처리에 법적 근거가 없음이 확인됐음** — 기재부 **예정가격작성기준 전 조문에 From 5bbb0aa53bfe34836c3fc1879c0f6688af6b4188 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 08:10:30 +0900 Subject: [PATCH 08/16] =?UTF-8?q?fix(b08,b09):=20=ED=95=9C=20=EC=A4=84=20?= =?UTF-8?q?=E3=80=8C=EC=95=94=E3=80=8D=20=EB=A7=89=ED=9E=98=20=EA=B9=8C?= =?UTF-8?q?=EB=8B=AD=20=EB=B0=94=EB=A1=9C=EC=9E=A1=EC=9D=8C=20=E2=80=94=20?= =?UTF-8?q?=E3=80=8C=EA=B3=B5=EC=A2=85=EC=BD=94=EB=93=9C=20=EC=97=86?= =?UTF-8?q?=EC=9D=8C=E3=80=8D=EC=9D=B4=20=EC=95=84=EB=8B=88=EB=9D=BC=20?= =?UTF-8?q?=EC=95=94=20=EA=B0=88=EB=9E=98=20=EA=B5=AC=EC=84=B1=EB=B9=84=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=ED=95=84=EC=9A=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - B08: 갈래로 안 나뉜 암 총량은 시공법만 골라서는 안 풀림 → 사유 「암 갈래 구성비(%)가 아직 없어…」 · 시공법 안내 목록에서 뺌 - B09 내역: 공종을 못 고른 줄에 B08 막힘 까닭이 있으면 그 문구 그대로(입력 필요 ↔ 매핑 구멍 가름) - 배정 프로젝트 흙깎기 암 3,104.57㎥ · 측구터파기 암 160.48㎥ 가 이 자리 · 시험 1건 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- B08_Quantity/B08_Quantity_Engine_Handoff.py | 2 +- .../B08_Quantity_Engine_Handoff_Mapping.py | 6 ++++++ B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py | 3 +++ B09_Estimation/B09_Estimation_BillOfQuantities.py | 6 ++++++ resources/tester/test_b08_handoff.py | 14 ++++++++++++++ 5 files changed, 30 insertions(+), 1 deletion(-) diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff.py b/B08_Quantity/B08_Quantity_Engine_Handoff.py index e3b7f446..8340e70b 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff.py @@ -185,7 +185,7 @@ def build_handoff( str(row.get("ground_class")) for row in work_items if row.get("ground_class") - and row.get("ground_class") != "토사" + and row.get("ground_class") not in ("토사", "암") # 「암」은 구성비가 빠진 것 and row.get("work_item_code") is None and row.get("origin") == ORIGIN_EARTHWORK } diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py index 96598873..5e735b82 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py @@ -121,6 +121,12 @@ ORIGIN_PIPE = "pipe" #: 갈래 이름(연암·보통암…)만으로는 공종을 못 고른다(2026-09-07 일감 9 실서버에서 드러남). METHOD_TO_GROUND = {"ripping": "리핑암", "blasting": "발파암"} NOTE_METHOD_MISSING = "시공법 미지정으로 공종을 못 고름" +#: 암 총량이 갈래로 안 나뉘어 한 줄 「암」으로 선 경우 — 시공법은 **갈래마다** 고르므로 이 줄은 +#: 시공법만 골라서는 안 풀린다. 빠진 입력은 구성비다(2026-09-14 흙깎기·측구터파기 암). +NOTE_ROCK_RATIO_MISSING = ( + "암 갈래 구성비(%)가 아직 없어 암 총량이 한 줄 「암」으로 섬 — 산출 조건 「암 갈래 구성비」를 " + "넣고 갈래마다 시공법(리핑·발파)을 고르면 공종이 섬" +) #: 철근으로 보는 성분 이름 조각. **정확한 낱말이 아니라 앞머리**로 본다 — #: 「이형철근 D13」·「원형철근」처럼 규격이 뒤에 붙기 때문이다. `철근콘크리트`는 성분 이름이 diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py index 68adef0a..602d9aa3 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py @@ -19,6 +19,7 @@ from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import ( METHOD_TO_GROUND, NOTE_HAUL_IN_SUMMARY, NOTE_METHOD_MISSING, + NOTE_ROCK_RATIO_MISSING, ORIGIN_EARTHWORK, ORIGIN_HAUL, ORIGIN_SLOPE, @@ -54,6 +55,8 @@ def _mapping_ground(ground: str | None, methods: dict[str, str | None]) -> tuple """ if ground is None or ground == "토사": return ground, "" + if ground == "암": + return None, NOTE_ROCK_RATIO_MISSING # 갈래로 안 나뉜 총량 — 시공법만으론 안 풀림 method = methods.get(ground) mapped = METHOD_TO_GROUND.get(method or "") if mapped: diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities.py b/B09_Estimation/B09_Estimation_BillOfQuantities.py index 4712202a..aae78a79 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities.py @@ -546,12 +546,18 @@ def build_bill( if item.origin == "structure" else "공종을 못 골랐습니다 — B08 인계에 공종코드가 없습니다." ) + # ⚠ B08 이 막힘 까닭을 실어 보냈으면 **그 문구를 그대로** — 「코드가 없다」로 덮으면 + # 입력하면 풀리는 자리(암 갈래 구성비·시공법)가 매핑 구멍으로 읽힘(2026-09-14 흙깎기 암). + if item.blocked_reason: + kind = item.blocked_kind or _NOT_OUR_ROW + reason = f"{_BLOCKED_LABELS.get(kind, '막힘')} — {item.blocked_reason}" result.missing.append( { "name": item.display_name, "unit": item.unit, "quantity": str(item.quantity), "reason": reason, + **({"blocked_kind": item.blocked_kind} if item.blocked_kind else {}), } ) diff --git a/resources/tester/test_b08_handoff.py b/resources/tester/test_b08_handoff.py index 9e95f95f..c31fc7e2 100644 --- a/resources/tester/test_b08_handoff.py +++ b/resources/tester/test_b08_handoff.py @@ -367,6 +367,20 @@ def test_시공법을_안_정하면_찍지_않고_드러냄() -> None: assert any("시공법" in item for item in handoff["unmatched_work_items"]) +def test_갈래로_안_나뉜_암은_구성비가_빠졌다고_말함() -> None: + """2026-09-14 — 한 줄 「암」은 시공법만 골라서는 안 풀림. B09 내역도 이 까닭을 그대로 실음.""" + handoff = build_handoff(summary_table=집계표(집계줄("흙깎기", "암", 100.0))) + row = handoff["work_items"][0] + assert row["work_item_code"] is None and row["blocked_kind"] == "input_missing" + assert "암 갈래 구성비" in row["blocked_reason"] + assert handoff["missing_method_classes"] == [] # 시공법 안내에 「암」을 안 띄움 + + from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill + + bill = build_bill(handoff) + assert any("입력이 필요합니다 — 암 갈래 구성비" in m["reason"] for m in bill.missing) + + def test_토사는_시공법이_필요없음() -> None: handoff = build_handoff(summary_table=집계표(집계줄("흙깎기", "토사", 100.0))) assert handoff["work_items"][0]["work_item_code"] == "FP-09-03-02" From 81286400bd6cb802b5c87a1743c321805adb0c92 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 08:22:14 +0900 Subject: [PATCH 09/16] =?UTF-8?q?fix(b08,b09):=20=EA=B8=B0=EB=B3=B8?= =?UTF-8?q?=EA=B0=92=EC=9C=BC=EB=A1=9C=20=EC=84=A0=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=AC=BC=EC=9D=80=20=EC=A4=84=EB=A7=8C=20=EC=84=9C=EA=B3=A0=20?= =?UTF-8?q?=EA=B8=88=EC=95=A1=20=EB=B0=96=20=E2=80=94=20=E3=80=8C=EB=AF=B8?= =?UTF-8?q?=ED=99=95=EC=A0=95=20N=EA=B1=B4=20=E2=80=94=20=EA=B8=88?= =?UTF-8?q?=EC=95=A1=EC=97=90=20=EC=95=88=20=EB=93=A4=EC=96=B4=EA=B0=90?= =?UTF-8?q?=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 브레인 판정: 값 없음을 감추면 조용히 빠지거나 조용히 부풂 — 없으면 없다고 보이기 - 관 지점 벽 치수를 등록부 기본값으로 채운 줄 = 미확정: 원단위 합·자재·터파기·되메우기·기초잡석·타설·잔토·채집석에서 뺌 - 인계 막힘 갈래 넷째 값 unconfirmed(계약 칸은 안 늘림) · B09 는 내역 제자리에 빈 금액 + 빨간 테두리 · 머리 뱃지 - 구조물도 그림에 표와 같은 기본값 사유 줄 · 「횡단도와 같은 값」 문구는 틀려 고침(B06 관 벽 높이는 관경 기준) - 936be972 내역 184,291,506 → 124,424,547 · 미확정 18건 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- B08_Quantity/B08_Quantity_Engine_Handoff.py | 1 + .../B08_Quantity_Engine_Handoff_Mapping.py | 3 ++ .../B08_Quantity_Engine_Handoff_Rows.py | 19 ++++++++-- .../B08_Quantity_Engine_Handoff_Trench.py | 8 +++- .../B08_Quantity_Engine_HaulInputs.py | 2 + .../B08_Quantity_Engine_MaterialSummary.py | 8 ++++ B08_Quantity/B08_Quantity_Engine_Pipe.py | 19 +++++++--- .../B08_Quantity_Engine_StructureFigure.py | 12 ++++-- .../B08_Quantity_Engine_UnitQuantity.py | 10 ++++- .../B08_Quantity_Engine_UnitQuantity_Base.py | 8 ++++ .../B09_Estimation_BillOfQuantities.py | 17 ++++++++- .../B09_Estimation_BillOfQuantities_Rows.py | 4 ++ B09_Estimation/B09_Estimation_UI_Store.ts | 2 + B09_Estimation/B09_Estimation_UI_Tab_Bill.ts | 9 +++++ .../tester/test_b08_facility_structures.py | 37 +++++++++++++++++-- resources/tester/test_b08_handoff_contract.py | 10 ++++- ui_template/ui_template_locale_b3.ts | 1 + 17 files changed, 149 insertions(+), 21 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff.py b/B08_Quantity/B08_Quantity_Engine_Handoff.py index e3b7f446..8f1535a5 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff.py @@ -54,6 +54,7 @@ from B08_Quantity.B08_Quantity_Engine_Handoff_Trench import ( from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import ( # noqa: F401 BLOCKED_FORMULA_MISSING, BLOCKED_INPUT_MISSING, + BLOCKED_UNCONFIRMED, BLOCKED_UNIT_DATA_MISSING, METHOD_TO_GROUND, NOTE_METHOD_MISSING, diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py index 96598873..cda537d3 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py @@ -132,6 +132,9 @@ REBAR_PREFIXES = ("이형철근", "원형철근", "철근") BLOCKED_INPUT_MISSING = "input_missing" # 저장 제원 칸이 비어 있음 — 입력하면 풀림 BLOCKED_UNIT_DATA_MISSING = "unit_data_missing" # 원단위·표준 물량 자료가 없음 BLOCKED_FORMULA_MISSING = "formula_missing" # 수량 산출식 자체가 없음 +#: 치수가 없어 **등록부 기본값으로 선** 구조물 줄(2026-09-14 브레인 판정) — 수량은 보이되 금액·합계엔 +#: 안 듦. B09 가 내역 제자리에 빈 금액 + 빨간 테두리로 세우고 「미확정 N건 — 금액에 안 들어감」. +BLOCKED_UNCONFIRMED = "unconfirmed" #: 사면 계열 — 토공집계에 함께 실리지만 출처가 사면적이다. #: ⚠ `item` 칸이 **지반 갈래**인 공종 — 그 밖의 공종에서 `item` 은 **작업 갈래**다 diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py index 68adef0a..d353c8c7 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py @@ -13,6 +13,7 @@ from B08_Quantity.B08_Quantity_Engine_BasisUnit import normalize_unit, unit_for_ from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import ( BLOCKED_FORMULA_MISSING, BLOCKED_INPUT_MISSING, + BLOCKED_UNCONFIRMED, BLOCKED_UNIT_DATA_MISSING, GROUND_SPLIT_GROUPS, HAUL_SUMMARY_GROUPS, @@ -33,6 +34,9 @@ from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import ( ) from B08_Quantity.B08_Quantity_Wording import type_label as wording_type_label +#: 기본값으로 선 구조물 줄 사유 머리 — B09 「미확정 N건 — 금액에 안 들어감」과 같은 말. +UNCONFIRMED_LABEL = "미확정 — 금액에 안 들어감" + def _spec_detail(structure: dict[str, Any]) -> str: """규격 표기 — 저장된 제원에서 만든다. 없는 값은 적지 않는다.""" @@ -447,6 +451,13 @@ def _structure_rows( if notes else "물량이 0 이라 내역에 안 세움 — 저장 제원에서 치수·면적을 넣으면 값이 섭니다" ) + # ⭐ 치수가 없어 기본값으로 선 구조물(2026-09-14 브레인 판정) — **줄은 서되 금액은 안 듦.** + # B09 가 내역 제자리에 빈 금액 + 빨간 테두리로 세우고 「미확정 N건 — 금액에 안 들어감」. + unconfirmed = str(structure.get("unconfirmed") or "") + if unconfirmed: + in_bill = False + zero_reason = f"{UNCONFIRMED_LABEL} — {unconfirmed}" + blocked_kind, blocked_reason = BLOCKED_UNCONFIRMED, zero_reason rows.append( { "work_item_code": code, @@ -491,7 +502,7 @@ def _structure_rows( "origin": ORIGIN_STRUCTURE, } ) - if sheet_entry is not None: + if sheet_entry is not None and not unconfirmed: rows[-1] = template_row(rows[-1], structure, sheet_entry) return rows, unmatched @@ -541,8 +552,10 @@ def _placing_rows( # 2026-09-08 B09 가 「철근이 겹치나」를 물어 그 김에 드러난 자리다 — # 철근은 안 겹치고(자재는 재료·묶음 조각은 품, 재료 0원) **타설이 겹쳤다.** # ⚠ 묶음이 아직 미확보라 지금은 값이 안 걸렸을 뿐, 묶음이 서는 날 두 번이 된다. - if mapping.composite_for(str(structure.get("type_id") or "")): - continue + if mapping.composite_for(str(structure.get("type_id") or "")) or structure.get( + "unconfirmed" + ): + continue # 기본값으로 선 구조물도 — 금액에 안 듦 # ⚠ 버림은 **따로 센다** — 실무 내역이 「레미콘타설(장비) **무근,버림**」으로 갈라 # 적는다(봉화 제50호표, 2026-09-09 데스크탑 보조 확인). 같은 공종·같은 단가라 # 금액은 안 움직이고 **이름만 맞추는 것**이다. diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Trench.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Trench.py index 55ccbc65..28cfe826 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Trench.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Trench.py @@ -166,8 +166,10 @@ def rubble_base_rows( total = 0.0 bases: list[str] = [] for structure in unit_quantity_table.get("structures") or []: - if mapping.composite_for(str(structure.get("type_id") or "")): - continue + if mapping.composite_for(str(structure.get("type_id") or "")) or structure.get( + "unconfirmed" + ): + continue # 묶음 조각이 품음 · 기본값으로 선 구조물은 금액에 안 듦 sheet_entry = priced.get(str(structure.get("structure_id"))) if sheet_entry is not None and RUBBLE_GROUP in sheet_entry["covered"]: continue @@ -219,6 +221,8 @@ def structure_earthwork_rows( # 사유도 안 났다). 관측 원단위로 가는 종류는 표에 터파기 줄이 없으면 이렇게 된다. without_trench: list[str] = [] for structure in unit_quantity_table.get("structures") or []: + if structure.get("unconfirmed"): + continue # 기본값으로 선 구조물 — 제 줄에 사유가 섬, 토공 금액엔 안 듦 options = structure.get("options") or {} height = float(structure.get("height_m") or options.get("height_m") or 0.0) # 단면으로 판 깊이가 있으면 그것(옹벽은 기초분만 팜 · 비탈분 제외) — 없으면 직고 + 기초 깊이. diff --git a/B08_Quantity/B08_Quantity_Engine_HaulInputs.py b/B08_Quantity/B08_Quantity_Engine_HaulInputs.py index cc9b4aa3..3e26dd0f 100644 --- a/B08_Quantity/B08_Quantity_Engine_HaulInputs.py +++ b/B08_Quantity/B08_Quantity_Engine_HaulInputs.py @@ -97,6 +97,8 @@ def haul_inputs(unit_quantity_table: dict[str, Any] | None) -> dict[str, Any]: stone_by_ground: dict[str, float] = {} stone_unknown = 0.0 for structure in unit_quantity_table.get("structures") or []: + if structure.get("unconfirmed"): + continue # 기본값으로 선 구조물 — 잔토·채집석이 사토 운반 금액으로 번지지 않게 amount = 0.0 stone = 0.0 for component in structure.get("components") or []: diff --git a/B08_Quantity/B08_Quantity_Engine_MaterialSummary.py b/B08_Quantity/B08_Quantity_Engine_MaterialSummary.py index 869caf3d..b1471b29 100644 --- a/B08_Quantity/B08_Quantity_Engine_MaterialSummary.py +++ b/B08_Quantity/B08_Quantity_Engine_MaterialSummary.py @@ -286,6 +286,10 @@ def _supply_setting(supply: dict[str, Any], row: MaterialRow) -> Any: return None +#: 건너뛴 까닭 칸 — 치수가 없어 기본값으로 선 구조물(구조물 수로 셈). +UNCONFIRMED_SKIP = "미확정(기본값으로 선 구조물)" + + def _collect( unit_quantity_table: dict[str, Any], ) -> tuple[dict[tuple[str, str, str], MaterialRow], dict[str, int]]: @@ -293,6 +297,10 @@ def _collect( rows: dict[tuple[str, str, str], MaterialRow] = {} skipped: dict[str, int] = {} for structure in unit_quantity_table.get("structures", []): + if structure.get("unconfirmed"): + # 기본값으로 선 구조물 — 자재에 안 듦(2026-09-14 브레인 판정 「없으면 없다고 보이기」). + skipped[UNCONFIRMED_SKIP] = skipped.get(UNCONFIRMED_SKIP, 0) + 1 + continue label = str(structure.get("name") or structure.get("type_id") or "") for component in structure.get("components", []): destination = str(component.get("destination") or "") or "(없음)" diff --git a/B08_Quantity/B08_Quantity_Engine_Pipe.py b/B08_Quantity/B08_Quantity_Engine_Pipe.py index e0b5ca9e..4c4e1722 100644 --- a/B08_Quantity/B08_Quantity_Engine_Pipe.py +++ b/B08_Quantity/B08_Quantity_Engine_Pipe.py @@ -208,9 +208,14 @@ REVET_ROLES = ( ("inlet", "유입부 기슭막이", "유입 칸 벽"), ("outlet", "유출부 기슭막이", "유출 칸 벽"), ) +#: ⚠ 「횡단도와 같은 값」이 아님 — B06 은 관 벽 높이를 **관경 기준 최소 높이**로 따로 그림 +#: (`revetWallSpec`), 독립 기슭막이도 저장 높이가 없으면 근입 깊이로 그림. 형태·길이만 같은 기본값. NOTE_DEFAULT_WALL = ( - "⚠ 시설 지점에 {filled} 을 안 적어 등록부 기본값으로 섰음 — 횡단도 그림과 같은 값" + "⚠ 시설 지점에 {filled} 을 안 적어 등록부 기본값으로 섰음 — 미확정이라 금액에 안 들어감" + " · 횡단도 벽 높이는 관경 기준으로 따로 그려져 다를 수 있음" ) +#: 금액에서 뺄 까닭(2026-09-14 브레인 판정) — 줄·표·그림은 서되 내역 합에는 안 듦. +UNCONFIRMED_WALL = "벽 치수({filled})가 시설 지점에 없어 기본값으로 섰음 — 적으면 금액이 섬" NOTE_SIDE_UNKNOWN = ( "⚠ 설치 측 「{side}」인데 두 칸(유입·유출) 값이 달라 어느 칸이 그쪽인지 서버가 못 가림" "(횡단 지형이 정함) — 값을 안 세움 · 두 칸을 같게 적으면 섬" @@ -231,7 +236,7 @@ def _num_or_zero(value: Any) -> float: def _revet_values( options: dict[str, Any], role: str, defaults: dict[str, Any], legacy: bool ) -> tuple[dict[str, Any], list[str]]: - """벽 한쪽 제원과 등록부 기본값으로 채운 칸 — B06 횡단 그림(`_side_spec`)과 같은 채움.""" + """벽 한쪽 제원과 등록부 기본값으로 채운 칸 — B06 세트 스펙(`_side_spec`)과 같은 채움.""" values: dict[str, Any] = {} filled: list[str] = [] for name, label in REVET_FIELDS: @@ -250,7 +255,8 @@ def facility_structures(points: list[dict[str, Any]]) -> list[dict[str, Any]]: """계곡 통과 시설(`pipe_points.json`) → 원단위 전개가 읽는 구조물 줄 (A1, 2026-09-14). ⚠ 관 자체는 안 냄 — `build_rows` 가 관 연장으로 셈. - ⚠ 안 적힌 벽 칸은 **등록부 기본값** — 횡단 그림이 쓰는 값과 같고, 그 사실을 줄 사유로 붙임. + ⚠ 안 적힌 벽 칸은 **등록부 기본값** — 그 사실을 줄 사유로 붙이고 `unconfirmed` 로 금액 합에서 뺌 + (2026-09-14 브레인 판정 「줄은 서되 금액은 실제 값이 있을 때만」). ⚠ 집수정·기슭막이 터파기·되메우기는 전개 성분(`destination: earthwork`)이라 토공집계로만 감. """ from B05_Profile.B05_Profile_Structures_Schema import structure_type_map @@ -303,17 +309,17 @@ def facility_structures(points: list[dict[str, Any]]) -> list[dict[str, Any]]: notes = list(inlet_notes) if role == "inlet" else [] if filled: notes.append(NOTE_DEFAULT_WALL.format(filled=" · ".join(filled))) - walls.append([role, own_label if legacy else pipe_label, values, notes, False]) + walls.append([role, own_label if legacy else pipe_label, values, notes, False, filled]) side = str(options.get("side") or "") if legacy else "" if side in ("좌", "우"): # 좌·우가 유입/유출 어느 칸인지는 **횡단 지형**이 정함 — 두 칸이 같을 때만 한 벽으로 셈. same = walls[0][2] == walls[1][2] - walls = [[walls[0][0], f"{side} 벽", walls[0][2], walls[0][3], not same]] + walls = [[walls[0][0], f"{side} 벽", walls[0][2], walls[0][3], not same, walls[0][5]]] if not same: walls[0][3].append(NOTE_SIDE_UNKNOWN.format(side=side)) foundation = options.get("foundation" if legacy else "revet_foundation") kept = {k: v for k, v in options.items() if not k.startswith(("inlet_", "outlet_"))} - for role, label, values, notes, withheld in walls: + for role, label, values, notes, withheld, filled in walls: before, after = _num_or_zero(values["before_m"]), _num_or_zero(values["after_m"]) row = child_row(base, "revetment", label, f"{role}_revet") row.update( @@ -322,6 +328,7 @@ def facility_structures(points: list[dict[str, Any]]) -> list[dict[str, Any]]: options={**(kept if legacy else {}), **values, "foundation": foundation}, notes=notes, withheld=withheld, + unconfirmed=UNCONFIRMED_WALL.format(filled=" · ".join(filled)) if filled else "", ) rows.append(row) return rows diff --git a/B08_Quantity/B08_Quantity_Engine_StructureFigure.py b/B08_Quantity/B08_Quantity_Engine_StructureFigure.py index edd922e7..91ed57c9 100644 --- a/B08_Quantity/B08_Quantity_Engine_StructureFigure.py +++ b/B08_Quantity/B08_Quantity_Engine_StructureFigure.py @@ -26,6 +26,7 @@ from __future__ import annotations from typing import Any +from B08_Quantity.B08_Quantity_Engine_Pipe import NOTE_DEFAULT_WALL from B08_Quantity.B08_Quantity_Engine_StructureSheet import slope_of from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( _back_length, @@ -283,9 +284,14 @@ def _masonry_figure(sheet: dict[str, Any]) -> list[dict[str, Any]]: _LABEL_SIZE, ), ] - if default_note: - # 「이 값이 어디서 왔나」 — 표 사유 줄과 **글자까지 같게**(브레인 판정). - shapes.append(_text(default_note, (0.0, -0.80 - base_d), "left")) + # 「이 값이 어디서 왔나」 — 표 사유 줄과 **글자까지 같게**(브레인 판정). 벽 치수 자체가 + # 기본값인 관 지점 기슭막이는 그 줄도 — 그림이 설계값처럼 보이면 더 위험함(2026-09-14). + wall_prefix = NOTE_DEFAULT_WALL.split("{")[0] + lines = [default_note] + [ + str(n) for n in sheet.get("notes") or [] if str(n).startswith(wall_prefix) + ] + for index, line in enumerate(line for line in lines if line): + shapes.append(_text(line, (0.0, -0.80 - base_d - index * 0.18), "left")) return shapes diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py index dcd043c2..a7feeb03 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -51,6 +51,7 @@ from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Base import ( # noqa: F401 Component, StructureQuantity, _num, + is_unconfirmed, ) from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Masonry import ( # noqa: F401 — 다시 내보냄 boulder_masonry, @@ -528,6 +529,7 @@ def build_table( if rubble is not None: quantity.components.append(rubble) _append_section_trench(quantity, item, observed, rubble_base_thickness_m) + quantity.unconfirmed = str(item.get("unconfirmed") or "") quantities.append(quantity) if use_templates: # 늦게 부름 — 양식 모듈이 이 모듈을 부르므로 맨 위에서 부르면 맞물림. @@ -544,7 +546,9 @@ def build_table( violations = verify_no_mix_components(quantities) totals: dict[str, dict[str, Any]] = {} - for item in quantities: + # 합은 **금액에 드는 구조물만** — 기본값으로 선 구조물(`unconfirmed`)은 줄에만 보임. + priced = [item for item in quantities if not item.unconfirmed] + for item in priced: for component in item.components: key = f"{component.name}|{component.spec}|{component.unit}" entry = totals.setdefault( @@ -581,6 +585,8 @@ def build_table( "trench_depth_m": item.trench_depth_m, # 양식 있음/없음 — 화면이 가림(비면 지금 전개). "library_item": item.library_item, + # 기본값으로 선 까닭 — 차 있으면 금액·자재·토공·운반 합에서 빠짐(`is_unconfirmed`). + "unconfirmed": item.unconfirmed, "notes": item.notes, "components": [ { @@ -619,7 +625,7 @@ def build_table( COLLECTED_STONE_KEY: round( sum( component.amount - for item in quantities + for item in priced for component in item.components if component.name == "채집석" ), diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Base.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Base.py index c57cd173..5bd4177b 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Base.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Base.py @@ -108,6 +108,14 @@ class StructureQuantity: trench_depth_m: float | None = None #: 양식으로 성분을 세웠으면 그 양식 이름(PLAN 3장 ④-2). 비면 지금 전개 값. library_item: str = "" + #: 치수가 정본에 없어 **등록부 기본값으로 선** 까닭(2026-09-14 브레인 판정) — 차 있으면 줄·표·그림은 + #: 서되 **금액·자재·토공·운반 합에는 안 듦**(`is_unconfirmed`). 설계자가 치수를 적으면 비고 금액이 섬. + unconfirmed: str = "" + + +def is_unconfirmed(structure: dict[str, Any]) -> bool: + """금액 합에서 뺄 구조물인가 — 합을 내는 자리마다 이 한 벌로 가림.""" + return bool(structure.get("unconfirmed")) def _num(value: Any, fallback: float = 0.0) -> float: diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities.py b/B09_Estimation/B09_Estimation_BillOfQuantities.py index 4712202a..9a73aa88 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities.py @@ -64,8 +64,12 @@ SUPPLY_OWNER = "owner_supplied" #: 그것을 「우리가 만들어야 하는 것」에 얹으면 **결국 이중계상으로 간다**(㉠~㉦ 규칙). _NOT_OUR_ROW = "not_our_row" +#: B08 `BLOCKED_UNCONFIRMED` 와 같은 글 — 치수 없이 기본값으로 선 줄. +BLOCKED_UNCONFIRMED = "unconfirmed" + _BLOCKED_LABELS = { _NOT_OUR_ROW: "여기서 세지 않는 줄", + BLOCKED_UNCONFIRMED: "미확정 — 금액에 안 들어감", "input_missing": "입력이 필요합니다", "unit_data_missing": "원단위가 없습니다(우리가 만들 것)", "formula_missing": "전개식이 없습니다(우리가 만들 것)", @@ -123,6 +127,12 @@ class HandoffWorkItem: def display_name(self) -> str: return f"{self.name} {self.spec}".strip() + @property + def unconfirmed(self) -> bool: + """B08 이 치수 없이 기본값으로 세운 줄(2026-09-14 브레인 판정) — 내역 제자리에 + **빈 금액 + 빨간 테두리**로 서고 합계에 안 듦 · 머리에 「미확정 N건 — 금액에 안 들어감」.""" + return self.blocked_kind == BLOCKED_UNCONFIRMED + @dataclass(frozen=True) class HandoffMaterial: @@ -262,6 +272,8 @@ class BillResult: material_sheet: Any = None #: 수동 단가로 선 자리 — 내역서 끝 「미확정 N건」(PLAN 확정 ⑦). 줄마다 `{name, count}`. unconfirmed: list[dict[str, Any]] = field(default_factory=list) + #: 치수 없이 기본값으로 선 구조물 줄 — 제자리에 빈 금액으로 서고 **합계에 안 듦**(`{name, reason}`). + unpriced: list[dict[str, Any]] = field(default_factory=list) @property def direct_material_krw(self) -> Decimal: @@ -435,7 +447,8 @@ def build_bill( composites: list[HandoffWorkItem] = [] templated: list[HandoffWorkItem] = [] for item in work_items: - if not item.in_bill: + if not item.in_bill and not (item.unconfirmed and item.work_item_code in index): + # (미확정 줄은 제자리 나무로 감 — `_leaf_row` 가 금액 없이 세우고 `unpriced` 에 셈) # ⚠ 코드 유무보다 **먼저** 가른다. 보정량계는 공종코드가 없어서가 아니라 # **검산용 줄이라서** 금액이 없는 것이다 — `missing` 으로 새면 「단가를 구해야 할 # 줄」로 잘못 읽힌다. @@ -669,6 +682,8 @@ def bill_summary(result: BillResult) -> dict[str, Any]: "missing": result.missing, "unconfirmed": result.unconfirmed, "unconfirmed_count": sum(int(entry["count"]) for entry in result.unconfirmed), + "unpriced": result.unpriced, + "unpriced_count": len(result.unpriced), "body_total_krw": str(result.body_total_krw), "direct_material_krw": str(result.direct_material_krw), "direct_labor_krw": str(result.direct_labor_krw), diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py index 7a456b66..7c319c3e 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py @@ -267,6 +267,10 @@ def _leaf_row( # 검산용 줄 — 수량은 보이되 **단가를 안 붙인다**(PLAN 8-7 ㉡ 와 같은 성격). row.quantity = item.quantity row.add_note("", item.in_bill_reason or "합계 검산용 줄 — 금액을 매기지 않습니다.") + if item.unconfirmed: + # 치수 없이 기본값으로 선 줄 — 빨간 테두리 + 머리 「미확정 N건 — 금액에 안 들어감」. + row.unconfirmed = 1 + result.unpriced.append({"name": item.display_name, "reason": item.blocked_reason}) result.excluded.append(row) return row diff --git a/B09_Estimation/B09_Estimation_UI_Store.ts b/B09_Estimation/B09_Estimation_UI_Store.ts index 1473748e..1d415ee4 100644 --- a/B09_Estimation/B09_Estimation_UI_Store.ts +++ b/B09_Estimation/B09_Estimation_UI_Store.ts @@ -129,6 +129,8 @@ export interface BillDto { detail_rows: number; missing: MissingDto[]; unconfirmed_count: number; + /** 치수 없이 기본값으로 선 구조물 줄 — 제자리에 빈 금액으로 서고 합계에 안 듦. */ + unpriced_count?: number; notes: string[]; material_sheet: MaterialSheetDto | null; }; diff --git a/B09_Estimation/B09_Estimation_UI_Tab_Bill.ts b/B09_Estimation/B09_Estimation_UI_Tab_Bill.ts index 6332de5a..56458849 100644 --- a/B09_Estimation/B09_Estimation_UI_Tab_Bill.ts +++ b/B09_Estimation/B09_Estimation_UI_Tab_Bill.ts @@ -202,6 +202,15 @@ function drawBill(ctx: B09TabContext, bill: BillDto, reload: () => void): void { ); if (bill.summary.unconfirmed_count > 0) bar.append(unconfirmedBadge(bill.summary.unconfirmed_count)); + // 치수 없이 기본값으로 선 줄 — 빨간 테두리 줄로 서고 금액·합계엔 안 듦(브레인 판정 2026-09-14). + if (bill.summary.unpriced_count) + bar.append( + el( + "span", + "b09s-badge", + `${L("B09_Sheet_Unconfirmed")} ${bill.summary.unpriced_count}${L("B09_Sheet_Count")} — ${L("B09_Sheet_Unpriced")}`, + ), + ); const rateToggle = el("button", "b09s-undo", L("B09_Sheet_Rate")); rateToggle.type = "button"; rateToggle.addEventListener("click", () => { diff --git a/resources/tester/test_b08_facility_structures.py b/resources/tester/test_b08_facility_structures.py index 8a684a5a..6ad5f803 100644 --- a/resources/tester/test_b08_facility_structures.py +++ b/resources/tester/test_b08_facility_structures.py @@ -42,10 +42,41 @@ def test_관_하나에_기슭막이_둘이_기본값_사유와_함께_서고_관 work = {row["name"]: row for row in build_handoff(unit_quantity_table=table)["work_items"]} assert work["배수관 · 유입부 기슭막이"]["work_item_code"] == "FP-13-04-05" assert work["배수관 · 유출부 기슭막이"]["work_item_code"] == "FP-13-04-02" - assert ( - work["배수관 · 유입부 기슭막이"]["in_bill"] - and work["배수관 · 유입부 기슭막이"]["unit"] == "㎡" + assert work["배수관 · 유입부 기슭막이"]["unit"] == "㎡" + + +def test_기본값으로_선_벽은_줄만_서고_금액_합에는_안_든다() -> None: + """브레인 판정(2026-09-14) — 줄은 서야 채울 자리가 보이고, 금액은 실제 값이 있을 때만.""" + point = {"chainage_m": 100.0, "options": {"pipe_diameter_mm": 1000}} + table = _table([point]) + assert all(s["unconfirmed"] and s["components"] for s in table["structures"]) + assert table["totals"] == [] # 원단위 합(자재·채집석 밑수)에도 안 듦 + handoff = build_handoff(unit_quantity_table=table) + rows = handoff["work_items"] + walls = [r for r in rows if "기슭막이" in r["name"]] + assert len(walls) == 2 and all( + not r["in_bill"] and r["blocked_kind"] == "unconfirmed" for r in walls ) + assert walls[0]["quantity"] > 0 # 수량은 보임 + # 터파기·되메우기·기초잡석·버림 타설이 금액 줄로 번지지 않음 + assert not [r for r in rows if r["in_bill"] and r["quantity"] > 0] + + from B09_Estimation.B09_Estimation_BillOfQuantities import bill_summary, build_bill + + bill = build_bill(handoff) + placed = [r for r in bill.rows if not r.is_group and "기슭막이" in r.name] + assert len(placed) == 2 and all(r.unconfirmed == 1 and r.amount_krw is None for r in placed) + summary = bill_summary(bill) + assert summary["unpriced_count"] == 2 and summary["body_total_krw"] == "0" + + # 치수를 다 적으면 미확정이 풀림 + filled = { + f"{side}_revet_{key}": value + for side in ("inlet", "outlet") + for key, value in (("form", "돌쌓기(메)"), ("height_m", 1.5), ("length_m", 6)) + } + confirmed = _table([{**point, "options": {**point["options"], **filled}}]) + assert not any(s["unconfirmed"] for s in confirmed["structures"]) def test_유입구가_집수정이면_집수정_줄이_사유로_서고_유입_기슭막이는_없다() -> None: diff --git a/resources/tester/test_b08_handoff_contract.py b/resources/tester/test_b08_handoff_contract.py index f27312bf..8f4d9d8a 100644 --- a/resources/tester/test_b08_handoff_contract.py +++ b/resources/tester/test_b08_handoff_contract.py @@ -131,10 +131,18 @@ def test_막힌_까닭이_세_갈래_중_하나일것() -> None: from B08_Quantity.B08_Quantity_Engine_Handoff import ( BLOCKED_FORMULA_MISSING, BLOCKED_INPUT_MISSING, + BLOCKED_UNCONFIRMED, BLOCKED_UNIT_DATA_MISSING, ) - allowed = {None, BLOCKED_INPUT_MISSING, BLOCKED_UNIT_DATA_MISSING, BLOCKED_FORMULA_MISSING} + # ⭐ 넷째 갈래(2026-09-14 브레인 판정) — 치수 없이 기본값으로 선 줄: 수량은 보이되 금액 밖. + allowed = { + None, + BLOCKED_INPUT_MISSING, + BLOCKED_UNIT_DATA_MISSING, + BLOCKED_FORMULA_MISSING, + BLOCKED_UNCONFIRMED, + } for row in _rows()["work_items"]: assert row["blocked_kind"] in allowed diff --git a/ui_template/ui_template_locale_b3.ts b/ui_template/ui_template_locale_b3.ts index 5b7c9ef9..b79d3809 100644 --- a/ui_template/ui_template_locale_b3.ts +++ b/ui_template/ui_template_locale_b3.ts @@ -26,6 +26,7 @@ export const ui_locales_b3 = { B09_Sheet_Won: ["원", " KRW"], B09_Sheet_Count: ["건", ""], B09_Sheet_Unconfirmed: ["미확정", "Unconfirmed"], + B09_Sheet_Unpriced: ["금액에 안 들어감", "not in the amount"], B09_Sheet_Missing: ["금액을 못 세운 줄", "Rows without a price"], B09_Sheet_Reload: ["다시 불러오기", "Reload"], B09_Sheet_Level: ["보이는 레벨", "Show levels"], From 139e505843b011245ecd0bf60edb80516a692224 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 08:30:20 +0900 Subject: [PATCH 10/16] =?UTF-8?q?feat(b09):=20=EB=8D=A4=ED=94=84=20?= =?UTF-8?q?=EC=9A=B4=EB=B0=98=2010-12=20=E2=80=94=20=EC=9A=B4=EB=B0=98?= =?UTF-8?q?=EA=B1=B0=EB=A6=AC=EB=A7=88=EB=8B=A4=20=ED=98=B8=ED=91=9C(X=20?= =?UTF-8?q?=EB=8D=A4=ED=94=84=ED=8A=B8=EB=9F=AD=2015ton=20=E2=86=92=20D=20?= =?UTF-8?q?=E2=86=92=20B)=20=C2=B7=20=EC=9B=90=EB=AC=B8=20=EC=96=B4?= =?UTF-8?q?=EA=B8=8B=EB=82=A8=EC=9D=80=20=EA=B3=A0=EB=A5=B8=20=EA=B0=92?= =?UTF-8?q?=C2=B7=EB=B2=84=EB=A6=B0=20=EA=B0=92=EC=9D=84=20=EC=82=AC?= =?UTF-8?q?=EC=9C=A0=EC=97=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 원문 본문 식(채워 넣는 서식)을 데이터로: Qt = T/γt×L · n = Qt/(0.7×K) · ㎝t = t1+t2+t3+t4+t5 · Q = 60×Qt×f×E/㎝t - 브레인 판정: n 의 Qt 계산값(원문 표기 10 버림) · 발파암 E 0.9(원문 누락) · Es 운반 줄 값 · 거리별 갈래 - 거리는 B08 인계(유토곡선·사토장)에서 — 내역이 조립에 넘기고, 호표 한 장은 코드 #L…m 에서 되읽음 - 거리 없는 줄은 0 원 아니라 「운반거리 미입력」 · 갈래는 범위 별칭 리핑암 → 암절취 - 배정 프로젝트 164.23m: 토사 3,249원/㎥ · 암절취 8,882원/㎥ · 같은 인계 대조로 바뀐 줄은 이 둘뿐(+1,437,981원) - 시험 5건 · 전체 1761 통과 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- .../B09_Estimation_BillOfQuantities_Rows.py | 38 +++- ...B09_Estimation_MachineProductivity_Dump.py | 212 ++++++++++++++++++ B09_Estimation/B09_Estimation_Router.py | 23 +- B09_Estimation/B09_Estimation_UnitPrice.py | 13 ++ resources/tester/test_b09_dump_haul.py | 104 +++++++++ 5 files changed, 383 insertions(+), 7 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_MachineProductivity_Dump.py create mode 100644 resources/tester/test_b09_dump_haul.py diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py index 7a456b66..16786f4a 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py @@ -22,9 +22,18 @@ from B09_Estimation.B09_Estimation_BillOfQuantities import ( _MasterNode, _decimal, ) +from B09_Estimation.B09_Estimation_MachineProductivity_Dump import ( + DUMP_PARENT, + dump_child_for, + dump_title_code, +) from B09_Estimation.B09_Estimation_PriceBook import Money3 from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at -from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, find_variant_code +from B09_Estimation.B09_Estimation_UnitPrice import ( + UnitPriceBuild, + _master_edition, + find_variant_code, +) def bill_line(unit: Money3, quantity) -> Money3: @@ -299,6 +308,33 @@ def _leaf_row( return row price_code = f"B-{node.code}" + if node.code == DUMP_PARENT and item.haul_equipment == "dump_truck": + # 덤프 운반(10-12) — 잎(토사·암절취·발파암) × 운반거리마다 호표 한 장. 거리 없으면 0원 금지. + child = dump_child_for(item.variant_value, _master_edition()) + why = "" + if child is None: + why = ( + f"덤프 운반 갈래 「{item.variant_value or '미지정'}」를 10-12 잎" + "(토사·암절취·발파암)에 못 맞춤" + ) + elif item.haul_distance_m is None: + why = "입력이 필요합니다 — 운반거리 미입력(유토곡선·사토장 거리가 서면 섬)" + elif dump_title_code(child, item.haul_distance_m) not in unit_prices.book.titles: + why = unit_prices.component_gaps.get(child) or "덤프 운반 일위대가를 못 세웠습니다" + if why: + row.add_note("unit_price_krw", why) + result.missing.append( + { + "name": row.name, + "code": node.code, + "unit": row.unit, + "quantity": str(item.quantity), + "reason": why, + } + ) + return row + price_code = dump_title_code(child, item.haul_distance_m) + row.spec = f"{row.spec} L={item.haul_distance_m}m".strip() if price_code not in unit_prices.book.titles: # B08 은 **의미**(어느 공종·어느 제원)만 보내고 갈래 키는 우리가 만든다. # 못 맞추면 후보를 보이는 길로 내려간다 — 가까운 갈래를 임의로 고르지 않는다. diff --git a/B09_Estimation/B09_Estimation_MachineProductivity_Dump.py b/B09_Estimation/B09_Estimation_MachineProductivity_Dump.py new file mode 100644 index 00000000..07b2e629 --- /dev/null +++ b/B09_Estimation/B09_Estimation_MachineProductivity_Dump.py @@ -0,0 +1,212 @@ +"""B09 원가계산 — **덤프트럭 운반** 시공능력 (산림사업 표준품셈 10-12 「2. 운반」 · 2026-09-14). + +원문(고시 2025-82호 10-12-1·2·3 「2. 운반」)은 표가 아니라 **채워 넣는 식 서식**이라 공종 마스터에 +안 실렸다. 값은 원문 본문에서 읽어 여기 한 곳에 둔다(식을 데이터로). + + Qt = T / γt × L 덤프 1대 적재토량(㎥, 흐트러진 상태) · T 15ton + n = Qt / (q × K) 적재기계 싸이클 횟수 · q 버켓 0.7㎥ + t1 = ㎝s × n / (60 × Es) 적재 대기(분) · ㎝s 20초 + t2 = (D/V1 + D/V2) × 60 왕복(분) · D 운반거리 km · V1 5 · V2 6 km/hr + ㎝t = t1 + t2 + t3 + t4 + t5 t3 적하 1.1 · t4 대기 0.9 · t5 덮개 0.5 (분) + Q = 60 × Qt × f × E / ㎝t ㎥/hr (자연상태) · f = 1/L + +⚠ **원문이 서로 어긋나는 자리 — 고른 값과 버린 값을 사유에 나란히 둔다**(2026-09-14 브레인 판정). + 사용자가 뒤집을 수 있어야 한다. + ① n 의 Qt — 원문 표기 「10/(0.7×K)」(셋 다 10) ↔ **계산값 T/γt×L**(토사 10.26·암절취 8.44· + 발파암 10.16). 원문이 `q` 한 글자를 적재토량·버켓용량 두 뜻으로 써 10 은 토사 어림수 + 복사로 봄. + ② 발파암 운반 줄에 E 누락 — **0.9**(덤프 작업효율 · 같은 절 토사·암절취 0.9). 빼면 Q 를 못 구함. + ④ Es(적재기계 작업효율 · t1) ≠ E0(적재 식) — **운반 줄 값 그대로**(토사 0.85 · + 암 0.35). +⚠ 운반거리 D 는 원문에 없다 — **설계자 값**(B08 유토곡선·사토장). 없으면 줄을 안 세우고 + 까닭(0 원 금지). +⚠ 적재(「1. 적재」 굴착기 Q1)는 **별도 줄**이다 — 이 식의 t1 은 덤프가 기다리는 시간이지 + 굴착기 품이 아님(흙깎기 9-3-2 ㎝ 20초·135° 는 깎아 옆에 둠 — 22초·180° 싣기와 다른 일). +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from decimal import Decimal +from typing import Any + +from B09_Estimation.B09_Estimation_MachineProductivity import fix2 + +#: 「[주] 장비는 덤프트럭(15ton)을 적용한다」 — 기계 카탈로그 덤프트럭 15. +DUMP_TRUCK_CODE = "0602-0150" +DUMP_PARENT = "FP-10-12" + +_TRUCK_TON = Decimal(15) +_BUCKET_M3 = Decimal("0.7") +_LOADER_CYCLE_SEC = Decimal(20) +_V_LOADED_KMH = Decimal(5) +_V_EMPTY_KMH = Decimal(6) +_T3_UNLOAD, _T4_WAIT, _T5_COVER = Decimal("1.1"), Decimal("0.9"), Decimal("0.5") +_SIXTY = Decimal(60) + + +@dataclass(frozen=True) +class DumpMaterial: + """10-12 절 하나의 재료 값 — 원문 「2. 운반」 줄.""" + + work_item_code: str + label: str + unit_weight: Decimal # γt ton/㎥ + loose_factor: Decimal # L 토량환산계수 + bucket_factor: Decimal # K + loader_efficiency: Decimal # Es + truck_efficiency: Decimal # E + notes: tuple[str, ...] = () + + +_COMMON_NOTE = ( + "n 의 Qt = 계산값 T/γt×L(원문 표기 「10/(0.7×K)」의 10 은 토사 어림수 복사로 봄 — 버림)" +) +_ES_NOTE = "Es = 운반 줄 값(적재 식의 E0 와 다른 기호)" + +DUMP_MATERIALS: dict[str, DumpMaterial] = { + "FP-10-12-01": DumpMaterial( + "FP-10-12-01", "토사", Decimal("1.9"), Decimal("1.3"), Decimal("0.9"), Decimal("0.85"), + Decimal("0.9"), (_COMMON_NOTE, _ES_NOTE), + ), + "FP-10-12-02": DumpMaterial( + "FP-10-12-02", "암절취", Decimal("2.4"), Decimal("1.35"), Decimal("0.55"), Decimal("0.35"), + Decimal("0.9"), (_COMMON_NOTE + " · 원문 10 이면 n 1.18배", _ES_NOTE), + ), + "FP-10-12-03": DumpMaterial( + "FP-10-12-03", "발파암", Decimal("2.4"), Decimal("1.625"), Decimal("0.55"), Decimal("0.35"), + Decimal("0.9"), + (_COMMON_NOTE, _ES_NOTE, "E 원문 누락 — 0.9 적용(같은 절 토사·암절취 · 덤프 작업효율)"), + ), +} # fmt: skip + + +@dataclass(frozen=True) +class DumpHaul: + """덤프 운반 한 벌 — 재료 + 운반거리.""" + + material: DumpMaterial + distance_m: Decimal + + @property + def truck_load_m3(self) -> Decimal: + return _TRUCK_TON / self.material.unit_weight * self.material.loose_factor + + @property + def loader_cycles(self) -> Decimal: + return self.truck_load_m3 / (_BUCKET_M3 * self.material.bucket_factor) + + @property + def cycle_minutes(self) -> Decimal: + wait = _LOADER_CYCLE_SEC * self.loader_cycles / (_SIXTY * self.material.loader_efficiency) + km = self.distance_m / Decimal(1000) + travel = (km / _V_LOADED_KMH + km / _V_EMPTY_KMH) * _SIXTY + return wait + travel + _T3_UNLOAD + _T4_WAIT + _T5_COVER + + @property + def volume_factor(self) -> Decimal: + return fix2(Decimal(1) / self.material.loose_factor) + + @property + def hourly_output(self) -> Decimal: + """Q (㎥/hr) — f 와 Q 는 소수 2자리로 먼저 확정(명세 7장).""" + return fix2( + _SIXTY + * self.truck_load_m3 + * self.volume_factor + * self.material.truck_efficiency + / self.cycle_minutes + ) + + @property + def formula_text(self) -> str: + m = self.material + return ( + f"Qt = 15/{m.unit_weight}×{m.loose_factor} = {self.truck_load_m3:.2f}㎥ · " + f"n = {self.truck_load_m3:.2f}/(0.7×{m.bucket_factor}) = {self.loader_cycles:.2f}회 · " + f"㎝t = {self.cycle_minutes:.2f}분(L={self.distance_m}m) · " + f"Q = 60×{self.truck_load_m3:.2f}×{self.volume_factor}×{m.truck_efficiency}" + f"/{self.cycle_minutes:.2f} = {self.hourly_output}㎥/hr" + ) + + +def dump_title_code(work_item_code: str, distance_m: Decimal) -> str: + """운반거리별 갈래 — 사토장이 여럿이면 거리마다 호표가 갈림(STmate 도 그 모양).""" + return f"B-{work_item_code}#L{distance_m.normalize():f}m" + + +def attach_dump_hauls(build: Any, master: dict[str, Any], distances_m: tuple[Decimal, ...]) -> None: + """거리마다 덤프 운반 일위대가를 세운다 — X(덤프트럭 15ton) → D → B. + + 시간당 사용료가 안 섰으면 세우지 않고 까닭을 남긴다(0 원 일위대가 금지). + """ + from B09_Estimation.B09_Estimation_PriceBook import PriceKind, PriceTitle + + if not distances_m: + return + hourly = f"X-{DUMP_TRUCK_CODE}" + names = { + str(node.get("work_item_code")): str(node.get("name") or "") + for node in master.get("work_items", []) + } + for code, material in DUMP_MATERIALS.items(): + if hourly not in build.book.titles: + build.component_gaps[code] = "덤프트럭(15ton) 시간당 사용료가 아직 안 섰습니다" + continue + for distance in distances_m: + haul = DumpHaul(material, distance) + title_code = dump_title_code(code, distance) + if title_code in build.book.titles: + continue + build.book.add_title( + PriceTitle( + code=title_code, + kind=PriceKind.UNIT_PRICE, + name=f"{names.get(DUMP_PARENT) or '덤프운반'} " + f"{names.get(code) or material.label}", + spec=f"L={distance.normalize():f}m", + unit="㎥", + ) + ) + build.book.add_output_detail( + title_code, + hourly, + Decimal(1) / haul.hourly_output, + haul.formula_text + " · " + " · ".join(material.notes), + output=haul.hourly_output, + ) + + +def dump_haul_distances(payload: dict[str, Any]) -> tuple[str, ...]: + """B08 인계에서 덤프 운반 거리(m) 갈래 — 거리가 없는 줄은 안 셈(그 줄은 「운반거리 미입력」).""" + found = { + str(Decimal(str(row["haul_distance_m"]))) + for row in payload.get("work_items") or [] + if str(row.get("work_item_code") or "").startswith(DUMP_PARENT) + and row.get("haul_equipment") == "dump_truck" + and row.get("haul_distance_m") not in (None, "") + } + return tuple(sorted(found, key=Decimal)) + + +_RE_DUMP_CODE = re.compile(rf"^[BD]-{DUMP_PARENT}-0[1-3]#L(\d+(?:\.\d+)?)m$") + + +def dump_distance_from_code(code: str) -> tuple[str, ...]: + """호표 한 장을 여는 쪽 — 코드 `#L164.23m` 에서 거리를 되읽음(인계를 다시 안 셈).""" + found = _RE_DUMP_CODE.match(code) + return (found.group(1),) if found else () + + +def dump_child_for(ground: str | None, edition: str) -> str | None: + """인계 갈래(토사·리핑암·발파암) → 10-12 잎. 모르는 갈래는 `None` — 가까운 것을 안 고름.""" + from common_util.common_util_aliases import alias_target, load_aliases + + wanted = str(ground or "").strip() + if not wanted: + return None + alias = alias_target(load_aliases("variant"), wanted, DUMP_PARENT, edition) or wanted + return next( + (code for code, material in DUMP_MATERIALS.items() if material.label == alias), None + ) diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index 8700c744..869e314e 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -31,6 +31,10 @@ from B09_Estimation.B09_Estimation_PriceBook import PriceBookError from B09_Estimation.B09_Estimation_Provenance import estimation_provenance from B09_Estimation.B09_Estimation_BillOfQuantities import bill_summary, build_bill from B09_Estimation.B09_Estimation_Guards import DoubleCountError +from B09_Estimation.B09_Estimation_MachineProductivity_Dump import ( + dump_distance_from_code, + dump_haul_distances, +) from B09_Estimation.B09_Estimation_Rates import RateLookupError from B09_Estimation.B09_Estimation_Statutory import STATUTORY_ITEMS from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at @@ -248,11 +252,13 @@ async def _project_root_of(project_id: UUID) -> str | None: return None -async def _build_for(project_id: UUID): +async def _build_for(project_id: UUID, dump_haul_m: tuple[str, ...] = ()): """그 프로젝트가 **고른 값**으로 조립한 일위대가. ⚠ 범위 계수(작업효율)·장비 규격은 프로젝트마다 다를 수 있다(확정 ①). 전역 한 벌로 돌면 한 프로젝트에서 바꾼 값이 다른 프로젝트 금액까지 흔든다. + `dump_haul_m` — 덤프 운반 거리(m) 갈래. 인계(유토곡선·사토장)에서 오므로 내역이 넘기고, + 호표 한 장을 여는 쪽은 코드(`#L…m`)에서 되읽어 넘김. """ from B09_Estimation.B09_Estimation_LaborSurcharge import parse_choices as parse_labor_surcharge from common_util.common_util_project_settings import estimation_settings @@ -281,6 +287,7 @@ async def _build_for(project_id: UUID): tuple(sorted(parse_labor_surcharge(settings.get("labor_surcharge")).items())), # 조종원 시간당 노임 자르는 자리 — 실무마다 다름(명세 7장 정정). 안 정하면 원 미만. str(settings.get("operator_wage_digits") or ""), + tuple(sorted(set(dump_haul_m), key=Decimal)), ) # 사용자가 고친 값(PLAN 12장 2차) — 없으면 기본 조립 그 벌 그대로. return edited_build(args, edits_key(settings.get("edits"))) @@ -415,7 +422,10 @@ async def get_unit_price_detail(project_id: UUID, code: str) -> JSONResponse: try: return JSONResponse( content=_with_provenance( - {"status": "success", **detail_of(await _build_for(project_id), code)} + { + "status": "success", + **detail_of(await _build_for(project_id, dump_distance_from_code(code)), code), + } ) ) except PriceBookError as error: @@ -460,11 +470,12 @@ async def get_bill(project_id: UUID) -> JSONResponse: from B08_Quantity.B08_Quantity_Router_Material import get_handoff, structure_bill_prices try: - # ⚠ **그 프로젝트가 고른 값**으로 조립한 단가표 — 구조물도 미리보기(`_build_for`)와 한 벌. - # 기본값(`cached_build()`)을 쓰면 계수를 고른 프로젝트에서 화면과 내역이 갈림(브레인). - build = await _build_for(project_id) response = await get_handoff(project_id) payload = json.loads(bytes(response.body).decode("utf-8")) + # ⚠ **그 프로젝트가 고른 값**으로 조립한 단가표 — 구조물도 미리보기(`_build_for`)와 한 벌. + # 기본값(`cached_build()`)을 쓰면 계수를 고른 프로젝트에서 화면과 내역이 갈림(브레인). + # 덤프 운반은 인계 거리마다 갈래가 서므로 인계를 먼저 받음(10-12 · 2026-09-14). + build = await _build_for(project_id, dump_haul_distances(payload)) except Exception: logger.exception("B09 내역서 조회 실패(인계): project_id=%s", project_id) return JSONResponse( @@ -535,7 +546,7 @@ async def get_price_basis_detail(project_id: UUID, code: str) -> JSONResponse: try: # 내역서와 같은 단가표 — 번호·금액이 내역 줄과 갈리지 않게. - body = price_basis_detail(code, await _build_for(project_id)) + body = price_basis_detail(code, await _build_for(project_id, dump_distance_from_code(code))) except Exception: logger.exception("B09 단가산출서 조회 실패: project_id=%s, code=%s", project_id, code) return JSONResponse( diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index 2f35994d..cb750bf5 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -617,6 +617,7 @@ def build_unit_prices( transport_road: str | None = None, labor_surcharge_choices: dict[str, str] | None = None, operator_wage_digits: int = 0, + dump_haul_m: tuple[Decimal, ...] = (), ) -> UnitPriceBuild: """자원 축을 일위대가(`B`)로 조립한다. @@ -771,6 +772,14 @@ def build_unit_prices( from B09_Estimation.B09_Estimation_Transport import TRANSPORT_VARIANTS machine_codes |= {variant["machine_code"] for variant in TRANSPORT_VARIANTS} + # 덤프 운반(10-12)도 공식표가 아니라 본문 식이라 기종이 안 드러남 — **거리가 왔을 때만** 세운다. + from B09_Estimation.B09_Estimation_MachineProductivity_Dump import ( + DUMP_TRUCK_CODE, + attach_dump_hauls, + ) + + if dump_haul_m: + machine_codes.add(DUMP_TRUCK_CODE) build.operator_wage_digits = operator_wage_digits build.incomplete_machines = _add_machine_layers( build.book, machine_codes, fuel_region, operator_wage_digits @@ -1022,6 +1031,8 @@ def build_unit_prices( distance_km=transport_distance_km, road_key=transport_road, ) + # 덤프 운반 — 운반거리(B08 유토곡선·사토장)마다 한 벌(산림품셈 10-12 「2. 운반」). + attach_dump_hauls(build, master, dump_haul_m) # ⚠ **마지막에 한 번** — 조합 사용 공종의 본체 기계를 잡재료 16% 층으로 바꿔 단다 # (품셈 제8장 [주]⑤). 조립 도중에 바꾸면 어느 공종이 조합인지 아직 모른다. @@ -1161,6 +1172,7 @@ def cached_build( transport_road: str = "", labor_surcharge: tuple[tuple[str, str], ...] = (), operator_wage_digits: str = "", + dump_haul_m: tuple[str, ...] = (), ) -> UnitPriceBuild: """조립 결과를 한 번만 만든다 — 품셈 3 MB 를 요청마다 다시 읽지 않는다. @@ -1188,6 +1200,7 @@ def cached_build( transport_road=transport_road or None, labor_surcharge_choices=dict(labor_surcharge), operator_wage_digits=parse_operator_wage_digits(operator_wage_digits), + dump_haul_m=tuple(Decimal(value) for value in dump_haul_m), ) diff --git a/resources/tester/test_b09_dump_haul.py b/resources/tester/test_b09_dump_haul.py new file mode 100644 index 00000000..02fd296d --- /dev/null +++ b/resources/tester/test_b09_dump_haul.py @@ -0,0 +1,104 @@ +"""덤프 운반(산림품셈 10-12 「2. 운반」) — 거리별 호표 · 원문 어긋남은 고른 값·버린 값을 사유에. + +2026-09-14 축 C 1장 (브레인 판정 ①②④⑤): + ① n 의 Qt = 계산값 T/γt×L (원문 표기 10 버림) ② 발파암 E 0.9 ④ Es = 운반 줄 값 + ⑤ 운반거리마다 갈래 · 거리 없으면 0 원 아니라 「운반거리 미입력」 +손셈(토사 164.23 m): Qt 10.2632 · n 16.2907 · t1 6.3885 · t2 3.6131 · ㎝t 12.5016 · f 0.77 + → Q 34.135 → 34.14(사사오입) +""" + +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_MachineProductivity_Dump import ( # noqa: E402 + DUMP_MATERIALS, + DumpHaul, + dump_child_for, + dump_distance_from_code, + dump_haul_distances, + dump_title_code, +) +from B09_Estimation.B09_Estimation_UnitPrice import cached_build # noqa: E402 + +DISTANCE = Decimal("164.23") + + +def test_토사_운반_식_손셈() -> None: + haul = DumpHaul(DUMP_MATERIALS["FP-10-12-01"], DISTANCE) + assert round(haul.truck_load_m3, 4) == Decimal("10.2632") + assert round(haul.loader_cycles, 4) == Decimal("16.2907") + assert round(haul.cycle_minutes, 4) == Decimal("12.5016") + assert (haul.volume_factor, haul.hourly_output) == (Decimal("0.77"), Decimal("34.14")) + + +def test_암절취는_계산값_Qt_와_운반줄_Es() -> None: + haul = DumpHaul(DUMP_MATERIALS["FP-10-12-02"], DISTANCE) + assert round(haul.truck_load_m3, 4) == Decimal("8.4375") # 원문 표기 10 이 아님 + assert haul.hourly_output == Decimal("12.49") + assert any("원문 표기" in note for note in DUMP_MATERIALS["FP-10-12-02"].notes) + assert DUMP_MATERIALS["FP-10-12-03"].truck_efficiency == Decimal("0.9") + assert any("E 원문 누락" in note for note in DUMP_MATERIALS["FP-10-12-03"].notes) + + +def test_거리마다_호표가_서고_코드에서_거리를_되읽는다() -> None: + build = cached_build(dump_haul_m=(str(DISTANCE),)) + code = dump_title_code("FP-10-12-01", DISTANCE) + assert code == "B-FP-10-12-01#L164.23m" + money = build.book.resolve(code) + assert money.total > 0 + assert dump_distance_from_code(code) == ("164.23",) + assert code not in cached_build().book.titles # 거리 없는 조립엔 안 섬 + + +def test_인계_거리와_갈래_잇기() -> None: + payload = { + "work_items": [ + { + "work_item_code": "FP-10-12", + "haul_equipment": "dump_truck", + "haul_distance_m": 164.23, + }, + {"work_item_code": "FP-10-12", "haul_equipment": "dump_truck", "haul_distance_m": None}, + {"work_item_code": "FP-10-11", "haul_equipment": "dozer", "haul_distance_m": 32.09}, + ] + } + assert dump_haul_distances(payload) == ("164.23",) + assert dump_child_for("리핑암", "2026-01-01") == "FP-10-12-02" # 범위 별칭 리핑암 → 암절취 + assert dump_child_for("토사", "2026-01-01") == "FP-10-12-01" + assert dump_child_for("모르는암", "2026-01-01") is None + + +def test_내역_줄_거리_없으면_운반거리_미입력() -> None: + from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill + + def payload(distance): + return { + "work_items": [ + { + "work_item_code": "FP-10-12", + "name": "dump_truck 운반", + "spec": "토사", + "unit": "㎥", + "quantity": 40.0, + "haul_equipment": "dump_truck", + "haul_distance_m": distance, + "variant_axis": "ground_class", + "variant_value": "토사", + "in_bill": True, + "origin": "haul", + } + ], + "materials": [], + } + + blank = build_bill(payload(None), build=cached_build()) + assert any("운반거리 미입력" in m["reason"] for m in blank.missing) + priced = build_bill(payload(164.23), build=cached_build(dump_haul_m=("164.23",))) + row = next(r for r in priced.rows if r.name == "dump_truck 운반") + assert row.price_code == "B-FP-10-12-01#L164.23m" and row.amount_krw > 0 From 58afe8f2d2fb94951d0f54d4ca88ef2394f38999 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 08:37:58 +0900 Subject: [PATCH 11/16] =?UTF-8?q?feat(b09):=20=EB=8D=A4=ED=94=84=20?= =?UTF-8?q?=EC=A0=81=EC=9E=AC=2010-12=20=E3=80=8C1.=20=EC=A0=81=EC=9E=AC?= =?UTF-8?q?=E3=80=8D=20=E2=80=94=20=EA=B5=B4=EC=B0=A9=EA=B8=B0=200.7?= =?UTF-8?q?=E3=8E=A5=20Q1=20=ED=98=B8=ED=91=9C(#=EC=A0=81=EC=9E=AC)=20?= =?UTF-8?q?=C2=B7=20E0=20=ED=86=A0=EC=82=AC=200.60(=EB=B3=B8=EB=AC=B8=200.?= =?UTF-8?q?75=20=EB=B2=84=EB=A6=BC=EC=9D=84=20=EC=82=AC=EC=9C=A0=EC=97=90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Q1 = 3600×0.7×K×f×E0/22초 · 토사 47.63 · 암절취 16.32 · 발파암 13.67 ㎥/hr - E0 는 운반 식 Es 와 다른 기호 — 토사 0.60(표 「임도」·10-12-3 [주]③) 채택 · 본문 0.75 버림(브레인 판정) - 거리 무관이라 늘 세움 · 내역은 인계 수단 표지 dump_loading 줄을 잎 #적재 로(B08 짝 줄은 다음 커밋) - 시험 1건 보탬 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- .../B09_Estimation_BillOfQuantities_Rows.py | 20 ++++-- ...B09_Estimation_MachineProductivity_Dump.py | 69 ++++++++++++++++++- resources/tester/test_b09_dump_haul.py | 22 ++++++ 3 files changed, 103 insertions(+), 8 deletions(-) diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py index 2914ee70..e18a4905 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py @@ -24,8 +24,10 @@ from B09_Estimation.B09_Estimation_BillOfQuantities import ( ) from B09_Estimation.B09_Estimation_MachineProductivity_Dump import ( DUMP_PARENT, + LOADING_EQUIPMENT, dump_child_for, dump_title_code, + loading_title_code, ) from B09_Estimation.B09_Estimation_PriceBook import Money3 from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at @@ -312,19 +314,26 @@ def _leaf_row( return row price_code = f"B-{node.code}" - if node.code == DUMP_PARENT and item.haul_equipment == "dump_truck": + loading = item.haul_equipment == LOADING_EQUIPMENT + if node.code == DUMP_PARENT and (item.haul_equipment == "dump_truck" or loading): # 덤프 운반(10-12) — 잎(토사·암절취·발파암) × 운반거리마다 호표 한 장. 거리 없으면 0원 금지. + # 적재 짝 줄은 거리 무관 `#적재` 한 장. child = dump_child_for(item.variant_value, _master_edition()) why = "" + wanted = "" if child is None: why = ( f"덤프 운반 갈래 「{item.variant_value or '미지정'}」를 10-12 잎" "(토사·암절취·발파암)에 못 맞춤" ) + elif loading: + wanted = loading_title_code(child) elif item.haul_distance_m is None: why = "입력이 필요합니다 — 운반거리 미입력(유토곡선·사토장 거리가 서면 섬)" - elif dump_title_code(child, item.haul_distance_m) not in unit_prices.book.titles: - why = unit_prices.component_gaps.get(child) or "덤프 운반 일위대가를 못 세웠습니다" + else: + wanted = dump_title_code(child, item.haul_distance_m) + if wanted and wanted not in unit_prices.book.titles: + why = unit_prices.component_gaps.get(child) or "덤프 운반·적재 일위대가를 못 세웠습니다" if why: row.add_note("unit_price_krw", why) result.missing.append( @@ -337,8 +346,9 @@ def _leaf_row( } ) return row - price_code = dump_title_code(child, item.haul_distance_m) - row.spec = f"{row.spec} L={item.haul_distance_m}m".strip() + price_code = wanted + if not loading: + row.spec = f"{row.spec} L={item.haul_distance_m}m".strip() if price_code not in unit_prices.book.titles: # B08 은 **의미**(어느 공종·어느 제원)만 보내고 갈래 키는 우리가 만든다. # 못 맞추면 후보를 보이는 길로 내려간다 — 가까운 갈래를 임의로 고르지 않는다. diff --git a/B09_Estimation/B09_Estimation_MachineProductivity_Dump.py b/B09_Estimation/B09_Estimation_MachineProductivity_Dump.py index 07b2e629..f79ff9b5 100644 --- a/B09_Estimation/B09_Estimation_MachineProductivity_Dump.py +++ b/B09_Estimation/B09_Estimation_MachineProductivity_Dump.py @@ -82,6 +82,40 @@ DUMP_MATERIALS: dict[str, DumpMaterial] = { } # fmt: skip +#: 「1. 적재」 — Q1 = 3600 × q0 × K × f × E0 / ㎝ · 굴착기(무한궤도) 0.7㎥ · ㎝ 22초(180°). +#: ⚠ E0 는 운반 식의 Es 와 **다른 기호**다(판정 ④⑥). 토사 본문 「E0=0.75」 는 표(10-12-1 [주]⑤ +#: 「E0 토사 0.60(불량) — 임도」)·10-12-3 [주]③(「토사 0.6」)과 어긋나 **0.60 채택 · 0.75 버림**. +LOADER_CODE = "0201-0070" +_LOADING_CYCLE_SEC = Decimal(22) +_LOADING_E0 = { + "FP-10-12-01": Decimal("0.60"), + "FP-10-12-02": Decimal("0.35"), + "FP-10-12-03": Decimal("0.35"), +} +_LOADING_NOTES = { + "FP-10-12-01": "E0 = 0.60(표 「임도」·10-12-3 [주]③) — 본문 표기 0.75 버림", + "FP-10-12-02": "E0 = 0.35(본문·표 「파쇄암」 같음)", + "FP-10-12-03": "E0 = 0.35(10-12-3 [주]③ 「파쇄암 0.35」)", +} # fmt: skip + + +def loading_output(code: str) -> Decimal: + """적재 Q1 (㎥/hr, 자연상태) — f·Q1 소수 2자리 확정(명세 7장).""" + material = DUMP_MATERIALS[code] + return fix2( + Decimal(3600) + * _BUCKET_M3 + * material.bucket_factor + * fix2(Decimal(1) / material.loose_factor) + * _LOADING_E0[code] + / _LOADING_CYCLE_SEC + ) + + +def loading_title_code(code: str) -> str: + return f"B-{code}#적재" + + @dataclass(frozen=True) class DumpHaul: """덤프 운반 한 벌 — 재료 + 운반거리.""" @@ -143,13 +177,39 @@ def attach_dump_hauls(build: Any, master: dict[str, Any], distances_m: tuple[Dec """ from B09_Estimation.B09_Estimation_PriceBook import PriceKind, PriceTitle - if not distances_m: - return - hourly = f"X-{DUMP_TRUCK_CODE}" names = { str(node.get("work_item_code")): str(node.get("name") or "") for node in master.get("work_items", []) } + # 적재 — 거리와 무관해 늘 세움(굴착기 0.7㎥ 사용료 층이 있으면). 운반과 한 벌이라 여기 둠. + loader = f"X-{LOADER_CODE}" + for code in DUMP_MATERIALS: + title_code = loading_title_code(code) + if loader not in build.book.titles or title_code in build.book.titles: + continue + output = loading_output(code) + material = DUMP_MATERIALS[code] + label = names.get(code) or material.label + build.book.add_title( + PriceTitle( + code=title_code, + kind=PriceKind.UNIT_PRICE, + name=f"{names.get(DUMP_PARENT) or '덤프운반'} {label} 적재", + spec="굴착기(무한궤도) 0.7㎥", + unit="㎥", + ) + ) + build.book.add_output_detail( + title_code, + loader, + Decimal(1) / output, + f"Q1 = 3600×0.7×{material.bucket_factor}×{fix2(Decimal(1) / material.loose_factor)}" + f"×{_LOADING_E0[code]}/22 = {output}㎥/hr · {_LOADING_NOTES[code]}", + output=output, + ) + if not distances_m: + return + hourly = f"X-{DUMP_TRUCK_CODE}" for code, material in DUMP_MATERIALS.items(): if hourly not in build.book.titles: build.component_gaps[code] = "덤프트럭(15ton) 시간당 사용료가 아직 안 섰습니다" @@ -192,6 +252,9 @@ def dump_haul_distances(payload: dict[str, Any]) -> tuple[str, ...]: _RE_DUMP_CODE = re.compile(rf"^[BD]-{DUMP_PARENT}-0[1-3]#L(\d+(?:\.\d+)?)m$") +#: 인계 「덤프 적재」 짝 줄의 수단 표지 — 운반 줄(`dump_truck`)과 갈라 잎 `#적재` 로 감. +LOADING_EQUIPMENT = "dump_loading" + def dump_distance_from_code(code: str) -> tuple[str, ...]: """호표 한 장을 여는 쪽 — 코드 `#L164.23m` 에서 거리를 되읽음(인계를 다시 안 셈).""" diff --git a/resources/tester/test_b09_dump_haul.py b/resources/tester/test_b09_dump_haul.py index 02fd296d..da497c9d 100644 --- a/resources/tester/test_b09_dump_haul.py +++ b/resources/tester/test_b09_dump_haul.py @@ -74,6 +74,23 @@ def test_인계_거리와_갈래_잇기() -> None: assert dump_child_for("모르는암", "2026-01-01") is None +def test_적재는_거리_무관_한_장이고_E0_는_0_60() -> None: + from B09_Estimation.B09_Estimation_MachineProductivity_Dump import ( + loading_output, + loading_title_code, + ) + + # 토사 3600×0.7×0.9×0.77×0.60/22 = 47.628 → 47.63 + # 암절취 3600×0.7×0.55×0.74×0.35/22 = 16.317 → 16.32 + assert loading_output("FP-10-12-01") == Decimal("47.63") + assert loading_output("FP-10-12-02") == Decimal("16.32") + build = cached_build() # 거리 없어도 섬 + code = loading_title_code("FP-10-12-01") + assert build.book.resolve(code).total > 0 + basis = build.book.details[f"D-{code[2:]}"][0].note + assert "0.75 버림" in basis + + def test_내역_줄_거리_없으면_운반거리_미입력() -> None: from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill @@ -102,3 +119,8 @@ def test_내역_줄_거리_없으면_운반거리_미입력() -> None: priced = build_bill(payload(164.23), build=cached_build(dump_haul_m=("164.23",))) row = next(r for r in priced.rows if r.name == "dump_truck 운반") assert row.price_code == "B-FP-10-12-01#L164.23m" and row.amount_krw > 0 + loading = payload(None) + loading["work_items"][0].update(name="덤프 적재", haul_equipment="dump_loading") + loaded = build_bill(loading, build=cached_build()) + row = next(r for r in loaded.rows if r.name == "덤프 적재") + assert row.price_code == "B-FP-10-12-01#적재" and row.amount_krw > 0 From 0eebb50c03d6558deea50a0fc59fab5b23e9b2cc Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 08:40:49 +0900 Subject: [PATCH 12/16] =?UTF-8?q?feat(b05,b08):=20=EB=93=B1=EB=A1=9D?= =?UTF-8?q?=EB=B6=80=20=EC=B9=B8=20=EC=B1=84=EC=9B=80=20A2=C2=B7A3=20?= =?UTF-8?q?=E2=80=94=20=EA=B0=9C=EA=B1=B0=20=EC=97=B0=EC=9E=A5=20=C2=B7=20?= =?UTF-8?q?=EB=AC=BC=EB=84=98=EC=9D=B4=ED=8F=AC=EC=9E=A5=20=EB=91=90?= =?UTF-8?q?=EA=BB=98=C2=B7=EB=85=B8=ED=8F=AD=20=EB=B0=A9=ED=96=A5=20?= =?UTF-8?q?=EA=B8=B8=EC=9D=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 표가 읽는데 등록부에 없어 영영 안 서던 칸 — 옛 저장본은 안 깨지고 비면 사유(어디서 적는지) - 개거(점 배치) length_m · 물넘이포장 thickness_cm·length_m(detail · 구조물 집계표에서도 고침) - B05 시설 칸에 물넘이 두께·길이 입력 — 시설 저장이 칸을 통째로 갈아 끼워 집계표로 적은 값이 지워지던 길을 막음 - 700줄 한계로 유입구 형식 목록을 _Fields 로 옮김(부르는 쪽 그대로) - 사유 문구: 「open_ditch 연장」 키 노출 · 「연장·면적이 0」 → 무엇을 적으면 서는지 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- B05_Profile/B05_Profile_Structure_Types.json | 29 +++++++++ .../B05_Profile_UI_Drainage_Facility.ts | 42 ++++++------- ...B05_Profile_UI_Drainage_Facility_Fields.ts | 26 ++++++++ .../B08_Quantity_Engine_ObservedUnit.py | 7 ++- ..._Quantity_Engine_UnitQuantity_Revetment.py | 4 +- B08_Quantity/B08_Quantity_Wording.py | 3 + .../tester/test_b05_registry_fill_a2_a3.py | 60 +++++++++++++++++++ 7 files changed, 145 insertions(+), 26 deletions(-) create mode 100644 resources/tester/test_b05_registry_fill_a2_a3.py diff --git a/B05_Profile/B05_Profile_Structure_Types.json b/B05_Profile/B05_Profile_Structure_Types.json index 820ef552..6362f9c3 100644 --- a/B05_Profile/B05_Profile_Structure_Types.json +++ b/B05_Profile/B05_Profile_Structure_Types.json @@ -332,6 +332,26 @@ "unit": "%", "default": null, "required": false + }, + { + "key": "thickness_cm", + "label": "포장 두께", + "input": "number", + "unit": "㎝", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 표가 안 서고 「두께를 적어야 섬」 사유가 뜸 — 두께로 관측 원단위(울진 20㎝)를 고름(2026-09-14 A3)" + }, + { + "key": "length_m", + "label": "포장 길이(노폭 방향)", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 면적(월류 폭 × 길이)이 0 이라 표가 안 서고 사유가 뜸 — 노견까지 전폭을 적음(2026-09-14 A3)" } ] }, @@ -479,6 +499,15 @@ "required": false, "phase": "detail", "empty_means": "비우면 실무 붙박이(콘크리트 개거)로 돎" + }, + { + "key": "length_m", + "label": "연장", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "empty_means": "비우면 표가 안 서고 「연장을 적어야 섬」 사유가 뜸 — 개거는 m당 원단위라 연장이 곧 밑수(2026-09-14 A2)" } ] }, diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index 1caa2b97..409ed2f1 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -45,7 +45,10 @@ import { fordSection, grid, group, + INLET_KINDS, INLET_REVET_KEYS, + inletKindOf, + type InletStructureKind, labeled, numberInput, optionalSelect, @@ -57,29 +60,8 @@ import { WING_OUT_KEYS, } from "./B05_Profile_UI_Drainage_Facility_Fields"; -/** B06 유입측 구조물 형식 — 조정창 드롭다운과 같은 값 집합(2026-08-29 병합). - * 문자열 리터럴로 두어 B05가 B06 모듈에 기대지 않게 한다. */ -export type InletStructureKind = "auto" | "revet" | "I" | "L" | "U"; - -/** 유입구 "구조" 목록 — 조정창의 구조물 형식(자동/기슭막이/집수정 I·ㄴ·ㄷ)을 이 한 - * 드롭다운으로 합쳤다(2026-08-29 사용자 지시 5: 일단 리스트를 합치고 중복은 뒤에 뺀다). - * 고른 값 하나가 정본 둘을 정한다 — 관 옵션 `inlet_type`(기슭막이/집수정)과 B06 - * 유입측 형식(`inlet_structure`). */ -const INLET_KINDS: ReadonlyArray<{ - label: string; - type: "기슭막이" | "집수정"; - structure: InletStructureKind; -}> = [ - { label: "기슭막이", type: "기슭막이", structure: "revet" }, - { label: "집수정", type: "집수정", structure: "auto" }, - { label: "자동(규칙)", type: "기슭막이", structure: "auto" }, - { label: "집수정 I형", type: "집수정", structure: "I" }, - { label: "집수정 ㄴ형", type: "집수정", structure: "L" }, - { label: "집수정 ㄷ형", type: "집수정", structure: "U" }, -]; - -const inletKindOf = (label: string): (typeof INLET_KINDS)[number] => - INLET_KINDS.find((kind) => kind.label === label) ?? INLET_KINDS[0]; +// 유입구 형식 목록은 700줄 한계로 `_Fields` 로 옮김(2026-09-14) — 부르던 곳은 그대로 여기서. +export type { InletStructureKind }; /** 시설 확장 정보 한 건 — 관 지점(chainage)에 얹힌다. 배관(pipe)도 부속 옵션을 * 가지면 저장한다. */ @@ -382,7 +364,15 @@ export function createFacilityOptionsForm( // 비우면 그 측점의 **노면 횡단경사**를 그대로 쓴다 — 횡단도가 판단한다. const fordSlope = numberInput("0.1"); fordSlope.placeholder = "노면 기울기"; - const fordSlopeRow = grid(labeled("바닥 경사 유입→유출 (%)", stepper(fordSlope, 0.1))); + // 포장 두께·노폭 방향 길이 — 수량(㎡ = 월류 폭 × 길이 · 두께로 원단위 고름)이 읽는 칸(2026-09-14 A3). + // 비우면 표가 안 서고 그 까닭이 뜸 — 지어내지 않는다. + const fordThickness = numberInput("1"); + const fordLength = numberInput("0.1"); + const fordSlopeRow = grid( + labeled("바닥 경사 유입→유출 (%)", stepper(fordSlope, 0.1)), + labeled("포장 두께 (㎝)", stepper(fordThickness, 1, 0)), + labeled("포장 길이 노폭 방향 (m)", stepper(fordLength, 0.1)), + ); const fordSummary = document.createElement("p"); fordSummary.className = "b05-drainage__facility-note"; /** 담당 유역 설계유량(㎥/s) — 개략 단면의 입력. 유역이 없으면 null. */ @@ -634,6 +624,8 @@ export function createFacilityOptionsForm( // 새 시설을 올리는 참이다 — 저장된 높이는 사용자 값으로 보고 자동 추적을 끊는다. fordAutoDepthM = null; fordSlope.value = text("ford_slope_pct"); + fordThickness.value = text("thickness_cm"); + fordLength.value = text("length_m"); revetSide.value = text("side") || "양쪽"; const spread = legacyRevetOptions(options); revetInlet.fields.write(spread); @@ -679,6 +671,8 @@ export function createFacilityOptionsForm( putNumber(options, "ford_width_m", fordWidth.value); putFordHeight(options); putNumber(options, "ford_slope_pct", fordSlope.value); + putNumber(options, "thickness_cm", fordThickness.value); + putNumber(options, "length_m", fordLength.value); } else if (current === "ford_bridge") { options.pipe_kind = pipeMaterial.value; options.pipe_diameter_mm = Number(pipeDiameter.value); diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts index 0bead6cd..1b3eb9ce 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts @@ -10,6 +10,32 @@ import { FORD_MANNING_N, FORD_SLOPE } from "@config/config_frontend"; +/* ── 유입구 구조 ──────────────────────────────────────────────────────────── */ + +/** B06 유입측 구조물 형식 — 조정창 드롭다운과 같은 값 집합(2026-08-29 병합). + * 문자열 리터럴로 두어 B05가 B06 모듈에 기대지 않게 한다. */ +export type InletStructureKind = "auto" | "revet" | "I" | "L" | "U"; + +/** 유입구 "구조" 목록 — 조정창의 구조물 형식(자동/기슭막이/집수정 I·ㄴ·ㄷ)을 이 한 + * 드롭다운으로 합쳤다(2026-08-29 사용자 지시 5: 일단 리스트를 합치고 중복은 뒤에 뺀다). + * 고른 값 하나가 정본 둘을 정한다 — 관 옵션 `inlet_type`(기슭막이/집수정)과 B06 + * 유입측 형식(`inlet_structure`). */ +export const INLET_KINDS: ReadonlyArray<{ + label: string; + type: "기슭막이" | "집수정"; + structure: InletStructureKind; +}> = [ + { label: "기슭막이", type: "기슭막이", structure: "revet" }, + { label: "집수정", type: "집수정", structure: "auto" }, + { label: "자동(규칙)", type: "기슭막이", structure: "auto" }, + { label: "집수정 I형", type: "집수정", structure: "I" }, + { label: "집수정 ㄴ형", type: "집수정", structure: "L" }, + { label: "집수정 ㄷ형", type: "집수정", structure: "U" }, +]; + +export const inletKindOf = (label: string): (typeof INLET_KINDS)[number] => + INLET_KINDS.find((kind) => kind.label === label) ?? INLET_KINDS[0]; + /* ── 물넘이·세월교 개략 단면 ────────────────────────────────────────────── */ /** 월류 단면 한 벌 — 설계유량을 흘리는 데 **필요한** 수심·단면적과 그때의 유속. */ diff --git a/B08_Quantity/B08_Quantity_Engine_ObservedUnit.py b/B08_Quantity/B08_Quantity_Engine_ObservedUnit.py index 878de5d4..b8ba88ed 100644 --- a/B08_Quantity/B08_Quantity_Engine_ObservedUnit.py +++ b/B08_Quantity/B08_Quantity_Engine_ObservedUnit.py @@ -273,7 +273,12 @@ def expand_observed( if scale <= 0: from B08_Quantity.B08_Quantity_Wording import type_label - return [], [f"{type_label(type_id)}의 연장·면적이 0 이라 물량을 내지 않았습니다"] + where = ( + "면적(월류 폭 × 포장 길이)이 0 — 두 칸을 적으면 섬" + if found.get("unit") == "㎡" + else "연장이 0 — 연장을 적으면 섬" + ) + return [], [f"{type_label(type_id)}의 {where}"] source_key = str(found.get("source") or "") options = structure.get("options") or {} diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py index 939c4e05..f138f672 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py @@ -519,7 +519,9 @@ def open_ditch(length_m: float, options: dict[str, Any]) -> tuple[list[Component from B08_Quantity.B08_Quantity_Engine_UnitQuantity import DESTINATION if length_m <= 0: - return [], ["연장이 없어 전개하지 않음"] + from B08_Quantity.B08_Quantity_Wording import option_missing + + return [], [option_missing("length_m", "open_ditch")] spec = str(options.get("ditch_spec") or "콘크리트 개거 150×200").strip() table = OPEN_DITCH_FORMS.get(spec) if table is None: diff --git a/B08_Quantity/B08_Quantity_Wording.py b/B08_Quantity/B08_Quantity_Wording.py index 86f22eca..673ebde7 100644 --- a/B08_Quantity/B08_Quantity_Wording.py +++ b/B08_Quantity/B08_Quantity_Wording.py @@ -28,6 +28,7 @@ TYPE_LABELS = { "pipe": "배수관", "pipe_inlet_basin": "배수관 유입부 집수정", "ford_pavement": "물넘이포장", + "open_ditch": "개거(겉도랑)", "ford_bridge": "세월교", "box_culvert": "BOX암거", # 사방 시설 — 레지스트리 이름 그대로 옮긴 것(2026-09-08 확인). 지어낸 이름 아님. @@ -44,6 +45,8 @@ OPTION_LABELS = { "form": ("옹벽 형식", "구조물 상세 입력"), "height_m": ("높이(m)", "구조물 배치"), "length_m": ("연장(m)", "구조물 배치"), + # 물넘이포장(관 지점 시설) — B05 시설 칸 · 구조물 집계표에서 적음(2026-09-14 A3). + "thickness_cm": ("포장 두께(㎝)", "구조물 배치 물넘이포장 칸 · 구조물 집계표"), # 2026-09-09 칸이 생겼음(`ebdf2988`) — 「아직 칸이 없음」이 거짓이 되어 고침. # ⚠ 비워 두면 품셈 표준경사표로 자동 판정된다(확정 ⑨). "face_slope_ratio": ("전면 기울기", "구조물 상세 입력 — 비우면 품셈 표준경사로 자동"), diff --git a/resources/tester/test_b05_registry_fill_a2_a3.py b/resources/tester/test_b05_registry_fill_a2_a3.py new file mode 100644 index 00000000..4bbd8da1 --- /dev/null +++ b/resources/tester/test_b05_registry_fill_a2_a3.py @@ -0,0 +1,60 @@ +"""등록부 칸 채우기 A2·A3 (2026-09-14) — 표가 읽는 칸이 등록부에 없어 영영 안 서던 자리. + +A2 개거(점 배치) — 연장 칸 · A3 물넘이포장(관 지점 시설) — 두께·노폭 방향 길이 칸. +⚠ 옛 저장본(칸 없음)은 안 깨지고 **사유가 보임** — 「값이 없으면 표가 안 선다」가 아니라 「사유를 보인다」. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B05_Profile.B05_Profile_Structures_Repository import _validate_types # noqa: E402 +from B05_Profile.B05_Profile_Structures_Schema import StructureInstance, structure_type_map # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_Pipe import facility_structures # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402 + + +def _ditch(**options) -> dict: + return build_table( + [{"structure_id": "d", "type_id": "open_ditch", "chainage_m": 10.0, "options": options}] + )["structures"][0] + + +def test_개거_연장_칸이_등록부에_서고_구조물_목록에_저장된다() -> None: + keys = {option.key for option in structure_type_map()["open_ditch"].options} + assert "length_m" in keys + item = StructureInstance( + type_id="open_ditch", placement="point", chainage_m=10.0, options={"length_m": 12} + ) + _validate_types([item]) # 등록부에 없는 칸이면 여기서 거절됨 + + +def test_개거_연장이_있으면_표가_서고_없으면_어디서_적는지_사유() -> None: + assert _ditch(length_m=12)["components"] + old = _ditch() # 옛 저장본 — 칸이 없음 + assert not old["components"] + assert old["notes"][0].startswith("개거(겉도랑) 연장(m)이(가) 아직 입력되지 않았습니다") + + +def _ford(**options) -> dict: + point = {"facility": "ford_pavement", "chainage_m": 30.0, "options": options} + return build_table(facility_structures([point]))["structures"][0] + + +def test_물넘이포장_두께_길이_칸이_있으면_면적으로_선다() -> None: + keys = {option.key: option for option in structure_type_map()["ford_pavement"].options} + assert keys["thickness_cm"].phase == "detail" and keys["length_m"].phase == "detail" + row = _ford(ford_width_m=5, thickness_cm=20, length_m=8) + assert row["components"] and (row["billing_unit"], row["billing_quantity"]) == ("㎡", 40.0) + + +def test_물넘이포장_칸이_비면_무엇을_적을지_사유() -> None: + old = _ford(ford_width_m=5) + assert not old["components"] and "포장 두께(㎝)" in old["notes"][0] + no_length = _ford(ford_width_m=5, thickness_cm=20) + assert not no_length["components"] and "포장 길이" in no_length["notes"][0] From a1b1182b9ba74005b4334ff4f9c83a7763d66ab7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 08:49:38 +0900 Subject: [PATCH 13/16] =?UTF-8?q?fix(b05):=20=EA=B3=84=EA=B3=A1=20?= =?UTF-8?q?=ED=86=B5=EA=B3=BC=20=EC=8B=9C=EC=84=A4=20[=EC=A0=80=EC=9E=A5]?= =?UTF-8?q?=EC=9D=B4=20=ED=8F=BC=EC=9D=B4=20=EB=AA=A8=EB=A5=B4=EB=8A=94=20?= =?UTF-8?q?=EC=B9=B8=EC=9D=84=20=EC=A7=80=EC=9A=B0=EB=8D=98=20=EA=B2=83=20?= =?UTF-8?q?=E2=80=94=20=ED=8F=BC=20=EC=B9=B8=EB=A7=8C=20=EA=B0=88=EC=95=84?= =?UTF-8?q?=20=EB=81=BC=EC=9B=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 저장이 옵션을 통째로 갈아 끼워 집계표·구조물도로 적은 칸(관 기슭막이 기초 등)이 조용히 지워졌음 - 같은 시설 종류면 폼이 적는 칸만 갈고 나머지는 이어 붙임 · 종류를 바꿀 때만 통째로 - 폼이 아는 칸을 폼이 비우면 지움(사용자가 폼에서 한 일) - 폼 칸 목록 거울 시험 — 폼에 칸을 늘리고 목록에 안 넣으면 시험이 잡음 - ORCA 936be972: 집계표로 기초유 적고 B05 폼 고침 → 재계산 요청에 기초유 남음 확인 · 값·캐시 되돌림 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- .../B05_Profile_UI_Drainage_Facility_Merge.ts | 73 +++++++++++ B05_Profile/B05_Profile_UI_Drainage_Panel.ts | 5 +- .../tester/test_b05_facility_options_merge.py | 118 ++++++++++++++++++ 3 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts create mode 100644 resources/tester/test_b05_facility_options_merge.py diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts new file mode 100644 index 00000000..712b5f88 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts @@ -0,0 +1,73 @@ +/* ============================================================================= + * B05_Profile_UI_Drainage_Facility_Merge.ts + * 계곡 통과 시설 [저장] — **폼이 아는 칸만 갈아 끼우고, 폼이 모르는 칸은 건드리지 않음** + * (2026-09-14 브레인 판정). + * + * ⚠ 앞서 저장이 옵션을 **통째로** 갈아 끼웠음 — 구조물 집계표·구조물도로 적은 칸(관 기슭막이 + * 기초 `revet_foundation` · 독립 기슭막이 `foundation`·뒷길이·돌 종류 …)이 B05 에서 그 시설을 + * 한 번 더 저장하면 **조용히 지워졌음**. 「작업본 쓰기는 [저장]에서만」인데 그 [저장]이 옆 칸까지 + * 지우던 자리. + * ⚠ 통째로 갈아 끼우는 것은 **시설 종류 자체를 바꿀 때만** — 그때는 옛 칸이 뜻을 잃음. + * ⚠ 폼이 아는 칸을 폼이 비워 보내면(값을 지움·유입구를 집수정 → 기슭막이로 바꿔 집수정 칸이 빠짐) + * 그 칸은 지워짐 — 사용자가 폼에서 한 일이므로 맞음. + * ⚠ 폼에 칸을 늘리면 아래 목록에도 넣을 것. 빠뜨리면 그 칸을 **폼에서 지워도 옛 값이 남는** + * 쪽으로 틀림(지워지는 쪽보다 덜 위험). 거울 시험 `test_b05_facility_options_merge`. + * ⚠ import 없음 — 시험이 이 파일만 옮겨 돌림. + * ========================================================================== */ + +const revetKeys = (side: "inlet" | "outlet"): string[] => + ["form", "length_m", "height_m", "before_m", "after_m"].map((key) => `${side}_revet_${key}`); +const wingKeys = (side: "in" | "out"): string[] => [ + `wing_${side}`, + `wing_${side}_height_m`, + `wing_${side}_length_m`, + `wing_${side}_angle_deg`, +]; + +/** 시설 종류별로 **폼이 적는 칸** — `createFacilityOptionsForm().readOptions()` 가 쓰는 키 전부. */ +export const FORM_OPTION_KEYS: Readonly> = { + pipe: [ + "pipe_diameter_mm", + "pipe_kind", + "wing_wall_type", + "inlet_type", + "inlet_structure", + "inlet_basin_form", + "inlet_basin_material", + "inlet_basin_length_m", + ...revetKeys("inlet"), + "outlet_type", + ...revetKeys("outlet"), + ], + box_culvert: ["body_width_m", "body_height_m", ...wingKeys("in"), ...wingKeys("out")], + ford_pavement: ["ford_width_m", "ford_height_m", "ford_slope_pct", "thickness_cm", "length_m"], + ford_bridge: [ + "pipe_kind", + "pipe_diameter_mm", + "pipe_count", + "ford_width_m", + "ford_height_m", + ...wingKeys("in"), + ...wingKeys("out"), + ], + revetment: ["side", ...revetKeys("inlet"), ...revetKeys("outlet")], +}; + +interface MergeableAttributes { + facility: string; + start_m?: number; + end_m?: number; + options?: Record; +} + +/** 저장할 시설 정보 — 같은 종류면 폼이 모르는 옛 칸을 이어 붙임. */ +export function mergeFacilityOptions( + previous: T | null, + next: T, +): T { + if (!previous || previous.facility !== next.facility) return next; + const managed = new Set(FORM_OPTION_KEYS[next.facility] ?? []); + const kept = Object.entries(previous.options ?? {}).filter(([key]) => !managed.has(key)); + const options = { ...Object.fromEntries(kept), ...(next.options ?? {}) }; + return { ...next, options: Object.keys(options).length ? options : undefined }; +} diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index e8c1f183..045be36a 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -30,6 +30,7 @@ import { buildStrengthArray } from "../B04_PreProcess/B04_PreProcess_UI_FlowRamp import { resampleRoute } from "../B04_PreProcess/B04_PreProcess_UI_RouteSamples"; import { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes"; import { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility"; +import { mergeFacilityOptions } from "./B05_Profile_UI_Drainage_Facility_Merge"; import { writePendingPipes } from "./B05_Profile_Api_Pipes_Draft"; import { createDrainageChrome } from "./B05_Profile_UI_Drainage_Chrome"; import { bindDrainageInteractions } from "./B05_Profile_UI_Drainage_Interact"; @@ -607,8 +608,10 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra pipeEditor.addAtChainage(chainageM); }, updatePipeFacility(fromChainageM, toChainageM, attributes) { + // ⚠ 통째로 갈아 끼우지 않음 — 폼이 모르는 칸(집계표로 적은 기초 등)을 지키려 이어 붙임. + const previous = facilityStore.get(fromChainageM); facilityStore.set(fromChainageM, null); - facilityStore.set(toChainageM, attributes); + facilityStore.set(toChainageM, mergeFacilityOptions(previous, attributes)); if (Math.abs(fromChainageM - toChainageM) > 0.005) { // 기준점이 옮겨졌다 — 관을 이동시키면 onCommit이 재계산을 돌리고, // attach가 새 위치로 시설 정보를 승계한다. diff --git a/resources/tester/test_b05_facility_options_merge.py b/resources/tester/test_b05_facility_options_merge.py new file mode 100644 index 00000000..9b4a4593 --- /dev/null +++ b/resources/tester/test_b05_facility_options_merge.py @@ -0,0 +1,118 @@ +"""B05 시설 [저장]이 폼이 모르는 칸을 안 지우는가 (2026-09-14 브레인 판정). + +⚠ 앞서 저장이 옵션을 통째로 갈아 끼워, 구조물 집계표·구조물도로 적은 칸(관 기슭막이 기초 등)이 + B05 에서 그 시설을 한 번 더 저장하면 조용히 지워졌음 — 되돌릴 길이 없는 사고. +짝: `B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts` — 실제 TS 를 옮겨 돌림. +""" + +from __future__ import annotations + +import json +import re +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" +MERGE_TS = ROOT / "B05_Profile" / "B05_Profile_UI_Drainage_Facility_Merge.ts" +FORM_TS = ROOT / "B05_Profile" / "B05_Profile_UI_Drainage_Facility.ts" + +RUNNER = """ +import { readFileSync, writeFileSync } from "node:fs"; +import { FORM_OPTION_KEYS, mergeFacilityOptions } from "./B05_Profile_UI_Drainage_Facility_Merge.js"; +const cases = JSON.parse(readFileSync(process.argv[2], "utf8")); +const out = cases.map(([previous, next]) => mergeFacilityOptions(previous, next)); +writeFileSync(process.argv[3], JSON.stringify({ out, keys: FORM_OPTION_KEYS })); +""" + +PIPE_BEFORE = { + "facility": "pipe", + "options": { + "pipe_diameter_mm": 1000, + "inlet_type": "집수정", + "inlet_basin_form": "□형(기본형)", + "revet_foundation": "기초유", # 집계표로 적은 칸 — 폼에 없음 + "inlet_basin_before_m": 1.5, # 횡단(B06)이 적는 칸 — 폼에 없음 + }, +} +# 폼이 다시 저장 — 유입구를 기슭막이로 바꿔 집수정 칸은 안 보냄 +PIPE_FORM = { + "facility": "pipe", + "options": { + "pipe_diameter_mm": 800, + "inlet_type": "기슭막이", + "inlet_revet_form": "돌쌓기(찰)", + }, +} +REVET_BEFORE = {"facility": "revetment", "options": {"side": "양쪽", "back_len_cm": "35"}} +TYPE_CHANGE = {"facility": "ford_pavement", "options": {"ford_width_m": 5}} + + +@pytest.fixture(scope="module") +def merged(tmp_path_factory) -> dict: + if not TSC.is_file(): + pytest.skip("프론트엔드 의존성 미설치") + out = tmp_path_factory.mktemp("merge_js") + subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(TSC), + str(MERGE_TS), + "--outDir", + str(out), + "--module", + "esnext", + "--target", + "es2022", + "--moduleResolution", + "bundler", + "--ignoreConfig", + ], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + (out / "runner.mjs").write_text(RUNNER, encoding="utf-8") + cases = [ + [PIPE_BEFORE, PIPE_FORM], + [REVET_BEFORE, {"facility": "revetment", "options": {"side": "좌"}}], + [PIPE_BEFORE, TYPE_CHANGE], + [None, PIPE_FORM], + ] + (out / "cases.json").write_text(json.dumps(cases, ensure_ascii=False), encoding="utf-8") + subprocess.run( # noqa: S603 + ["node", str(out / "runner.mjs"), str(out / "cases.json"), str(out / "result.json")], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + return json.loads((out / "result.json").read_text(encoding="utf-8")) + + +def test_폼이_모르는_칸은_남고_폼이_아는_칸은_폼대로(merged) -> None: + options = merged["out"][0]["options"] + assert options["revet_foundation"] == "기초유" # 집계표 값이 살아남음 + assert options["inlet_basin_before_m"] == 1.5 + assert options["pipe_diameter_mm"] == 800 # 폼 값이 이김 + assert "inlet_basin_form" not in options # 폼이 아는 칸을 폼이 비움 → 지움 + assert merged["out"][1]["options"] == {"side": "좌", "back_len_cm": "35"} + + +def test_시설_종류를_바꾸면_통째로_갈아_끼움(merged) -> None: + assert merged["out"][2] == TYPE_CHANGE + assert merged["out"][3] == PIPE_FORM + + +def test_폼이_적는_칸이_모두_목록에_있다(merged) -> None: + """폼에 칸을 늘리고 목록에 안 넣으면 여기서 잡힘.""" + source = FORM_TS.read_text(encoding="utf-8") + body = source[source.index("readOptions() {") :] + branches = re.split(r'current === "(\w+)"', body)[1:] + assert branches[::2] == ["pipe", "box_culvert", "ford_pavement", "ford_bridge", "revetment"] + for facility, block in zip(branches[::2], branches[1::2]): + written = set(re.findall(r'putNumber\(options, "(\w+)"', block)) + written |= set(re.findall(r"options\.(\w+) =", block)) + missing = written - set(merged["keys"][facility]) + assert not missing, f"{facility}: 폼이 적는데 목록에 없음 {missing}" From a2b3ae698f270742e1d053182aaba9ecc00c28ce Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 08:57:24 +0900 Subject: [PATCH 14/16] =?UTF-8?q?fix(test):=20=EB=8D=A4=ED=94=84=20?= =?UTF-8?q?=EC=A0=81=EC=9E=AC=20=EB=92=A4=20=EB=82=A1=EC=9D=80=20=EC=8B=9C?= =?UTF-8?q?=ED=97=98=20=EB=91=98=20=E2=80=94=20=EA=B0=80=EB=93=9C=20?= =?UTF-8?q?=EB=9C=BB=EC=9D=80=20=EC=A7=80=ED=82=A4=EA=B3=A0=20=EC=83=88=20?= =?UTF-8?q?=EB=AA=A8=EC=96=91=EC=97=90=20=EB=A7=9E=EC=B6=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - v16 「덤프 운반은 아직 안 선다」가 지키던 것 = 운반거리(설계 입력) 없이 운반이 조용히 금액을 갖지 않기 → 「거리가 와야 선다」로: 거리 없는 기본 조립엔 운반(#L…m) 없음 · 거리 무관 적재(#적재)만 섬 - 갈래 제목 ⊂ 마스터 갈래 키: 10-12 는 표가 아니라 본문 식이라 마스터 표에 갈래가 없음 (#L…m 는 프로젝트 거리마다 생겨 마스터에 둘 수도 없음) → 식이 내는 FP-10-12 갈래만 까닭 적고 제외 - 58afe8f2 를 일부 시험만 돌리고 올려 난 빨강 · 전체 1763 통과 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- resources/tester/test_b09_verify_guards.py | 10 +++++++--- resources/tester/test_work_item_master_variants.py | 4 ++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/resources/tester/test_b09_verify_guards.py b/resources/tester/test_b09_verify_guards.py index 941371ef..b82f081a 100644 --- a/resources/tester/test_b09_verify_guards.py +++ b/resources/tester/test_b09_verify_guards.py @@ -53,10 +53,14 @@ def test_v16_불도저_운반이_갈래마다_금액으로_선다() -> None: assert build.book.resolve(code).total > 0, code -def test_v16_덤프_운반은_아직_안_선다() -> None: - """⚠ 안 서는 것도 사실대로 못 박는다 — 운반거리(설계 입력)를 기다리는 자리다.""" +def test_v16_덤프_운반은_거리가_와야_선다() -> None: + """⚠ 안 서는 것도 사실대로 못 박는다 — 운반은 운반거리(설계 입력)가 와야 선다. + + 2026-09-14 — 적재(10-12 「1. 적재」)는 거리 무관이라 늘 서고, 운반(`#L…m`)은 거리가 온 조립에만. + """ build = cached_build() - assert not [code for code in build.book.titles if code.startswith("B-FP-10-12")] + dump = [code for code in build.book.titles if code.startswith("B-FP-10-12")] + assert dump and all(code.endswith("#적재") for code in dump) # ── V-17 ──────────────────────────────────────────────────────────── diff --git a/resources/tester/test_work_item_master_variants.py b/resources/tester/test_work_item_master_variants.py index 3e0983c8..9a46aa3d 100644 --- a/resources/tester/test_work_item_master_variants.py +++ b/resources/tester/test_work_item_master_variants.py @@ -23,6 +23,10 @@ def test_단가표_갈래_제목은_전부_마스터_갈래_키에_있다() -> N for code in cached_build().book.titles: if not code.startswith("B-") or "#" not in code: continue + if code.startswith("B-FP-10-12-"): + # 덤프 운반·적재(10-12)는 **표가 아니라 본문 식**이라 마스터 표에 갈래가 없음 — + # 갈래(`#적재`·`#L…m`)는 식이 냄(2026-09-14 · `MachineProductivity_Dump`). + continue work_item, variant = code[2:].split("#", 1) keys = { normalize_variant_key(k) for k in nodes.get(work_item, {}).get("variant_keys") or [] From a440b4f9190f8198bd18aca185a0db59d86f3c4b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 08:57:24 +0900 Subject: [PATCH 15/16] =?UTF-8?q?feat(b08):=20=EB=8D=A4=ED=94=84=20?= =?UTF-8?q?=EC=9A=B4=EB=B0=98=EB=A7=88=EB=8B=A4=20=E3=80=8C=EB=8D=A4?= =?UTF-8?q?=ED=94=84=20=EC=A0=81=EC=9E=AC=E3=80=8D=20=EC=A7=9D=20=EC=A4=84?= =?UTF-8?q?=20=E2=80=94=2010-12=20=E3=80=8C1.=20=EC=A0=81=EC=9E=AC?= =?UTF-8?q?=E3=80=8D=20=C2=B7=20=EA=B1=B0=EB=A6=AC=20=EB=AC=B4=EA=B4=80=20?= =?UTF-8?q?=C2=B7=20=EC=88=98=EB=9F=89=EC=9D=80=20=EC=9A=B4=EB=B0=98?= =?UTF-8?q?=EB=9F=89=20=EA=B7=B8=EB=8C=80=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _haul_rows 한 곳: dump_truck 내역 줄 뒤에 haul_equipment dump_loading 짝 줄(갈래·수량 같음) - 흙깎기 9-3-2(㎝ 20초·135°)에 싣기가 안 들어 있어 운반과 한 벌로 냄(브레인 판정) - B09 운반토량 검산(운반 ≤ 절취)에서 적재 줄은 뺌 — 같은 흙을 두 번 세지 않게 - 시험 기대값: 운반표 줄 4 → 6(적재 둘) · 내역 줄 3 → 5 · 전체 1763 통과 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- .../B08_Quantity_Engine_Handoff_Rows.py | 19 +++++++++++++++++++ .../B09_Estimation_BillOfQuantities.py | 10 +++++++++- resources/tester/test_b08_handoff.py | 13 ++++++++++--- 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py index fd748b2b..c4ddcfb6 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py @@ -288,9 +288,28 @@ def _haul_rows( "origin": ORIGIN_HAUL, } ) + if equipment == "dump_truck" and in_bill: + # 덤프 운반은 **싣는 일이 따로** — 산림품셈 10-12 「1. 적재」 + # (굴착기 0.7㎥ · ㎝ 22초·180°). + # 흙깎기 9-3-2(㎝ 20초·135°, 깎아 옆에 둠)에 안 들어 있어 운반과 한 벌로 짝 줄을 냄 + # (2026-09-14 브레인 판정). 거리와 무관 · 수량은 운반량 그대로. + rows.append( + { + **rows[-1], + "name": "덤프 적재", + "haul_distance_m": None, + "haul_equipment": LOADING_EQUIPMENT, + "spec_detail": f"{state_note} · 덤프 운반량만큼 싣기(10-12 「1. 적재」)", + } + ) return rows, unmatched +#: 인계 「덤프 적재」 짝 줄의 수단 표지 — +#: B09 `MachineProductivity_Dump.LOADING_EQUIPMENT` 와 같은 글. +LOADING_EQUIPMENT = "dump_loading" + + def blocked_of( structure: dict[str, Any], class_basis: str = "", has_code: bool = False ) -> tuple[str | None, str]: diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities.py b/B09_Estimation/B09_Estimation_BillOfQuantities.py index 6f67830c..86b1c528 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities.py @@ -628,7 +628,15 @@ def build_bill( ), _ZERO, ) - haul_total = sum((item.quantity for item in work_items if item.haul_equipment), _ZERO) + # 「덤프 적재」 짝 줄은 운반이 아니라 싣기 — 같은 흙을 운반량으로 두 번 세지 않게 뺌. + haul_total = sum( + ( + item.quantity + for item in work_items + if item.haul_equipment and item.haul_equipment != "dump_loading" + ), + _ZERO, + ) if cut_total > 0 and haul_total > 0: check_haul_volume_within_cut( haul_volume_total_m3=haul_total, diff --git a/resources/tester/test_b08_handoff.py b/resources/tester/test_b08_handoff.py index c31fc7e2..016d73dc 100644 --- a/resources/tester/test_b08_handoff.py +++ b/resources/tester/test_b08_handoff.py @@ -188,6 +188,11 @@ def test_무대는_넘기되_내역에는_안_섬() -> None: assert dump["in_bill"] is True assert dump["work_item_code"] == "FP-10-12" assert dump["haul_distance_m"] == pytest.approx(1200.0) + # 2026-09-14 — 덤프 운반마다 「덤프 적재」 짝 줄(10-12 「1. 적재」) · 거리 없음 · 수량 같음 + loading = 줄(handoff, "덤프 적재") + assert loading["haul_equipment"] == "dump_loading" and loading["haul_distance_m"] is None + assert loading["quantity"] == dump["quantity"] and loading["variant_value"] == "토사" + assert not any(r["name"] == "덤프 적재" and r["spec"] != "토사" for r in handoff["work_items"]) def test_합계_줄은_내역에_안_섬() -> None: @@ -600,9 +605,11 @@ def 운반표() -> dict: def test_운반계획이_있으면_네_줄이_실림() -> None: handoff = build_handoff(haul_table=운반표()) haul_rows = [row for row in handoff["work_items"] if row["origin"] == "haul"] - assert len(haul_rows) == 4 + # 운반 넷 + 덤프 운반 둘의 적재 짝 줄 둘(2026-09-14 · 10-12 「1. 적재」 — 거리 없음) + moves = [row for row in haul_rows if row["haul_equipment"] != "dump_loading"] + assert len(moves) == 4 and len(haul_rows) == 6 assert all(row["haul_equipment"] for row in haul_rows) - assert all(row["haul_distance_m"] for row in haul_rows) + assert all(row["haul_distance_m"] for row in moves) def test_무대가_함께_와야_운반_검산이_걸림() -> None: @@ -612,7 +619,7 @@ def test_무대가_함께_와야_운반_검산이_걸림() -> None: assert free["in_bill"] is False assert free["quantity"] == pytest.approx(871.0) assert handoff["excluded_row_count"] == 1 - assert handoff["bill_row_count"] == 3 + assert handoff["bill_row_count"] == 5 # 도자 · 덤프 운반 둘 · 덤프 적재 짝 줄 둘 def test_운반계획이_없으면_줄이_없음() -> None: From 9cd2920afcf8ca5beb27ce84a85a68001c21731a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 09:01:37 +0900 Subject: [PATCH 16/16] =?UTF-8?q?knowledge(=EC=B6=95C=20=EB=AA=85=EC=84=B8?= =?UTF-8?q?):=20=EC=B4=9D=EC=95=A1=201,000=EC=9B=90=20=EB=AF=B8=EB=A7=8C?= =?UTF-8?q?=EB=B2=84=EB=A6=BC=20=EC=8B=A4=EC=B8=A1=20=EA=B2=B0=EA=B3=BC=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 고시 다섯 줄 중 총액 줄만 우리가 안 따르고 있었음(기본값 꺼짐) - 실무 6건 끝 세 자리 000 ↔ 우리 373·695·578·654·341·639 - 곁 버그 — 절사 자동보정이 둘째 걸음부터 ÷1.1 누락으로 진동 - 집계표 ROUND 는 설계서 소계와 다른 자리로 봄 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0154EMdwPYNPuKc9eZv8SLEX --- docs/raw/verification/2026-09-13_축C_명세.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/raw/verification/2026-09-13_축C_명세.md b/docs/raw/verification/2026-09-13_축C_명세.md index bcb21055..a9e4d7bd 100644 --- a/docs/raw/verification/2026-09-13_축C_명세.md +++ b/docs/raw/verification/2026-09-13_축C_명세.md @@ -358,7 +358,7 @@ B08 인계본 `ground_class_aliases` — **B09 가 안 읽음**)을 한 벌로 | 종목 | 단위 | 지위 | 비고 | 우리 실측과 | | --- | --- | --- | --- | --- | -| 설계서의 **총액** | 원 | **1,000** | 미만버림 | ⚠ **아직 확인 안 됨** — 데스크탑 서브가 재는 중 | +| 설계서의 **총액** | 원 | **1,000** | 미만버림 | ⚠ **우리는 안 버리고 있었음** — 실무 6건은 여섯 다 `000`, 우리는 `373·695·578·654·341·639`. 기본을 켜는 것으로 판정(2026-09-14) | | 설계서의 소계 | 원 | 1 | 미만버림 | ✅ | | 설계서의 **금액란** | 원 | 1 | 미만버림 | ✅ 「내역에 실릴 때 정수 절사」와 같음 | | **일위대가표의 계금** | 원 | 1 | 미만버림 | ✅ 「호표 안 성분 소계 절사」와 같음 | @@ -366,6 +366,15 @@ B08 인계본 `ground_class_aliases` — **B09 가 안 읽음**)을 한 벌로 ⇒ **STmate 를 재서 얻은 규칙이 법 조문이었음.** 경쟁사를 베낀 것이 아니라 둘 다 같은 고시를 따른 것임. +⚠ **총액 줄만 우리가 안 따르고 있었음** (2026-09-14 데스크탑 서브 실측). 기본값이 꺼져 있어(`cut_basis=""`) +실무 6건의 총공사비 끝 세 자리가 우리 엔진에서만 남음. **기본을 켜되 화면에 근거를 보일 것** — +2026-09-08 「조용히 깎지 않음」 합의는 근거가 없을 때 정한 것이고, 지금은 고시가 근거임. +곁에 버그 하나 — 절사 자동보정이 둘째 걸음부터 ÷1.1 을 안 해 경계를 지나쳐 **진동**함 +(울진 신설 `654→999→900→910→909…`). 다섯 건은 첫 걸음에 앉아 안 드러났음. + +⚠ 「설계서의 소계 1원 미만버림」과 우리 「집계표 행 금액 `ROUND`(279/279)」는 **다른 자리**로 봄 — +집계표는 설계서 소계가 아님. 같은 자리로 읽을 근거가 나오면 다시 잴 것. + **같은 고시 1-2-2 「1. 수량의 단위표준」 [주]**: ```