From 64a52df680d0d87639ba90273b908b337b728551 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 17:42:49 +0900 Subject: [PATCH] =?UTF-8?q?feat(b08):=209-21=20=EC=A0=9C=EA=B7=BC=20?= =?UTF-8?q?=EA=B5=B4=EC=B0=A9=EA=B8=B0=20=ED=81=AC=EA=B8=B0=20=EC=B9=B8=20?= =?UTF-8?q?=E2=80=94=20=ED=81=AC=EA=B8=B0(0.2=C2=B70.7)=C2=B7=EC=9E=84?= =?UTF-8?q?=EB=AA=A9=EC=B6=95=EC=A0=81=20=EB=93=B1=EA=B8=89=EC=9D=84=20?= =?UTF-8?q?=EB=A7=A4=ED=95=91=20variant=5Ftemplate=20=EB=A1=9C=20=ED=95=9C?= =?UTF-8?q?=20=EA=B0=88=EB=9E=98=20=EA=B0=92(=E3=80=8C0.7=C2=B7=EC=86=8C?= =?UTF-8?q?=EB=A6=BC=E3=80=8D)=EC=9C=BC=EB=A1=9C=20=EC=97=AE=EA=B3=A0=20?= =?UTF-8?q?=EB=B2=94=EC=9C=84=20=EB=B3=84=EC=B9=AD(FP-09-21)=EC=9D=B4=20B0?= =?UTF-8?q?9=20=EA=B0=88=EB=9E=98=EB=A1=9C=20=EC=9D=B4=EC=9D=8C=20=C2=B7?= =?UTF-8?q?=20=EC=A0=9C=EC=95=88=200.7=E3=8E=A5(=EC=82=B0=EB=A6=BC?= =?UTF-8?q?=ED=92=88=EC=85=88=2010-12-1=20[=EC=A3=BC]=E2=91=A0)=EC=9D=80?= =?UTF-8?q?=20=EC=B9=B8=20=EA=B3=81=EC=97=90=EB=A7=8C=20=C2=B7=20=EB=B9=84?= =?UTF-8?q?=EB=A9=B4=20=EA=B8=88=EC=95=A1=20=EC=97=86=EC=9D=B4=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=20=EC=82=AC=EC=9C=A0(=EB=B8=8C=EB=A0=88=EC=9D=B8=20?= =?UTF-8?q?=ED=8C=90=EC=A0=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 배정 프로젝트: 뿌리뽑기 줄만 사유가 「하위 후보 6건」→「입력이 필요합니다」 · 소림·0.7 넣으면 55원/㎡ × 17,698㎡ = 973,414(되돌림) · 전체 1935 통과 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- .../B08_Quantity_Engine_EarthworkSummary.py | 8 +++ B08_Quantity/B08_Quantity_Engine_Handoff.py | 3 + .../B08_Quantity_Engine_Handoff_Rows.py | 19 +++-- B08_Quantity/B08_Quantity_Router_Earthwork.py | 13 ++++ B08_Quantity/B08_Quantity_Router_Material.py | 2 + .../data_aliases/aliases_2026-01-01.json | 48 +++++++++++++ .../work_item_mapping_2026-01-01.json | 3 + .../tester/test_b08_root_removal_excavator.py | 70 +++++++++++++++++++ 8 files changed, 161 insertions(+), 5 deletions(-) create mode 100644 resources/tester/test_b08_root_removal_excavator.py diff --git a/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py b/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py index d504d52b..fa596476 100644 --- a/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py +++ b/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py @@ -52,6 +52,14 @@ FACE_DRESSING_FILL_SUGGESTED = ( "영월 실무 설계내역 「성토사면고르기 06M3 B/H」(백호 = 무한궤도 굴착기) · 임도는 산지라" " 타이어식이 잘 안 들어감 — 타이어식도 고를 수 있음(2026-09-14 브레인 ②)", ) +#: 지장목제거 뿌리뽑기(9-21 제근) 굴착기 크기 — 품셈 9-21 표 갈래 0.2·0.7(무한궤도). 등급과 한 갈래. +#: ⚠ 제안값은 칸 곁에만(스스로 안 고름 · 비면 금액 없이 사유 — 2026-09-14 브레인 판정). +ROOT_REMOVAL_EXCAVATOR_SIZES = ("0.2", "0.7") +ROOT_REMOVAL_EXCAVATOR_SUGGESTED = ( + "0.7", + "산림품셈 10-12-1 [주]① 「장비는 무한궤도 굴착기(0.7㎥)를 적용한다」 — 같은 고시가 임도 표준" + " 장비를 0.7 로 적은 자리 · 0.2㎥ 도 고를 수 있음", +) #: ⭐ 2026-09-14 브레인 판정 Ⓐ — 밑수 = 초류종자살포(파종) 면적. 반영률 칸을 따로 두면 같은 값을 #: 두 곳에서 관리하게 됨. 실무도 그렇게 움직임(거창 파종 성토 50%·절토 100% · 영월 성토 50%). FACE_DRESSING_NOTE = ( diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff.py b/B08_Quantity/B08_Quantity_Engine_Handoff.py index cd0054e2..677397af 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff.py @@ -115,6 +115,7 @@ def build_handoff( stand_volume_class: str | None = None, face_dressing_cut_class: str | None = None, face_dressing_fill_class: str | None = None, + root_removal_excavator_m3: str | None = None, priced_sheets: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: """B09 가 그대로 받는 모양. 없는 표는 건너뛰되 **빈 표와 구별해 적는다**. @@ -134,6 +135,8 @@ def build_handoff( "stand_volume_class": stand_volume_class, "face_dressing_cut_class": face_dressing_cut_class, "face_dressing_fill_class": face_dressing_fill_class, + # 제근 굴착기 크기 — 등급과 「크기·등급」 한 갈래로(매핑 `variant_template` · 09-14). + "root_removal_excavator_m3": root_removal_excavator_m3, } rows, misses = _earthwork_rows( summary_table, table, methods, bench_cut_depth_m, variant_inputs diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py index 1ad700ef..db1d6ce0 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py @@ -7,6 +7,7 @@ from __future__ import annotations +import string from typing import Any from B08_Quantity.B08_Quantity_Engine_BasisUnit import normalize_unit, unit_for_code @@ -151,16 +152,24 @@ def _earthwork_rows( # 보는 쪽을 고쳤으면 **다는 쪽도** 빠짐없이 달아야 한다. blocked_kind = mismatch[1] if mismatch else None blocked_reason = mismatch[2] if mismatch else "" + inputs = {**(variant_inputs or {}), "ground_class": ground} variant_value = ( (entry or {}).get("variant_value") - or { - **(variant_inputs or {}), - "ground_class": ground, - }.get(str((entry or {}).get("variant_from") or "")) + or inputs.get(str((entry or {}).get("variant_from") or "")) or None ) + # 입력 둘을 한 갈래 값으로 엮는 매핑(9-21 「크기·등급」 · 09-14) — 하나라도 비면 안 엮음. + template = str((entry or {}).get("variant_template") or "") + needs = [name for _, name, _, _ in string.Formatter().parse(template) if name] + template_ready = all(inputs.get(name) for name in needs) + if template and template_ready: + variant_value = template.format(**inputs) # 매핑이 「이 칸이 비면 못 고름」이라 적은 갈래 — 금액 없이 입력 사유(면고르기 · 09-14 Ⓒ). - if code and not variant_value and (entry or {}).get("variant_missing_reason"): + if ( + code + and (not variant_value or not template_ready) + and (entry or {}).get("variant_missing_reason") + ): blocked_kind = blocked_kind or BLOCKED_INPUT_MISSING blocked_reason = blocked_reason or str(entry["variant_missing_reason"]) if code is None and not is_subtotal: diff --git a/B08_Quantity/B08_Quantity_Router_Earthwork.py b/B08_Quantity/B08_Quantity_Router_Earthwork.py index 1009c85a..5e367607 100644 --- a/B08_Quantity/B08_Quantity_Router_Earthwork.py +++ b/B08_Quantity/B08_Quantity_Router_Earthwork.py @@ -34,6 +34,8 @@ from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import ( FACE_DRESSING_CUT_CLASSES, FACE_DRESSING_FILL_CLASSES, FACE_DRESSING_FILL_SUGGESTED, + ROOT_REMOVAL_EXCAVATOR_SIZES, + ROOT_REMOVAL_EXCAVATOR_SUGGESTED, SummaryInput, ) from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import build_table as build_summary_table @@ -173,6 +175,14 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse: "basis": FACE_DRESSING_FILL_SUGGESTED[1], }, } + # 제근 굴착기 크기 — 선택지·제안(회색 · [제안값 넣기])은 서버 한 곳(2026-09-14 브레인). + table["root_removal_excavator_choices"] = { + "choices": list(ROOT_REMOVAL_EXCAVATOR_SIZES), + "suggested": { + "value": ROOT_REMOVAL_EXCAVATOR_SUGGESTED[0], + "basis": ROOT_REMOVAL_EXCAVATOR_SUGGESTED[1], + }, + } # 준비공·사방공 — 못 서는 줄도 사유와 함께 남긴다(빈 표는 「빠뜨림」과 구별이 안 됨). structures = await _route_structures(project_id) table["preparation"] = build_preparation_table( @@ -478,6 +488,8 @@ class QuantitySettingsBody(BaseModel): stand_volume_class: str | None = None # 면고르기 갈래 — 절토면 토질 · 성토면 시공·토질(9-19-1 원문 표). `""` 는 「안 정함」. face_dressing_cut_class: str | None = None + # 제근 굴착기 크기 — "0.2"·"0.7"(품셈 9-21 갈래). `""` 는 「안 정함」. + root_removal_excavator_m3: str | None = None face_dressing_fill_class: str | None = None # 면고르기 면적 덮어쓰기(㎡) — `None` 은 파종 면적을 그대로(2026-09-14 판정 Ⓐ). face_dressing_fill_area_m2: float | None = None @@ -556,6 +568,7 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) - for key, choices in ( ("face_dressing_cut_class", FACE_DRESSING_CUT_CLASSES), ("face_dressing_fill_class", FACE_DRESSING_FILL_CLASSES), + ("root_removal_excavator_m3", ROOT_REMOVAL_EXCAVATOR_SIZES), ): if key in values and values[key] not in choices: values[key] = "" # 선택지 밖·빈 값은 「안 정함」 — 가까운 갈래로 안 고침 diff --git a/B08_Quantity/B08_Quantity_Router_Material.py b/B08_Quantity/B08_Quantity_Router_Material.py index 5aec260c..5e44e799 100644 --- a/B08_Quantity/B08_Quantity_Router_Material.py +++ b/B08_Quantity/B08_Quantity_Router_Material.py @@ -497,6 +497,8 @@ async def get_handoff(project_id: UUID) -> JSONResponse: # 면고르기 갈래 — 절토면 토질 · 성토면 시공·토질. 비면 그 줄이 입력 사유로 막힘. face_dressing_cut_class=settings.get("face_dressing_cut_class") or None, face_dressing_fill_class=settings.get("face_dressing_fill_class") or None, + # 제근 굴착기 크기(0.2·0.7) — 비면 뿌리뽑기 줄이 입력 사유로 막힘(제안 0.7 은 칸 곁에만). + root_removal_excavator_m3=settings.get("root_removal_excavator_m3") or None, # 구조물도 양식 일위대가로 셀 장 — 그 구조물은 호표 `AX-ST` 줄 하나로(PLAN 6장 ②). priced_sheets=_priced_sheets(project_root, unit_table, modes, settings), ) diff --git a/resources/data_aliases/aliases_2026-01-01.json b/resources/data_aliases/aliases_2026-01-01.json index 035083df..304af9cd 100644 --- a/resources/data_aliases/aliases_2026-01-01.json +++ b/resources/data_aliases/aliases_2026-01-01.json @@ -43,6 +43,54 @@ "pum_edition": "2026-01-01", "basis": "9-19-1 토사면 고르기 절토면 표(원문 L5414)가 자원 머리에 규격을 안 적음 — [주]① 「공기압축기는 3.5㎥/분, 소형브레이커는 1㎥/분, 굴착기는 0.7㎥를 기준한 것이다」 → 굴착기(무한궤도) 0.7(카탈로그 0.7 은 무한궤도뿐). 칸이 규격을 적은 줄(성토면 굴착기 0.6㎥)은 안 덮음. 2026-09-14 브레인 승인(⑤ Ⓐ)" }, + { + "axis": "variant", + "from": "0.2·소림", + "to": "굴착기(무한궤도) 0.2 · 소", + "scope": "FP-09-21", + "pum_edition": "2026-01-01", + "basis": "품셈 9-21 제근 표 — 굴착기(무한궤도) 0.2·0.7 블록 × 임목축적 등급 열 「소·중·밀」([주]① 소림·중림·밀림). B08 이 크기·등급 두 칸을 「크기·등급」 으로 엮어 보냄(2026-09-14 브레인 판정 · 갈래 표기는 이 표 한 곳)" + }, + { + "axis": "variant", + "from": "0.2·중림", + "to": "굴착기(무한궤도) 0.2 · 중", + "scope": "FP-09-21", + "pum_edition": "2026-01-01", + "basis": "품셈 9-21 제근 표 — 굴착기(무한궤도) 0.2·0.7 블록 × 임목축적 등급 열 「소·중·밀」([주]① 소림·중림·밀림). B08 이 크기·등급 두 칸을 「크기·등급」 으로 엮어 보냄(2026-09-14 브레인 판정 · 갈래 표기는 이 표 한 곳)" + }, + { + "axis": "variant", + "from": "0.2·밀림", + "to": "굴착기(무한궤도) 0.2 · 밀", + "scope": "FP-09-21", + "pum_edition": "2026-01-01", + "basis": "품셈 9-21 제근 표 — 굴착기(무한궤도) 0.2·0.7 블록 × 임목축적 등급 열 「소·중·밀」([주]① 소림·중림·밀림). B08 이 크기·등급 두 칸을 「크기·등급」 으로 엮어 보냄(2026-09-14 브레인 판정 · 갈래 표기는 이 표 한 곳)" + }, + { + "axis": "variant", + "from": "0.7·소림", + "to": "굴착기(무한궤도) 0.7 · 소", + "scope": "FP-09-21", + "pum_edition": "2026-01-01", + "basis": "품셈 9-21 제근 표 — 굴착기(무한궤도) 0.2·0.7 블록 × 임목축적 등급 열 「소·중·밀」([주]① 소림·중림·밀림). B08 이 크기·등급 두 칸을 「크기·등급」 으로 엮어 보냄(2026-09-14 브레인 판정 · 갈래 표기는 이 표 한 곳)" + }, + { + "axis": "variant", + "from": "0.7·중림", + "to": "굴착기(무한궤도) 0.7 · 중", + "scope": "FP-09-21", + "pum_edition": "2026-01-01", + "basis": "품셈 9-21 제근 표 — 굴착기(무한궤도) 0.2·0.7 블록 × 임목축적 등급 열 「소·중·밀」([주]① 소림·중림·밀림). B08 이 크기·등급 두 칸을 「크기·등급」 으로 엮어 보냄(2026-09-14 브레인 판정 · 갈래 표기는 이 표 한 곳)" + }, + { + "axis": "variant", + "from": "0.7·밀림", + "to": "굴착기(무한궤도) 0.7 · 밀", + "scope": "FP-09-21", + "pum_edition": "2026-01-01", + "basis": "품셈 9-21 제근 표 — 굴착기(무한궤도) 0.2·0.7 블록 × 임목축적 등급 열 「소·중·밀」([주]① 소림·중림·밀림). B08 이 크기·등급 두 칸을 「크기·등급」 으로 엮어 보냄(2026-09-14 브레인 판정 · 갈래 표기는 이 표 한 곳)" + }, { "axis": "variant", "from": "리핑암", 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 63b4e1ef..037592ad 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 @@ -173,6 +173,9 @@ "basis_source": "⚠ 교차 참조 — 산림품셈 9-21 표에 밑수 표기가 없어 건설공사 표준품셈 3-9-2 뿌리뽑기 「1,000㎡당」 면적 축을 빌려 씀(사용자 확정 5차 6번). 마스터 밑수는 비어 있음(basis_missing F0294).", "variant_axis": "stand_volume_class", "variant_from": "stand_volume_class", + "variant_template": "{root_removal_excavator_m3}·{stand_volume_class}", + "variant_missing_reason": "제근 굴착기 크기·임목축적 등급이 아직 다 입력되지 않았습니다 — 산출 조건에서 고르면 단가가 섭니다(품셈 9-21: 굴착기 0.2·0.7 × 소림·중림·밀림 · 크기 제안 0.7㎥ 산림품셈 10-12-1 [주]①)", + "variant_note": "2026-09-14 브레인 판정 — 크기·등급 두 칸을 템플릿으로 한 값(「0.7·소림」)으로 엮고 B09 갈래 표기는 범위 별칭(FP-09-21)이 앎. 둘 중 하나라도 비면 등급만 싣고 입력 사유.", "note": "2026-09-13 브레인 판정 Ⓑ — 제근은 토공 줄(토공_수량.md:30 「뿌리다듬기·적재·제근 | 9-20~21 | 벌개제근 연동」)이라 여기서 셈. 준비공 「제근·뿌리다듬기」는 같은 면적이라 참조로만 보임(이중계상 막이). 품은 임목축적 등급(소림·중림·밀림, 9-21 [주]①)으로 갈려 등급을 갈래로 넘김." } ], diff --git a/resources/tester/test_b08_root_removal_excavator.py b/resources/tester/test_b08_root_removal_excavator.py new file mode 100644 index 00000000..ad1a400c --- /dev/null +++ b/resources/tester/test_b08_root_removal_excavator.py @@ -0,0 +1,70 @@ +"""9-21 제근 굴착기 크기 칸 — 2026-09-14 브레인 판정(산출 조건 칸 · 제안 0.7㎥ · 비면 사유). + +9-21 품은 굴착기 0.2·0.7 × 임목축적 등급(소·중·밀) 여섯 갈래인데 B08 은 등급만 보내 B09 가 못 고름. +⇒ 매핑 `variant_template` 이 크기·등급 두 입력을 한 값(「0.7·소림」)으로 엮고, 범위 별칭(FP-09-21)이 + 그 값을 B09 갈래(「굴착기(무한궤도) 0.7 · 소」)로 잇는다 — 갈래 키 표기는 별칭표 한 곳만 앎. +""" + +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 B08_Quantity.B08_Quantity_Engine_EarthworkSummary import ( # noqa: E402 + ROOT_REMOVAL_EXCAVATOR_SIZES, + ROOT_REMOVAL_EXCAVATOR_SUGGESTED, +) +from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_Preparation import STAND_VOLUME_CLASSES # noqa: E402 + + +def _row(**inputs) -> dict: + summary = {"rows": [{"group": "지장목제거", "item": "뿌리뽑기", "spec": "", "unit": "㎡", + "amount": 1000.0, "application_ratio_pct": 100.0}]} # fmt: skip + rows = build_handoff(summary_table=summary, **inputs)["work_items"] + return next(r for r in rows if r["work_item_code"] == "FP-09-21") + + +def test_크기와_등급을_한_갈래_값으로_엮어_내역_금액이_섬() -> None: + from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill + + inputs = {"stand_volume_class": "소림", "root_removal_excavator_m3": "0.7"} + row = _row(**inputs) + assert row["variant_value"] == "0.7·소림" and not row["blocked_kind"], row + summary = {"rows": [{"group": "지장목제거", "item": "뿌리뽑기", "spec": "", "unit": "㎡", + "amount": 1000.0}]} # fmt: skip + bill = build_bill(build_handoff(summary_table=summary, **inputs)) + line = next(r for r in bill.rows if r.code == "FP-09-21") + assert line.price_code == "B-FP-09-21#굴착기(무한궤도)0.7·소", line.note + assert line.amount_krw and int(line.amount_krw) > 0, line.note + + +def test_크기가_비면_금액_없이_입력_사유_등급은_그대로_실음() -> None: + row = _row(stand_volume_class="중림") + assert row["variant_value"] == "중림" # 판정 Ⓑ — 등급 갈래를 잃지 않음 + assert row["blocked_kind"] == "input_missing" and "굴착기" in row["blocked_reason"], row + assert _row()["blocked_kind"] == "input_missing" + + +def test_선택지는_마스터_갈래_키의_크기_제안은_0_7_근거_10_12_1() -> None: + from B09_Estimation.B09_Estimation_ResourceAxis import load_work_item_master + + node = next( + n for n in load_work_item_master()["work_items"] if n["work_item_code"] == "FP-09-21" + ) + sizes = {key.split(")")[1].split("·")[0].strip() for key in node["variant_keys"]} + assert sizes == set(ROOT_REMOVAL_EXCAVATOR_SIZES) + value, basis = ROOT_REMOVAL_EXCAVATOR_SUGGESTED + assert value == "0.7" and "10-12-1" in basis + # 크기 × 등급 여섯이 모두 별칭으로 B09 갈래에 닿음 + from common_util.common_util_aliases import alias_target, load_aliases + + rows = load_aliases("variant") + for size in ROOT_REMOVAL_EXCAVATOR_SIZES: + for grade in STAND_VOLUME_CLASSES: + target = alias_target(rows, f"{size}·{grade}", "FP-09-21", "2026-01-01") + assert target in node["variant_keys"], (size, grade, target)