diff --git a/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py b/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py index 143ea9d8..fa7f6d26 100644 --- a/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py +++ b/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py @@ -176,17 +176,21 @@ def build_rows(source: SummaryInput) -> list[SummaryRow]: # 1.9.2 잡관목제거 벌목(5m미만) 11,035㎡ @882 ← 같은 11,035㎡ # ⚠⚠ **이중계상이 아니다** — 한 면적에 **다른 두 작업**이 얹히는 것이라 실무가 그렇게 적는다. # (같은 작업을 두 축에서 두 번 세는 것과는 다른 자리다.) - # ⚠ 잡관목제거는 **품셈에 그 이름이 없다** — 실무는 별도 단가(영월 D00033)를 씀. - # 공종 없는 줄 보류(확정 5차 3번)에 걸리므로 **코드 없이 서고 사유가 붙는다.** + # ⭐ 2026-09-13 브레인 판정 — 지장목제거는 **품셈에 있다**(벌목 4장 + 제근 9-20~21 단계 합산). + # 잡관목제거 = 단목베기 1,000㎡당 「5m 미만」(4-2-2) · 뿌리뽑기 = 제근(9-21, 판정 Ⓑ). + # 준비공 「제근·뿌리다듬기」는 같은 면적이라 **참조로만** 보인다(이중계상 막이). for item, why in ( ( "뿌리뽑기", - "확정 5차 2번 — 실무가 뿌리뽑기·잡관목제거 두 줄로 가름(같은 면적을 나눠 씀 · 이중계상 아님)", + "확정 5차 2번 — 실무가 뿌리뽑기·잡관목제거 두 줄로 가름" + "(같은 면적을 나눠 씀 · 이중계상 아님). 품셈 9-21 제근으로 이음 —" + " 준비공 「제근·뿌리다듬기」는 참조로만 보임(2026-09-13 판정)", ), ( "잡관목제거", "확정 5차 2번 — 같은 면적에 얹히는 다른 작업(이중계상 아님)." - " ⚠ 품셈에 그 이름이 없어 실무는 별도 단가를 씀(영월 D00033) — 공종 보류 대상", + " 품셈 4-2-2 단목베기(1,000㎡당) 「5m 미만」으로 이음" + "(2026-09-13 판정 · 실무 영월 1.9.2)", ), ): rows.append( diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff.py b/B08_Quantity/B08_Quantity_Engine_Handoff.py index a8fdf833..090e4b1a 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff.py @@ -111,6 +111,7 @@ def build_handoff( concrete_placing_method: str | None = None, bench_cut_depth_m: float | None = None, structure_trench_water: str | None = None, + stand_volume_class: str | None = None, ) -> dict[str, Any]: """B09 가 그대로 받는 모양. 없는 표는 건너뛰되 **빈 표와 구별해 적는다**.""" table = mapping or load_mapping() @@ -119,7 +120,11 @@ def build_handoff( methods = {key: value for key, value in (ground_methods or {}).items() if value} if summary_table: - rows, misses = _earthwork_rows(summary_table, table, methods, bench_cut_depth_m) + # 임목축적 등급 — 지장목제거 뿌리뽑기(9-21 제근)의 품 갈래(2026-09-13 판정 Ⓑ). + variant_inputs = {"stand_volume_class": stand_volume_class} + rows, misses = _earthwork_rows( + summary_table, table, methods, bench_cut_depth_m, variant_inputs + ) work_items.extend(rows) unmatched.extend(misses) if haul_table: diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py index 085d21f6..28f674a4 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py @@ -222,17 +222,30 @@ class WorkItemMapping: found[str(code)] = str(unit) return found - def for_earthwork(self, group: str, ground: str | None) -> dict[str, Any] | None: # noqa: D401 - """공종+지반유형으로 찾는다. 지반이 갈리지 않는 공종은 `ground` 없는 줄이 받는다.""" + def for_earthwork( + self, group: str, ground: str | None, item: str | None = None + ) -> dict[str, Any] | None: # noqa: D401 + """공종+지반유형으로 찾는다. 지반이 갈리지 않는 공종은 `ground` 없는 줄이 받는다. + + `item` 은 **작업 갈래**(지장목제거의 「잡관목제거」) — 매핑 줄에 `item` 이 있으면 같아야 함. + """ exact = [ row for row in self.earthwork - if row.get("group") == group and row.get("ground") == ground + if row.get("group") == group + and row.get("ground") == ground + and ("item" not in row or row.get("item") == item) ] if exact: return exact[0] # 지반을 안 가르는 공종(성토·층따기 등)은 `ground` 칸이 없는 줄로 맞춘다. - loose = [row for row in self.earthwork if row.get("group") == group and "ground" not in row] + loose = [ + row + for row in self.earthwork + if row.get("group") == group + and "ground" not in row + and ("item" not in row or row.get("item") == item) + ] return loose[0] if loose else None def for_haul(self, equipment: str) -> dict[str, Any] | None: diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py index c12f967f..119297cc 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py @@ -93,9 +93,12 @@ def _earthwork_rows( mapping: WorkItemMapping, methods: dict[str, str | None], bench_cut_depth_m: float | None = None, + variant_inputs: dict[str, str | None] | None = None, ) -> tuple[list[dict[str, Any]], list[str]]: """토공집계표 줄을 내역 줄로 옮긴다. + `variant_inputs` — 매핑 줄이 `variant_from` 으로 가리키는 **설정값**(임목축적 등급 등). + ⚠ 「보정량계」 같은 합계 줄은 **내역 줄이 아니다** — 빼지 않고 `in_bill: False` 로 넘긴다. 빼 버리면 B09 가 검산할 때 합이 안 맞는 까닭을 알 수 없다. """ @@ -115,7 +118,9 @@ def _earthwork_rows( # ⚠ 운반은 **집계에도 오르고 운반표에도 오른다** — 내역 줄은 운반표 쪽 하나뿐이다. is_subtotal = group in SUBTOTAL_GROUPS or group in HAUL_SUMMARY_GROUPS lookup_ground, method_note = _mapping_ground(ground, methods) - entry = mapping.for_earthwork(group, lookup_ground) if method_note == "" else None + entry = ( + mapping.for_earthwork(group, lookup_ground, work_kind) if method_note == "" else None + ) code = (entry or {}).get("work_item_code") # ⚠ 품셈 밑수와 우리 단위가 다른 자리 — **곱하면 금액이 틀린다**(층따기 9-18). # 면적 값을 버리지 않고 `spec_detail` 에 남겨 되짚을 수 있게 한다. @@ -169,9 +174,12 @@ def _earthwork_rows( "spec_detail": spec_detail, "composite_parts": None, "structure_kind": None, - # 토공·운반 줄에는 갈래 축이 없다 — 그래도 **칸은 둔다**(계약이 한 모양). - "variant_axis": None, - "variant_value": None, + # 토공 줄은 대개 갈래 축이 없다 — 그래도 **칸은 둔다**(계약이 한 모양). + # 매핑이 갈래를 적은 작업 갈래(잡관목제거 → 단목베기 「5m 미만」)만 값을 싣는다. + "variant_axis": (entry or {}).get("variant_axis"), + "variant_value": (entry or {}).get("variant_value") + or (variant_inputs or {}).get(str((entry or {}).get("variant_from") or "")) + or None, "secondary_axes": None, "spec_class": None, "spec_class_basis": "", diff --git a/B08_Quantity/B08_Quantity_Engine_Preparation.py b/B08_Quantity/B08_Quantity_Engine_Preparation.py index 50635c05..ed1d57ec 100644 --- a/B08_Quantity/B08_Quantity_Engine_Preparation.py +++ b/B08_Quantity/B08_Quantity_Engine_Preparation.py @@ -134,9 +134,8 @@ def preparation_rows( "대상 면적에 **소단면이 포함**됨(별표2 타.(1) 「노출되는 면은 전체면적 녹화」 — " "원문이 가르지 않고 실무도 한 덩이로 셈). " "토공집계의 「지장목제거」로 이미 섬 — 여기서 또 세우면 이중계상. " - "⚠ 다만 **공종 미확정** — 품셈 4장이 벌목을 목적별로 갈라(수확베기·단목베기·" - "위험목 베기) 임도 지장목이 어디에 붙는지 원본이 말하지 않음." - " 지금은 공종코드 없이 감." + "공종은 토공집계 줄이 가짐 — 잡관목제거는 품셈 4-2-2 단목베기(FP-04-02-02)" + " 「5m 미만」, 뿌리뽑기는 9-21 제근(FP-09-21)(2026-09-13 판정)." ), "work_item_code": None, }, @@ -259,6 +258,12 @@ ROOT_REMOVAL_BASIS = ( "밑수는 **면적 축**(사용자 확정 5차 6번) — 산림품셈 9-21 에 밑수 표기가 없어" " ⚠ **교차 참조**: 건설공사 표준품셈 **3-9-2 뿌리뽑기 「1,000㎡당」**을 빌려 씀" ) +#: ⭐ 2026-09-13 판정 Ⓑ — 제근은 **토공 줄**이다(지식DB `토공_수량.md:30` 「뿌리다듬기·적재·제근 | +#: 9-20~21 | 벌개제근 연동」). 실무 서식(영월 1.9.1 뿌리뽑기)대로 토공집계가 세고 여기는 참조. +ROOT_COUNTED_IN_SUMMARY = ( + "토공집계 「지장목제거 · 뿌리뽑기」가 품셈 9-21 제근(FP-09-21)으로 셈 — 같은 면적·같은 작업이라" + " 여기서 또 세우면 이중계상(2026-09-13 판정)" +) ROOT_REMOVAL_CLASS_MISSING = ( "임목축적 등급이 아직 입력되지 않았습니다 — 품셈 9-21 [주]① 이 소림(30~60㎥/㏊)·" "중림(60~90)·밀림(90 이상)으로 가름. ⚠ 본수가 아니라 **축적**이고, 산림조사부·영림계획에서" @@ -273,7 +278,11 @@ def _root_removal_row(slope: dict[str, float], stand_volume_class: str | None) - """ area = float(slope.get("tree_removal_fill", 0.0)) + float(slope.get("tree_removal_cut", 0.0)) picked = str(stand_volume_class or "").strip() - reasons = [ROOT_REMOVAL_BASIS, "대상 면적은 지장목제거와 같은 자리(벌개제근 연동)"] + reasons = [ + ROOT_COUNTED_IN_SUMMARY, + ROOT_REMOVAL_BASIS, + "대상 면적은 지장목제거와 같은 자리(벌개제근 연동)", + ] if picked in STAND_VOLUME_CLASSES: reasons.append(f"임목축적 등급 「{picked}」 — 품셈 9-21 [주]① 이 품을 그 축으로 가름") else: @@ -285,10 +294,12 @@ def _root_removal_row(slope: dict[str, float], stand_volume_class: str | None) - "item": "제근·뿌리다듬기", "unit": "㎡", "amount": area if area > 0 else None, - "status": STATUS_READY if area > 0 else STATUS_PENDING, + # ⭐ 2026-09-13 판정 Ⓑ — 셈은 토공집계 「지장목제거 · 뿌리뽑기」(FP-09-21)가 한다. + # 여기는 **보이되 안 실린다**(같은 면적 · 같은 작업 — 또 세면 이중계상). + "status": STATUS_COUNTED_ELSEWHERE if area > 0 else STATUS_PENDING, "reason": " · ".join(reasons), "reference_amount": area, - "work_item_code": "FP-09-21", + "work_item_code": None, } diff --git a/B08_Quantity/B08_Quantity_Router_Material.py b/B08_Quantity/B08_Quantity_Router_Material.py index 344d1500..56e49c6e 100644 --- a/B08_Quantity/B08_Quantity_Router_Material.py +++ b/B08_Quantity/B08_Quantity_Router_Material.py @@ -295,6 +295,8 @@ async def get_handoff(project_id: UUID) -> JSONResponse: bench_cut_depth_m=settings.get("bench_cut_depth_m"), # 용수 유무 — 기본 「육상」은 **통상값**이다(확정 3차 ④). 사유·화면에 그 사실이 뜬다. structure_trench_water=settings.get("structure_trench_water"), + # 임목축적 등급 — 지장목제거 뿌리뽑기(제근 9-21)의 품 갈래. 안 넣으면 B09 가 후보를 보임. + stand_volume_class=settings.get("stand_volume_class"), ) handoff["summary"] = summarize(handoff) handoff["skipped_structures"] = skipped diff --git a/B09_Estimation/B09_Estimation_MachineProductivity.py b/B09_Estimation/B09_Estimation_MachineProductivity.py index a120d4d7..530c9229 100644 --- a/B09_Estimation/B09_Estimation_MachineProductivity.py +++ b/B09_Estimation/B09_Estimation_MachineProductivity.py @@ -50,6 +50,9 @@ _KEY_CYCLE = ("㎝(sec)", "cm(sec)", "cm", "㎝") #: 품셈 표의 기계 이름 → 기종 카탈로그 이름. **표기만 다르고 같은 기종**이다. #: 「유압식백호우」는 카탈로그에 없어 그대로 두면 장비 몫이 통째로 빠진다. #: ⚠ 넓게 잡지 않는다 — 이름 전체가 이 표의 열쇠와 같을 때만 바꾼다. +#: ⚠ **별칭표(`common_util_aliases`) 대상이 아니다** — 품셈 전 장에서 같은 기종을 달리 적는 +#: **카탈로그 이름 정규화**라 범위(scope)가 없다(2026-09-13 브레인 판정). 규격·형식은 안 바꾸고 +#: 이름 머리만 맞추므로, 형식이 둘인 기종은 여전히 「규격 미정」으로 남는다. MACHINE_NAME_ALIASES = { "유압식백호우": "굴착기", "백호우": "굴착기", diff --git a/B09_Estimation/B09_Estimation_ResourceAxis_Transposed.py b/B09_Estimation/B09_Estimation_ResourceAxis_Transposed.py index d1707a1c..dde9430b 100644 --- a/B09_Estimation/B09_Estimation_ResourceAxis_Transposed.py +++ b/B09_Estimation/B09_Estimation_ResourceAxis_Transposed.py @@ -29,7 +29,10 @@ from B09_Estimation.B09_Estimation_ResourceAxis import ( ResourceCatalog, ResourceRow, UnmatchedRow, + _tidy_resource_name, + is_non_resource_label, parse_amount, + parse_machine_cell, split_name_and_spec, ) @@ -210,6 +213,8 @@ _RE_PACKED_NUMBER = re.compile(r"\d+(?:\.\d+)?") #: 「1.04(1.17)」의 괄호 값 — **조건 시공 시 대안값**이라 기본 수에 안 센다 #: (품셈 13-6-1 [주]②). 안 떼면 값이 하나 더 있는 것으로 보여 표가 통째로 버려진다. _RE_ALTERNATIVE_TAIL = re.compile(r"(?<=\d)\s*[((]\s*\d+(?:\.\d+)?\s*[))]") +#: 「그 갈래엔 없음」 표시 칸. +_ABSENT_MARKS = frozenset({"-", "–", "·", "ㆍ", "-"}) def _packed_numbers(cell: str) -> list: @@ -235,11 +240,26 @@ def _resolve_packed(catalog: ResourceCatalog, name: str, specs: list[str] | None parts = [part.strip() for part in str(name).split("+") if part.strip()] found = [] for part in parts: + # ⚠ 행 읽기 길과 **같은 표기 맞추기**를 먼저 한다 — 이 길만 「굴 삭 기 (무한궤도)」를 + # 「굴착기(무한궤도)」로 못 바꿔 큰돌쌓기 메쌓기(13-6-1) 장비 몫이 막혀 있었다(09-13). + # ⚠ **기종 이름이 실제로 바뀌고 형식(괄호)을 적었을 때만** 쓴다 — 공백만 지우면 + # 「부착용 집게」가 카탈로그 앞머리 비교에서 빠져 돌쌓기(장비) 넷이 막혔고(같은 날 대조로 + # 잡음), 형식 없는 「굴 삭 기」까지 바꾸면 무한궤도 **잠정** 우선순위로 새 단가가 선다 + # (판정 Ⓒ 「형식이 둘이면 규격 미정」과 어긋남). + tidied = _tidy_resource_name(part) + if _normalize_label(tidied) != _normalize_label(part) and "(" in tidied: + part = tidied base, spec = split_name_and_spec(part) entry = catalog.resolve(base, spec) if entry is None and not spec: candidates = catalog.by_name(base) entry = candidates[0] if len(candidates) == 1 else None + if entry is None: + # 갈래를 이름에 품은 기종(「굴착기(무한궤도)」)은 **그 이름째** 옆 칸 규격으로 먼저 — + # 앞머리 「굴착기」로만 보면 무한궤도·타이어가 둘 다 걸려 잠정 우선순위에 기댄다. + machine_name, _ = parse_machine_cell(part) + if machine_name != base: + entry = _resolve_with_side_spec(catalog, machine_name, specs or []) if entry is None: entry = _resolve_with_side_spec(catalog, base, specs or []) if entry is None: @@ -370,6 +390,10 @@ def match_packed_rows( break continue + # 비고·합계 줄은 자원이 아니다 — 문장 속 수(「100m」·「30%」)를 값으로 보고 「못 풀었다」며 + # 공종을 막고 있었다(단목베기 4-2-2 「비고」, 2026-09-13). 행 읽기 길과 같은 거름을 쓴다. + if is_non_resource_label(cells[0]): + continue # 규격은 **옆 칸**에 있을 수 있다 — 「굴착기+부착용 집게 | 0.6㎥ | 시간 | …」. side_specs = [cell for cell in cells[1:3] if cell] # ⚠ **칸 전체를 한 이름으로 먼저 본다** — 「굴착기 (무한궤도)」처럼 이름 안에 @@ -395,7 +419,12 @@ def match_packed_rows( ) continue - groups = [_packed_numbers(cell) for cell in cells[1:]] + # ⚠ 「-」·「·」 칸은 **그 갈래에 품이 없다**는 표시다 — 빈칸처럼 버리면 갈래 수가 모자라 + # 표째 버려진다(산림복원용 흙막이 13-13-2 「특별인부 0.014 | -」). 자리만 지키고 안 셈. + groups = [ + _packed_numbers(cell) or ([None] * len(names) if cell in _ABSENT_MARKS else []) + for cell in cells[1:] + ] groups = [group for group in groups if group] # 앞쪽에 규격·단위 칸이 낄 수 있다 — 「0.6㎥ | 시간 | 0.31 | 0.30 | 0.28」. # 갈래 수만큼 **뒤에서** 잘라 쓴다. @@ -417,6 +446,8 @@ def match_packed_rows( for label, group in zip(labels, groups): for entries, amount in zip(resolved, group): + if amount is None: + continue # 「-」 — 그 갈래에는 이 자원이 안 든다 value = amount if basis_quantity not in (None, 0, Decimal(1)): value = value / basis_quantity 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 c81f89d6..447b4533 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 @@ -125,6 +125,28 @@ "basis_source": "산림사업 표준품셈(고시 2025-82) 12-25 기초잡석 「(단위: ㎥당)」.", "master_name": "기초잡석", "note": "품셈은 운반·부설·다짐 품만 주고 **두께·폭을 정하지 않는다.** 폭은 버림 폭과 같고(KCS 34 50 05) 두께는 사용자 확정 3차 ②(0.2m)다 — 화면에서 바꿀 수 있다." + }, + { + "group": "지장목제거", + "item": "잡관목제거", + "work_item_code": "FP-04-02-02", + "master_name": "단목베기 > 1,000㎡당", + "basis_unit": "㎡", + "basis_source": "산림사업 표준품셈(고시 2025-82) 4-2-2 절 이름 「1,000㎡당」 — ⚠ 표 안·절 머리에 「(단위: …)」가 없어 마스터 밑수가 비어 있음(basis_missing F0087). B09 가 밑수 미확보로 막으므로 1,000배 틀린 금액은 안 섬.", + "variant_axis": "tree_height", + "variant_value": "5m 미만", + "note": "지장목제거 = 벌목(4장) + 제근(9-20~21) 단계 합산(2026-09-13 브레인 판정 · 원가_출력변수_사전.md:55). 실무 영월 1.9.2 「잡관목제거 벌목(5m미만)」이 단목베기 1,000㎡당 5m 미만 갈래와 같은 이름·같은 단위(㎡)." + }, + { + "group": "지장목제거", + "item": "뿌리뽑기", + "work_item_code": "FP-09-21", + "master_name": "토공 > 제근", + "basis_unit": "㎡", + "basis_source": "⚠ 교차 참조 — 산림품셈 9-21 표에 밑수 표기가 없어 건설공사 표준품셈 3-9-2 뿌리뽑기 「1,000㎡당」 면적 축을 빌려 씀(사용자 확정 5차 6번). 마스터 밑수는 비어 있음(basis_missing F0294).", + "variant_axis": "stand_volume_class", + "variant_from": "stand_volume_class", + "note": "2026-09-13 브레인 판정 Ⓑ — 제근은 토공 줄(토공_수량.md:30 「뿌리다듬기·적재·제근 | 9-20~21 | 벌개제근 연동」)이라 여기서 셈. 준비공 「제근·뿌리다듬기」는 같은 면적이라 참조로만 보임(이중계상 막이). 품은 임목축적 등급(소림·중림·밀림, 9-21 [주]①)으로 갈려 등급을 갈래로 넘김." } ], "haul": [ @@ -235,15 +257,6 @@ "pending_user": { "note": "이름이 비슷한 후보는 있으나 **어느 것인지 정할 근거가 없는** 자리. 임의로 고르지 않고 unmatched 로 낸다(CLAUDE.md 3장).", "items": [ - { - "group": "지장목제거", - "candidates": [ - "FP-04-01 수확베기", - "FP-04-02 단목베기", - "FP-04-03 위험목 베기" - ], - "why": "품셈 4장은 벌목을 목적별로 가르는데 임도 지장목이 어느 쪽인지 원본이 말하지 않음" - }, { "group": "흙깎기/측구터파기 암", "candidates": [ diff --git a/resources/tester/test_b08_handoff.py b/resources/tester/test_b08_handoff.py index c0bf957f..8441bb1d 100644 --- a/resources/tester/test_b08_handoff.py +++ b/resources/tester/test_b08_handoff.py @@ -94,7 +94,8 @@ def test_구조물은_한_줄로_서고_전개_성분은_안_옴() -> None: assert len(placing) == 1 # 그 줄의 물량은 **버림 콘크리트 몫뿐**이어야 한다 — 채움이 섞이면 이중계상이다. blinding = next( - c for c in build_unit_table([구조물()])["structures"][0]["components"] + c + for c in build_unit_table([구조물()])["structures"][0]["components"] if c["name"] == "버림콘크리트" ) assert placing[0]["quantity"] == pytest.approx(blinding["amount"], abs=1e-6) @@ -249,7 +250,11 @@ def test_매핑_파일이_없으면_전부_드러남() -> None: def test_정할_근거가_없는_자리는_지어내지_않고_알림() -> None: - """지장목제거·암 갈래는 후보만 있고 고를 근거가 없다 — 임의 확정 금지(3장).""" + """암 갈래는 고를 근거가 없다 — 임의 확정 금지(3장). + + ⭐ 2026-09-13 판정으로 지장목제거는 작업 갈래(뿌리뽑기 9-21 · 잡관목제거 4-2-2)로 이어졌다 — + **작업 갈래가 없는** 지장목제거 줄만 여전히 못 잇는다. + """ handoff = build_handoff(summary_table=집계표(집계줄("지장목제거", amount=100.0))) assert handoff["work_items"][0]["work_item_code"] is None assert "지장목제거" in handoff["unmatched_work_items"] @@ -259,7 +264,7 @@ def test_정할_근거가_없는_자리는_지어내지_않고_알림() -> None: item.get("group") or item.get("type_id") for item in handoff["mapping_pending_user"]["items"] ] - assert "지장목제거" in keys + assert "지장목제거" not in keys # 판정으로 닫힘(2026-09-13) # ⚠ 큰돌쌓기는 **닫혔다** — 랩탑이 `bond`(메쌓기/찰쌓기) 칸을 만들어 자동으로 갈린다. # 미결 목록에서 빠졌는지도 함께 본다(고쳐졌는데 목록만 남는 것 방지). assert "boulder_masonry" not in keys @@ -954,7 +959,9 @@ def test_검사가_실제로_잡는다_일부러_깨뜨려_봄(): handoff = build_handoff(unit_quantity_table=build_unit_table([구조물()])) handoff["materials"].append({"material_name": "가짜자재", "work_item_code": "FP-99-99"}) assert verify_no_code_on_materials(handoff) == ["가짜자재"] - handoff["work_items"].append({"name": "코드없이내역에선줄", "in_bill": True, "work_item_code": None}) + handoff["work_items"].append( + {"name": "코드없이내역에선줄", "in_bill": True, "work_item_code": None} + ) assert "코드없이내역에선줄" in verify_bill_flags(handoff) @@ -983,7 +990,12 @@ def test_타설_줄이_실제로_선다() -> None: _콘크리트구조물( [ {"name": "콘크리트", "unit": "㎥", "amount": 13.5, "destination": "unit_price"}, - {"name": "이형철근 D13", "unit": "kg", "amount": 134.5, "destination": "material"}, + { + "name": "이형철근 D13", + "unit": "kg", + "amount": 134.5, + "destination": "material", + }, ] ) ] @@ -999,7 +1011,13 @@ def test_타설_줄이_실제로_선다() -> None: def test_방식을_안_정하면_기본값으로_서되_알린다() -> None: - unit = {"structures": [_콘크리트구조물([{"name": "콘크리트", "unit": "㎥", "amount": 2.0, "destination": "unit_price"}])]} + unit = { + "structures": [ + _콘크리트구조물( + [{"name": "콘크리트", "unit": "㎥", "amount": 2.0, "destination": "unit_price"}] + ) + ] + } handoff = build_handoff(unit_quantity_table=unit) 타설 = next(r for r in handoff["work_items"] if r["name"] == "콘크리트 타설") assert 타설["work_item_code"] == "FP-12-01-01" # 기본값 = 레디믹스트 @@ -1008,7 +1026,13 @@ def test_방식을_안_정하면_기본값으로_서되_알린다() -> None: def test_방식을_바꾸면_공종이_갈린다() -> None: - unit = {"structures": [_콘크리트구조물([{"name": "콘크리트", "unit": "㎥", "amount": 2.0, "destination": "unit_price"}])]} + unit = { + "structures": [ + _콘크리트구조물( + [{"name": "콘크리트", "unit": "㎥", "amount": 2.0, "destination": "unit_price"}] + ) + ] + } 코드 = {} for method in ("ready_mixed", "machine_mixed", "hand_mixed"): handoff = build_handoff(unit_quantity_table=unit, concrete_placing_method=method) @@ -1024,7 +1048,13 @@ def test_방식을_바꾸면_공종이_갈린다() -> None: def test_콘크리트가_없으면_타설_줄도_없다() -> None: """0 ㎥ 짜리 빈 줄을 만들지 않는다.""" - unit = {"structures": [_콘크리트구조물([{"name": "야면석", "unit": "ton", "amount": 3.0, "destination": "material"}])]} + unit = { + "structures": [ + _콘크리트구조물( + [{"name": "야면석", "unit": "ton", "amount": 3.0, "destination": "material"}] + ) + ] + } handoff = build_handoff(unit_quantity_table=unit, concrete_placing_method="ready_mixed") assert not [r for r in handoff["work_items"] if r["name"] == "콘크리트 타설"] @@ -1053,7 +1083,9 @@ def test_개소_구조물은_연장으로_안_센다() -> None: 성분은 개소 기준으로 맞게 서는데 **줄의 축만** 어긋나 있어 아무 시험도 안 잡았다. """ - handoff = build_handoff(unit_quantity_table=build_unit_table([_집수정(2.0)], {"pipe_inlet_basin": "집수정"})) + handoff = build_handoff( + unit_quantity_table=build_unit_table([_집수정(2.0)], {"pipe_inlet_basin": "집수정"}) + ) 줄 = next(r for r in handoff["work_items"] if r["name"] == "집수정") assert (줄["unit"], 줄["quantity"]) == ("개소", 1.0) @@ -1063,7 +1095,9 @@ def test_개소_구조물은_연장이_길어도_한_개소() -> None: 긴것 = build_unit_table([_집수정(5.0)], {"pipe_inlet_basin": "집수정"}) for table in (한개, 긴것): 줄 = next( - r for r in build_handoff(unit_quantity_table=table)["work_items"] if r["name"] == "집수정" + r + for r in build_handoff(unit_quantity_table=table)["work_items"] + if r["name"] == "집수정" ) assert (줄["unit"], 줄["quantity"]) == ("개소", 1.0) @@ -1080,7 +1114,9 @@ def test_m당_구조물은_그대로_연장으로_센다() -> None: } 줄 = next( r - for r in build_handoff(unit_quantity_table=build_unit_table([옹벽], {"retaining_wall": "옹벽"}))["work_items"] + for r in build_handoff( + unit_quantity_table=build_unit_table([옹벽], {"retaining_wall": "옹벽"}) + )["work_items"] if r["name"] == "옹벽" ) assert (줄["unit"], 줄["quantity"]) == ("m", 20.0) @@ -1120,7 +1156,10 @@ def test_못_내는_줄도_사유와_함께_간다() -> None: # ⭐ 2026-09-09 확정 5차 6번으로 **밑수가 면적 축으로 정해져 값이 선다** — 그래서 이 줄은 # 더 이상 「못 내는 줄」의 표본이 아니다. 「못 내는 줄도 사유와 함께 간다」는 계약 자체는 # 아래 표토·부대시설 줄이 지킨다. - assert 제근["in_bill"] is True and 제근["quantity"] > 0 + # ⭐ 2026-09-13 판정 Ⓑ — 셈은 토공집계 뿌리뽑기(FP-09-21)가 하고 이 줄은 **보이되 안 실린다**. + assert 제근["in_bill"] is False and 제근["quantity"] > 0 + assert 제근["work_item_code"] is None and 제근["blocked_kind"] is None + assert "토공집계" in 제근["in_bill_reason"] assert 제근["unit"] == "㎡" # 밑수가 면적 축으로 확정됨(확정 5차 6번) # ⚠ 사유가 **두 번 바뀐 자리**다 — ㉠ 「입목 본수가 없다」(틀린 말: 본수 축이 아니었다) # ㉡ 「밑수 단위가 원문에 없다」(사실이었으나 확정으로 닫힘) ㉢ 지금은 **값이 서고** @@ -1173,7 +1212,14 @@ def test_묶음이_없는_종류는_그대로_타설_줄이_선다() -> None: unit_quantity_table={ "structures": [ _콘크리트구조물( - [{"name": "콘크리트", "unit": "㎥", "amount": 2.84, "destination": "unit_price"}] + [ + { + "name": "콘크리트", + "unit": "㎥", + "amount": 2.84, + "destination": "unit_price", + } + ] ) ] }, @@ -1203,9 +1249,7 @@ def test_연장으로_서는_공종은_전개식이_없어도_안_막힌다() -> 앞서 「성분이 없으면 전개식 없음」으로 단정해 B09 가 「우리가 만들 것」으로 빼 **금액이 0** 이었다(실측: 맹암거 40m 이 0원 → 고친 뒤 1,019,685원). """ - handoff = build_handoff( - unit_quantity_table={"structures": [_B군("underdrain", "맹암거")]} - ) + handoff = build_handoff(unit_quantity_table={"structures": [_B군("underdrain", "맹암거")]}) 줄 = next(r for r in handoff["work_items"] if r["name"] == "맹암거") assert 줄["work_item_code"] == "FP-12-10" assert 줄["blocked_kind"] is None, 줄["blocked_reason"] @@ -1283,8 +1327,16 @@ def test_겹치지_않으면_원합을_안_싣는다() -> None: def test_공종을_못_이은_B군은_사유와_함께_막힌다() -> None: - 표 = [{"type_id": "chute", "group": "B", "name": "도수로·산비탈수로", - "count": 1, "length_m": 40.0, "raw_length_m": 40.0}] + 표 = [ + { + "type_id": "chute", + "group": "B", + "name": "도수로·산비탈수로", + "count": 1, + "length_m": 40.0, + "raw_length_m": 40.0, + } + ] 줄 = build_handoff(length_table=표)["work_items"][0] assert 줄["work_item_code"] is None assert 줄["in_bill"] is False diff --git a/resources/tester/test_b08_preparation.py b/resources/tester/test_b08_preparation.py index 4581ebd7..bc7b1b04 100644 --- a/resources/tester/test_b08_preparation.py +++ b/resources/tester/test_b08_preparation.py @@ -110,10 +110,10 @@ def test_셈이_맞을것() -> None: # 사용자가 무엇을 정할지 안다(원단위 미확보에서 「표에 있는 규격을 함께 알린」 그 방식). -def test_지장목제거_줄에_공종_미확정_사유가_적힐것() -> None: +def test_지장목제거_줄에_공종_자리가_적힐것() -> None: + """⭐ 2026-09-13 판정 — 잡관목제거는 단목베기(4-2-2), 뿌리뽑기는 제근(9-21).""" row = 줄(build_table(), "벌목·지장목제거") - assert "공종 미확정" in row["reason"] - assert "수확베기" in row["reason"] # 후보가 무엇인지도 함께 보인다 + assert "FP-04-02-02" in row["reason"] and "FP-09-21" in row["reason"] # ── 규준틀 개소 — 원문이 기준을 정해 둠 (2026-09-07 ㉒) ──────────── diff --git a/resources/tester/test_b08_tree_removal_split.py b/resources/tester/test_b08_tree_removal_split.py index 159a6513..7ff941fb 100644 --- a/resources/tester/test_b08_tree_removal_split.py +++ b/resources/tester/test_b08_tree_removal_split.py @@ -44,7 +44,11 @@ def 집계() -> dict: def 줄들() -> list[dict]: - return [row for row in build_handoff(summary_table=집계())["work_items"] if row["name"] == "지장목제거"] + return [ + row + for row in build_handoff(summary_table=집계())["work_items"] + if row["name"] == "지장목제거" + ] def test_두_줄이_선다() -> None: @@ -59,7 +63,9 @@ def test_같은_면적이_두_줄에_들어간다() -> None: def test_이중계상이_아님이_사유에_적힌다() -> None: for row in 줄들(): - assert "이중계상 아님" in row["in_bill_reason"] or "이중계상 아님" in str(row["spec_detail"]) + assert "이중계상 아님" in row["in_bill_reason"] or "이중계상 아님" in str( + row["spec_detail"] + ) def test_작업_갈래를_지반_갈래로_읽지_않는다() -> None: @@ -67,11 +73,21 @@ def test_작업_갈래를_지반_갈래로_읽지_않는다() -> None: for row in 줄들(): assert row["ground_class"] is None unmatched = build_handoff(summary_table=집계())["unmatched_work_items"] - assert "지장목제거" in unmatched - assert not any("지장목제거(" in item for item in unmatched) + assert not any("지장목제거" in item for item in unmatched) # 두 작업 갈래 모두 이어짐 -def test_잡관목제거는_품셈에_이름이_없음이_남는다() -> None: +def test_잡관목제거는_단목베기_5m_미만으로_이어진다() -> None: + """⭐ 2026-09-13 판정 — 지장목제거는 품셈에 있다(벌목 4장 + 제근 9-20~21).""" 잡관목 = next(row for row in 줄들() if row["spec"] == "잡관목제거") - 사유 = str(잡관목["spec_detail"]) + str(잡관목["in_bill_reason"]) - assert "품셈에 그 이름이 없어" in 사유 + assert 잡관목["work_item_code"] == "FP-04-02-02" + assert 잡관목["variant_value"] == "5m 미만" + 뿌리뽑기 = next(row for row in 줄들() if row["spec"] == "뿌리뽑기") + assert 뿌리뽑기["work_item_code"] == "FP-09-21" + assert 뿌리뽑기["variant_axis"] == "stand_volume_class" + + +def test_뿌리뽑기는_임목축적_등급을_갈래로_싣는다() -> None: + """⭐ 판정 Ⓑ — 품이 등급(소림·중림·밀림)으로 갈려 갈래를 잃으면 안 된다.""" + rows = build_handoff(summary_table=집계(), stand_volume_class="중림")["work_items"] + 뿌리뽑기 = next(r for r in rows if r["name"] == "지장목제거" and r["spec"] == "뿌리뽑기") + assert 뿌리뽑기["variant_value"] == "중림" diff --git a/resources/tester/test_b09_prep_handoff_bill.py b/resources/tester/test_b09_prep_handoff_bill.py index ab264508..462a9018 100644 --- a/resources/tester/test_b09_prep_handoff_bill.py +++ b/resources/tester/test_b09_prep_handoff_bill.py @@ -54,4 +54,6 @@ def test_금액이_서는_줄은_수량째_본체에_선다() -> None: in_bill = [ r["name"] for r in handoff["work_items"] if r["origin"] == "preparation" and r["in_bill"] ] - assert {"표토제거", "제근·뿌리다듬기", "뿌리 적재"} <= set(in_bill) + assert {"표토제거", "뿌리 적재"} <= set(in_bill) + # ⭐ 2026-09-13 판정 Ⓑ — 제근은 토공집계 뿌리뽑기가 셈. 준비공 줄은 참조(제외 목록)로 간다. + assert "제근·뿌리다듬기" not in in_bill