"""표 읽기 (2026-09-13 축 C 1장 · 브레인 차례 ②). 지키는 것 ① 밑수가 **절 이름에만** 있는 표는 이름 전체가 「수+단위+당」일 때만 뽑는다(4-2-2 「1,000㎡당」) ② 뭉친 줄: 「굴 삭 기 (무한궤도)」 표기 맞추기 · 비고 줄 거름 · 「-」 칸은 그 갈래에 품 없음 ③ 조합 기종 「굴착기+부착용집게」는 공백이 달라도 카탈로그 「부착용 집게」에 붙는다 ④ 형식을 안 적은 「굴 삭 기」는 잠정 우선순위로 고르지 않는다(판정 Ⓒ) ⑤ 실무값 대조 — 단목베기 5m 미만이 영월 설계내역 「잡관목제거 벌목(5m미만)」 882원/㎡ 근처로 선다 """ from __future__ import annotations from decimal import Decimal from B08_Quantity.B08_Quantity_Build_WorkItemMaster import basis_from_name from B09_Estimation.B09_Estimation_ResourceAxis import ( AxisResult, CatalogEntry, ResourceCatalog, match_table, ) def test_절_이름_밑수는_이름_전체가_밑수일_때만(): assert basis_from_name("1,000㎡당") == (1000.0, "㎡") assert basis_from_name("100본당") == (100.0, "본") assert basis_from_name("나무주사(100본당)") == (None, None) assert basis_from_name("자재의 1회당 표준 운반량") == (None, None) def _packed(*rows: list[str]) -> dict: return { "pum_table_id": "T-시험", "pum_form": "requirement", "basis_quantity": 10.0, "basis_unit": "㎡", "raw_row": [list(row) for row in rows], } MACHINES = [ CatalogEntry("0201-0080", "굴착기(무한궤도)", "machine", "0.8"), CatalogEntry("0211-0080", "굴착기(타이어)", "machine", "0.8"), CatalogEntry("0201-0020", "굴착기(무한궤도)", "machine", "0.2"), CatalogEntry("7206-0020", "부착용 집게", "machine", "0.2"), CatalogEntry("7206-0100", "부착용 집게", "machine", "1.0"), CatalogEntry("1002", "보통인부", "labor"), CatalogEntry("1037", "벌목부", "labor"), CatalogEntry("1003", "특별인부", "labor"), ] def _codes(result: AxisResult, variant: str) -> dict[str, Decimal]: return {r.resource_code: r.amount for r in result.rows if r.variant == variant} def test_뭉친_줄_굴삭기_표기를_맞추고_비고는_거른다(): table = _packed( ["직경 40㎝이상 ∼60㎝미만", "직경 60㎝이상 ∼80㎝미만"], ["보통인부", "", "인", "1.04(1.17)", "1.08(1.22)"], ["굴 삭 기 (무한궤도)", "0.8㎥", "h", "3.84", "3.52"], ["비고", "- 본 품의 집재거리는 100m까지를 기준하며 매 100m 증가마다 30%씩 가산한다."], ) result = AxisResult() match_table({"work_item_code": "FP-13-06-01"}, table, ResourceCatalog(entries=MACHINES), result) first = _codes(result, "직경 40㎝이상 ∼60㎝미만") assert first["0201-0080"] == Decimal("0.384") # 무한궤도 — 이름에 형식이 적혀 있음 assert "0211-0080" not in first assert "FP-13-06-01" not in result.partial_items # 비고 줄로 막히지 않음 def test_조합_기종은_공백이_달라도_붙는다(): table = _packed( ["5m 미만", "5m이상~8m미만"], ["벌목부 보통인부", "", "인 인", "2.14 0.51", "2.80 0.66"], ["굴착기+부착용집게", "0.2㎥", "hr", "2.71", "3.54"], ) result = AxisResult() match_table({"work_item_code": "FP-04-02-02"}, table, ResourceCatalog(entries=MACHINES), result) assert set(_codes(result, "5m 미만")) >= {"0201-0020", "7206-0020"} assert "FP-04-02-02" not in result.partial_items def test_형식_없는_굴삭기와_빈칸_표시(): table = _packed( ["식생매트설치", "복 토"], ["특 별 인 부", "", "인", "0.014", "-"], ["굴 삭 기", "0.6 ㎥", "시간", "-", "0.031"], ) result = AxisResult() match_table({"work_item_code": "FP-13-13-02"}, table, ResourceCatalog(entries=MACHINES), result) # 「-」 — 복토 갈래에는 특별인부가 안 든다(자리만 지킴) assert _codes(result, "식생매트설치") == {"1003": Decimal("0.0014")} # 형식이 없는 굴삭기는 무한궤도·타이어 중 고르지 않는다 → 막고 사유를 남긴다 assert "굴 삭 기" in result.partial_items.get("FP-13-13-02", "") def test_단목베기_5m_미만이_실무값_근처로_선다(): from B09_Estimation.B09_Estimation_UnitPrice import build_unit_prices build = build_unit_prices() assert "FP-04-02-02" not in build.partial_ratio # 합판거푸집 — 2026-09-14 사용횟수 갈래로 풀림(`test_b09_formwork_use_count`) · 막힘 사유 없음 assert "FP-12-04" not in build.component_gaps total = build.book.resolve("B-FP-04-02-02#5m미만").total # 영월 설계내역 1.9.2 「잡관목제거 벌목(5m미만)」 @882 — 노임 연도 차이로 ±5 % 안이면 같은 읽기 assert Decimal("838") <= total <= Decimal("926"), total def test_단위_글자_없는_분모형_밑수도_본문에서_읽는다(): """「(인/100m당)」 — 「단위:」 없이 분모만 적은 22 곳(2026-09-13). 「인」은 밑수가 아니다.""" from B08_Quantity.B08_Quantity_Build_WorkItemMaster import basis_from_source assert basis_from_source(["(인/100m당)", "", "| 공정별 |"], 3) == (100.0, "m") assert basis_from_source(["(인/본당)", "| 규격 |"], 2) == (1.0, "본") assert basis_from_source(["(무한궤도/0.7㎥)", "| 규격 |"], 2) == (None, None) # 「당」 없음 _단끊기 = { "pum_table_id": "F-단끊기", "pum_form": "requirement", "basis_quantity": 100.0, "basis_unit": "m", "condition_note": ["공정별", "보통인부(인)", "비고"], "raw_row": [ ["보통토사", "경질ㆍ고사점토 및 자갈섞인 점토", "호박돌 섞인 토사", "", ""], ["절 취", "2.4", "3.3", "5.4", ""], ["수평잡기 및 단정리", "0.34", "0.34", "0.34", ""], ["잡석 및 뿌리 정리", "0.36", "0.36", "0.36", ""], ["절·성토면 고르기", "1.17", "1.17", "1.17", ""], ["합계", "2.03", "2.09", "2.23", ""], ], } def test_단끊기는_공정_줄을_더하고_작업반장을_20인당_1인_더한다(): catalog = ResourceCatalog(entries=[*MACHINES, CatalogEntry("1001", "작업반장", "labor")]) result = AxisResult() match_table({"work_item_code": "FP-05-16-01"}, _단끊기, catalog, result) assert _codes(result, "보통토사") == {"1002": Decimal("0.0427"), "1001": Decimal("0.002135")} assert _codes(result, "호박돌 섞인 토사")["1002"] == Decimal("0.0727") assert "FP-05-16-01" not in result.partial_items # 적어 두지 않은 공종은 같은 모양이어도 더하지 않는다(뭉기기 13-12-1 은 줄마다 단위가 다름) other = AxisResult() match_table({"work_item_code": "FP-13-12-01"}, _단끊기, catalog, other) assert not other.rows _STONE = [*MACHINES, CatalogEntry("1033", "석공", "labor"), CatalogEntry("1019", "포장공", "labor")] def test_인력_찰쌓기_두_단_머리는_hwpx_병합대로_돌과_쌓기를_가른다(): """앞 세 돌은 골·켜 두 잎씩, 호박돌 및 야면석은 한 잎(행 병합) — 7 잎 × 석공·보통인부.""" table = { "pum_table_id": "F-찰쌓기", "pum_form": "requirement", "basis_quantity": 1.0, "basis_unit": "㎡", "condition_note": ["뒷길이 (㎝)", "견 치 돌", "깬 돌", "깬 잡 석", "호박돌 및 야면석"], "raw_row": [ ["골쌓기", "켜쌓기", "골쌓기", "켜쌓기", "골쌓기", "켜쌓기", "", ""], ["석 공 (인)", "보통인부 (인)"] * 7 + [""], ["25", *["-"] * 8, "0.12", "0.12", "0.10", "0.10", "0.08", "0.10"], ["35", "0.40", "0.40", "0.44", "0.44", "0.24", "0.24", "0.22", "0.22"] + ["0.20", "0.20", "0.18", "0.18", "0.11", "0.14"], ], } result = AxisResult() match_table({"work_item_code": "FP-13-04-04"}, table, ResourceCatalog(entries=_STONE), result) assert _codes(result, "견치돌 · 켜쌓기 · 뒷길이 35㎝") == { "1033": Decimal("0.44"), "1002": Decimal("0.44"), } assert _codes(result, "호박돌 및 야면석 · 뒷길이 35㎝") == { "1033": Decimal("0.11"), "1002": Decimal("0.14"), } assert not _codes(result, "견치돌 · 골쌓기 · 뒷길이 25㎝") # 「-」 — 그 뒷길이엔 품이 없음 assert "FP-13-04-04" not in result.partial_items def test_콘크리트_포장_작업조는_두께마다_시공량으로_나눈다(): """12-6 — 두께 칸이 작업조 칸과 엇갈려 병합(hwpx) · 후진 진입 칸은 「50%까지 감」 범위.""" table = { "pum_table_id": "F-포장", "pum_form": "reference", "condition_note": ["배치인원(인)", "포장두께", "시공량(㎥)"], "raw_row": [ [ "콘크리트믹서트럭 직접타설인경우", "콘크리트믹서트럭 후진 진입 또는 경운기 등으로 운반인 경우", ], ["포장공", "3", "20㎝", "100", "좌측 시공량의 50%까지 감하여 적용한다."], ["30㎝", "150", "", "", ""], ["보통인부", "3", "", "", ""], ["40㎝", "200", "", "", ""], ], } result = AxisResult() match_table({"work_item_code": "FP-12-06"}, table, ResourceCatalog(entries=_STONE), result) assert _codes(result, "20㎝") == {"1019": Decimal("0.03"), "1002": Decimal("0.03")} assert _codes(result, "40㎝")["1019"] == Decimal("0.015") assert {r.amount_unit for r in result.rows} == {"㎥"} # 시공량 열 머리의 단위 def test_사람이_가른_표_형태는_까닭을_달고_절_번호가_어긋나면_멈춘다(): """미판정 표 판정(2026-09-13) — 못 가른 표는 싣지 않고, 표 차례가 밀리면 조용히 안 씀.""" import json from pathlib import Path import pytest from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Forms import FORM_JUDGMENTS, judged_form assert judged_form("F0413", "13-4-4")[0] == "reference" assert judged_form("F9999", "1-1") is None with pytest.raises(ValueError): judged_form("F0413", "13-4-5") assert all(why for _, _, why in FORM_JUDGMENTS.values()) # 마스터에 실렸고, 파형강관 12-11-3 처럼 못 가른 표는 미판정으로 남는다 path = Path("resources/data_work_item_master/work_item_master_2026-01-01.json") tables = { t["pum_table_id"]: t for n in json.loads(path.read_text(encoding="utf-8"))["work_items"] for t in n["tables"] } assert tables["F0413"]["pum_form"] == "reference" assert tables["F0189"]["pum_form"] == "productivity" assert tables["F0349"]["pum_form"] == "undetermined" def test_인력_찰쌓기는_표준경사_표에_막히지_않는다(): from B09_Estimation.B09_Estimation_UnitPrice import cached_build build = cached_build() assert "FP-13-04-04" not in build.partial_ratio assert build.book.resolve("B-FP-13-04-04#깬돌·골쌓기·뒷길이45㎝").labor > 0