"""표 읽기 ②(2026-09-14 · PLAN 1장) — 「못 붙은 줄」 로 잘못 서던 자리를 **상태가 움직이는지**로 잼. 9-21 제근 고르기형 표 안내 글이 `unmatched` 에 들어가 못 붙은 줄로 섬 — 밑수는 사용자 확정 (건설품셈 1,000㎡당 빌림)이라 안내는 비고(`known_gap_note`)로 옮김(사유는 안 사라짐) 12-2 표면 마무리 원본이 12-17-1 [주] 문장을 절 제목으로 읽어 표 7장·가짜 갈래 #설치·#철거 가 붙음 """ from __future__ import annotations from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note from B09_Estimation.B09_Estimation_ResourceAxis import load_work_item_master from B09_Estimation.B09_Estimation_UnitPrice import cached_build def test_제근은_못_붙은_줄이_아니고_교차참조_사유는_비고에_남음() -> None: build = cached_build() assert not build.unattached.get("FP-09-21"), build.unattached.get("FP-09-21") assert sum(1 for code in build.book.titles if code.startswith("B-FP-09-21#")) == 6 note = known_gap_note("FP-09-21") assert "교차 참조" in note and "1,000㎡당" in note def test_표면_마무리엔_미장공_표만_있고_가짜_갈래가_없음() -> None: nodes = {n["work_item_code"]: n for n in load_work_item_master()["work_items"]} assert [t["pum_table_id"] for t in nodes["FP-12-02"]["tables"]] == ["F0334"] assert "F0356" in [t["pum_table_id"] for t in nodes["FP-12-17-01"]["tables"]] build = cached_build() assert [c for c in build.book.titles if c.startswith("B-FP-12-02")] == ["B-FP-12-02"] assert not build.unattached.get("FP-12-02") def test_육상_발파암_1_3m_들어내기는_0_1m_로_읽고_고른_값_버린_값을_사유에() -> None: """③ 원문 「육상 발파암(1~2m)와 동일」(고시 5189·5201줄)은 9-13-14 자기 참조 — 같은 절 암절취· 용수 발파암이 모두 「(0~1m)와 동일」 이라 0~1m(9-13-13)로 읽음(2026-09-14 브레인 승인).""" build = cached_build() for code in ("FP-09-13-14", "FP-09-13-15"): assert code not in build.partial_ratio and code not in build.component_gaps, code # 들어내기 참조 줄은 풀림 — 남는 것은 치즐뿐(기본 조립엔 치즐 단가가 없어 종전대로) left = [label for label in build.unattached.get(code, []) if "치즐" not in label] assert not left, left source = build.factor_sources.get(code, "") assert "육상 발파암(0~1m)" in source and "자기 참조" in source, source def test_비탈면_고르기_발파암_소형브레이커는_1_0_규격으로_코드에_이어짐() -> None: """④ 9-19-3 값 「(2.45+3.05)/2/10㎡」 ← 9-19-1 표 ← [주]① 「소형브레이커는 1㎥/분」 — 값의 출처가 규격을 정함(2026-09-14 브레인 승인). 범위 별칭이 **코드로** 곧장 이음(이름만 돌려주면 규격 후보 넷이 다시 흐려짐). 「어어호스」 는 원문 오기 + 카탈로그에 호스 없음.""" from B09_Estimation.B09_Estimation_ResourceAxis import ( build_resource_axis, load_combined_catalog, ) axis = build_resource_axis(load_work_item_master(), load_combined_catalog()) rows = [ (r.resource_code, str(r.amount)) for r in axis.rows if r.work_item_code == "FP-09-19-03" ] assert ("5210-0010", "0.275") in rows, rows left = [u.cell for u in axis.unmatched if u.work_item_code == "FP-09-19-03"] assert left == ["어어호스(3/4인치)"], left def test_토사면_고르기는_두_표가_섞여_값이_불완전하다고_먼저_말함() -> None: """⑤ 사유 먼저(2026-09-14 브레인) — 원문은 절토면·성토면 두 표인데 섞여 보통인부 한 줄만 섰었음(조용히 틀린 값). ⑤ 본작업(같은 날)으로 두 표를 갈래로 읽어 「불완전」 사유와 섞인 본표(B-FP-09-19-01)가 함께 걷힘 — 갈래 없는 섞인 값이 다시 서면 빨강.""" assert "불완전" not in known_gap_note("FP-09-19-01") assert "B-FP-09-19-01" not in cached_build().book.titles #: ⑤ 9-19-1 원문 L5414~5436 — (단위: 10㎡당)이라 ㎡당 = 표값 ÷ 10. CUT = "B-FP-09-19-01#절토면·" # 갈래 키는 공백을 지움 FILL = "B-FP-09-19-01#성토면·" def _per_m2(code: str) -> dict[str, float]: """자원 줄(비율 줄 「%」 뺌) → ㎡당 수량.""" from B09_Estimation.B09_Estimation_UnitPrice import detail_of rows = detail_of(cached_build(), code)["rows"] return {str(r["ref_code"]): float(r["quantity"]) for r in rows if r.get("unit") != "%"} def test_토사면_고르기_절토면은_토질_여섯_갈래로_규격까지_이어_섬() -> None: """[주]① 공기압축기 3.5㎥/분 · 소형브레이커 1㎥/분 · 굴착기 0.7㎥ — 범위 별칭이 코드로 이음 (카탈로그 0.7 은 무한궤도뿐). 「·」 는 그 갈래에 없음(칸 자리만 지킴).""" titles = cached_build().book.titles cut = sorted(code for code in titles if code.startswith(CUT)) assert len(cut) == 6, cut sand = _per_m2(CUT + "모래ㆍ사질토ㆍ점토ㆍ점질토") assert sand == {"1002": 0.005, "X-0201-0070": 0.015}, sand soft_rock = _per_m2(CUT + "연암") # 공기압축기 3.5 — 운전경비 표 괄호 딸림값 파서 고침(같은 날)으로 기계 층이 서서 붙음 assert soft_rock == {"1002": 0.046, "X-5205-0035": 0.125, "X-5210-0010": 0.245}, soft_rock from B09_Estimation.B09_Estimation_ResourceAxis import ( build_resource_axis, load_combined_catalog, ) axis = build_resource_axis(load_work_item_master(), load_combined_catalog()) rock = { (r.resource_code, float(r.amount)) for r in axis.rows if r.work_item_code == "FP-09-19-01" and r.variant == "절토면 · 연암" } assert rock == {("1002", 0.046), ("5205-0035", 0.125), ("5210-0010", 0.245)}, rock def test_토사면_고르기_성토면_인력_두_갈래_기계는_형식_두_갈래() -> None: """Ⓒ 둘째 줄이 병합 칸 탓에 한 칸 밀려 옴 — 사람 판정표로만 읽음(일반 보정 금지 · 목재틀흙막이 503만원 전례). 굴착기 0.6㎥ 는 원문이 형식을 안 적음 — 처음(Ⓑ)엔 근거가 없어 안 세웠고, 2026-09-14 브레인 ② 로 무한궤도·타이어 두 갈래를 세움(고르기는 B08 칸 · 제안 무한궤도).""" build = cached_build() assert _per_m2(FILL + "인력·점토또는점질토") == {"1002": 0.019} assert _per_m2(FILL + "인력·모래또는사질토") == {"1002": 0.017} assert _per_m2(FILL + "기계·무한궤도") == {"X-0201-0060": 0.009} assert _per_m2(FILL + "기계·타이어") == {"X-0211-0060": 0.009} left = build.unattached.get("FP-09-19-01", []) assert not any("굴착기" in label for label in left), left def test_범위_별칭은_칸이_규격을_적었으면_덮지_않음() -> None: """Ⓐ 별칭은 빈 곳을 채우는 것 — 같은 공종 성토면 「굴착기 0.6㎥」 가 0.7 로 덮이면 안 됨.""" from B09_Estimation.B09_Estimation_ResourceAxis import load_combined_catalog from B09_Estimation.B09_Estimation_ResourceAxis_Join import ( apply_scoped_alias, scoped_alias_entry, ) catalog = load_combined_catalog() assert scoped_alias_entry(catalog, "굴착기", "FP-09-19-01").code == "0201-0070" side = ["0.6㎥", "시간", "0.09"] assert scoped_alias_entry(catalog, "굴착기", "FP-09-19-01", side) is None # 이름만 갈아도 옆 칸 0.6 으로 무한궤도가 조용히 골라짐 — 이름도 그대로 assert apply_scoped_alias(catalog, "굴착기", "FP-09-19-01", side) == "굴착기" # 값·식 칸은 규격이 아님 — 9-19-3 소형브레이커 줄(값 0.275 · 식)은 그대로 이음 value = ["hr", "0.275", "(2.45+3.05)/2/10㎡"] assert scoped_alias_entry(catalog, "소형브레이커", "FP-09-19-03", value).code == "5210-0010" def test_토사면_고르기_원문_주는_값을_안_짓고_비고로() -> None: """Ⓓ [주]③ 착암공은 수량이 없어 줄을 안 지음 · Ⓔ [주]② 풍화암 연암 준용은 선택이라 갈래 아님.""" note = known_gap_note("FP-09-19-01") assert "착암공" in note and "준용" in note, note def test_제근은_빌린_밑수가_확정이라_갈래가_서면_내역_금액이_섬() -> None: """9-21 원문엔 밑수가 없어 마스터 `basis_missing` 에 F0294 가 남음(원문 사실 그대로) — 그러나 사용자 확정(건설품셈 3-9-2 「1,000㎡당」 · ÷1,000 은 ChooseOne 이 검)이 있어 내역에서 「밑수 미확보」로 막히면 안 됨(2026-09-14 브레인). 교차 참조 사유는 비고에 그대로 남음.""" from B09_Estimation.B09_Estimation_BasisSheet import open_gaps from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill from B09_Estimation.B09_Estimation_UnitPrice import detail_of, load_basis_missing assert "F0294" in load_basis_missing() build = cached_build() assert "FP-09-21" not in build.basis_missing assert not [g for g in open_gaps(build) if g["code"] == "FP-09-21"] title = "B-FP-09-21#굴착기(무한궤도)0.7·소" # ㎡당 환산이 걸린 값 — 1,000㎡당 그대로면 5만원대로 섬 assert build.book.title(title) and 50 <= int(detail_of(build, title)["total"]) <= 60 item = {"work_item_code": "FP-09-21", "name": "지장목제거", "spec": "뿌리뽑기", "unit": "㎡", "quantity": "1000", "in_bill": True, "variant_axis": "stand_volume_class", "variant_value": "굴착기(무한궤도)0.7·소"} # fmt: skip result = build_bill({"work_items": [item], "materials": []}, build=build) assert not result.missing, result.missing row = next(r for r in result.rows if r.code == "FP-09-21") assert row.amount_krw and int(row.amount_krw) > 0, row.note