"""B08 공종 마스터 정규화 검사 — PLAN 8-6·8-7. 제일 중요한 것은 `pum_form` 이다. 생산량형(1÷값)과 소요량형(값÷밑수)은 환산 방향이 반대라, 뒤집히면 값이 오류 없이 조용히 20배쯤 틀린다. 그래서 실물 표를 집어 방향이 맞는지 못 박아 둔다. """ from __future__ import annotations import json import sys from pathlib import Path import pytest ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT)) from B08_Quantity.B08_Quantity_Build_WorkItemMaster import ( # noqa: E402 OUT_DIR, detect_form, parse_toc, section_number, ) MASTER = OUT_DIR / "work_item_master_2026-01-01.json" UNDET = OUT_DIR / "form_undetermined_2026-01-01.json" @pytest.fixture(scope="module") def master() -> dict: if not MASTER.exists(): pytest.skip("공종 마스터 미생성 — 빌드 스크립트를 먼저 실행할 것") return json.loads(MASTER.read_text(encoding="utf-8")) @pytest.fixture(scope="module") def tables() -> list[dict]: src = ROOT / "resources" / "data_cost_input_value" / "pum_forest_2026.json" return json.loads(src.read_text(encoding="utf-8"))["variables"]["pum"]["tables"] def find(tables: list[dict], table_id: str) -> dict: return next(t for t in tables if t["table_id"] == table_id) # ── pum_form — 뒤집히면 값이 반대가 되는 자리 ────────────────────────── def test_생산량형_작업능력표(tables: list[dict]) -> None: """`9-4-1 암파쇄` 는 「㎥/hr」 작업능력표다 — 품 = 1 ÷ 값.""" form, why = detect_form(find(tables, "F0241"), "9") assert form == "productivity", why def test_생산량형이_비고의_직종에_뒤집히지_않을것(tables: list[dict]) -> None: """`9-7-1` 은 「작업능력」 표인데 비고에 「보통인부 1인/일」이 붙어 있다. 직종을 먼저 보면 소요량형으로 뒤집힌다 — 그 사고를 막는 순서를 고정한다. """ table = find(tables, "F0247") assert "보통인부" in json.dumps(table["rows"], ensure_ascii=False) form, why = detect_form(table, "9") assert form == "productivity", why def test_소요량형_직종표(tables: list[dict]) -> None: """`9-3-1 인력` 은 「보통인부(인) 0.16」 — ㎥당 품이다.""" form, why = detect_form(find(tables, "F0239"), "9") assert form == "requirement", why def test_소요량형_직종이_둘째열에_있어도_잡을것(tables: list[dict]) -> None: """`9-12-1 토사` 는 첫 열이 「인력(10%)」, 직종은 둘째 열에 있다.""" form, why = detect_form(find(tables, "F0258"), "9") assert form == "requirement", why def test_계수표는_공종이_아님(tables: list[dict]) -> None: """`9-3-2 기계` 는 시공능력 공식의 K·f·E 파라미터다.""" form, why = detect_form(find(tables, "F0240"), "9") assert form == "coefficient", why def test_1장은_기준표(tables: list[dict]) -> None: """품셈 제1장은 적용기준이라 공종으로 세우지 않는다.""" form, why = detect_form(find(tables, "F0008"), "1") assert form == "reference", why def test_참조지시는_기준표(tables: list[dict]) -> None: """`10-3-1 모래` 는 값이 아니라 「별도계상」 이라는 지시뿐이다.""" form, why = detect_form(find(tables, "F0298"), "10") assert form == "reference", why # ── 계층·코드 ──────────────────────────────────────────────────────── def test_절번호_파싱() -> None: assert section_number("9-3-1. 인력") == "9-3-1" assert section_number("12-2 표면 마무리를 따른다.") == "12-2" assert section_number("부록1") is None def test_목차_계층() -> None: rows = [["제9장", "토공", "50"], ["9-3", "흙깎기", "51"], ["9-3-1", "인력", "51"]] nodes = parse_toc(rows) assert [n["work_item_code"] for n in nodes] == ["FP-09", "FP-09-03", "FP-09-03-01"] assert [n["level"] for n in nodes] == [1, 2, 3] assert nodes[2]["parent_code"] == "FP-09-03" # STmate 관례대로 256 간격 — 중간 삽입 여유를 둔다. assert [n["sort_order"] for n in nodes] == [256, 512, 768] # ── 산출물 계약 (B09 가 읽는 부분) ───────────────────────────────────── def test_원문셀을_버리지_않을것(master: dict) -> None: """`raw_row` 가 없으면 B09 가 476표를 다시 열어야 한다 — PLAN 8-7.""" got = [t for n in master["work_items"] for t in n["tables"]] assert got, "표가 하나도 안 붙었다" assert all("raw_row" in t for t in got) assert any(t["raw_row"] for t in got) def test_자원축은_비워둘것(master: dict) -> None: """자원 축(`resource_kind` 등)은 B09 몫이다 — 여기서 채우면 경계 위반.""" forbidden = {"resource_kind", "resource_code", "resource_spec", "amount", "amount_unit"} for node in master["work_items"]: for table in node["tables"]: assert not (forbidden & set(table)), f"{table['pum_table_id']} 에 자원 축이 섞였다" def test_dataset_version_세쪽_기록(master: dict) -> None: """파일명만으로는 같은 날짜 재생성본과 구분이 안 된다 — PLAN 8-6.""" dv = master["dataset_version"] assert dv["dataset_id"] and dv["effective_date"] assert len(dv["sha256"]) == 64 def test_미판정은_빈칸이_아니라_목록으로(master: dict) -> None: """판정 못 한 표를 조용히 비워 두면 뒤집힌 값이 들어간다 — 반드시 목록으로 낸다.""" if not UNDET.exists(): pytest.skip("미판정 목록 미생성") listed = {i["pum_table_id"] for i in json.loads(UNDET.read_text(encoding="utf-8"))["items"]} flagged = { t["pum_table_id"] for n in master["work_items"] for t in n["tables"] if t["pum_form"] == "undetermined" } assert flagged <= listed, "마스터에서 미판정인데 목록에 없는 표가 있다" def test_귀속률(master: dict) -> None: """표가 목차 계층에 붙어야 공종으로 선다. 미귀속이 늘면 목차 파싱이 깨진 것.""" s = master["stats"] assert s["tables_attached"] / s["tables_total"] > 0.9 # ── 첫 칸이 분류 딱지인 표 (2026-09-07 교차 확인에서 나옴) ────────── # # 서브 창이 자기 파싱에서 「첫 칸이 갈래 딱지이고 이름이 둘째 칸」인 표를 놓쳐 자재·장비가 # 통째로 빠지던 것을 잡았고, 같은 병이 여기 형태 판정에도 있었다 — 12장 임도 구조물 표 # 8건이 `undetermined` 로 빠져 있었다. 다시 걸리지 않게 못 박는다. def test_딱지형_표가_소요량형으로_판정될것() -> None: """`['자재', 'PVC 지수판', 'm', '1.04']` — 첫 칸이 이름이 아니라 갈래다.""" table = { "headers": ["구 분", "단위", "적 용", "비 고", ""], "rows": [ ["자재", "PVC 지수판(200×5)", "m", "1.04", "4% 할증"], ["용접봉", "kg", "0.042", "", ""], ["철선(#8)", "kg", "0.21", "", ""], ["인력(설치비)", "특별인부", "인", "0.151", ""], ], } form, basis = detect_form(table, "12") assert form == "requirement" assert "딱지" in basis def test_직종이_넷째_행에_있어도_잡힐것() -> None: """첫 세 행만 보던 탓에 놓쳤던 모양 — 강관동바리의 형틀목공은 넷째 행이다.""" table = { "headers": ["구 분", "단위", "적 용", "비 고", "", ""], "rows": [ ["자재", "강관 동바리", "내관(48.6mm×2.4mm)", "본", "0.38", ""], ["외관(60.6mm×2.3mm)", "본", "0.38", "", "", ""], ["잡재료비(재료비의)", "%", "5", "", "", ""], ["인력", "형틀목공", "인", "0.07", "", ""], ], } assert detect_form(table, "12")[0] == "requirement" def test_비율_지시_한_줄이_소요량표를_참조로_넘기지_않을것() -> None: """⚠ 만들다 실제로 걸린 자리 — `잡재료비(재료비의) 5 %` 한 줄 때문에 강관동바리가 참조로 넘어갔다. **소요량을 먼저 보고** 비율 지시는 그 뒤에 본다.""" ratio_only = { "headers": ["구 분", "적 용", "비 고"], "rows": [["재료비", "", ""], ["설치비", "재료비의 5%", ""]], } assert detect_form(ratio_only, "12")[0] == "reference" def test_수치가_없으면_지어내지_않고_미판정으로_둘것() -> None: """`['재료비', 'JOINT FILLER', '']` — 이름만 있고 소요량이 없다.""" table = {"headers": ["구 분", "적 용", "비 고"], "rows": [["재료비", "JOINT FILLER", ""]]} assert detect_form(table, "12")[0] == "undetermined" def test_생산량형이_딱지_규칙에_안_밀릴것() -> None: """딱지 규칙은 **맨 마지막**에 본다 — 앞의 판정을 흔들면 안 된다.""" table = { "headers": ["작업능력(㎥/hr)", "비고"], "rows": [["자재", "보통인부 1인/일", "12.5"]], } assert detect_form(table, "9")[0] == "productivity" # ── 딱지가 비율을 달고 오는 표 (2026-09-07 서브 창 제보로 확인) ───── # # `인력(10%)` · `장비(90%)` — 소요량형이면서 **장비 몫은 시공능력 공식**이라 값이 아니다. # 형태 한 낱말로만 적으면 받는 쪽이 그 단가를 전량에 곱해 **내역서가 9할 싸게** 선다. def test_몫이_적힌_표는_몫을_실을것() -> None: from B08_Quantity.B08_Quantity_Build_WorkItemMaster import resource_shares table = { "headers": ["구 분", "적 용", "비 고"], "rows": [ ["인력(10%)", "보통인부(인)", "0.23"], ["장비 (90%)", "유압식백호우 (무한궤도,0.7㎥)", "k", "0.9"], ], } assert resource_shares(table) == {"인력": 10.0, "장비": 90.0} def test_몫이_없는_표는_빈칸() -> None: from B08_Quantity.B08_Quantity_Build_WorkItemMaster import resource_shares assert resource_shares({"rows": [["보통인부(인)", "0.16"]]}) == {} def test_몫이_있으면_부분값으로_표시될것() -> None: """⚠ 공식 기호가 **첫 표에만** 있고 이어지는 표는 물려받는다 — 기호가 있는 표만 세면 9-13-2 같은 절반을 놓친다. 넓게 잡되 **값을 지우지 않고 표시만** 한다.""" from B08_Quantity.B08_Quantity_Build_WorkItemMaster import ( capacity_formula_pending, resource_shares, ) inherited = { "headers": ["구 분", "적 용", "비 고"], "rows": [ ["인력 (10%)", "보통인부(인)", "0.26"], ["장비 (90%)", "유압식백호우 (무한궤도,0.7㎥)"], ], } assert resource_shares(inherited) # 몫은 있고 assert not capacity_formula_pending(inherited) # 공식 기호는 이 표에 없다 def test_형태_판정은_그대로_소요량형() -> None: """몫 표시를 더해도 앞의 판정을 흔들지 않는다.""" table = { "headers": ["구 분", "적 용", "비 고"], "rows": [["인력(10%)", "보통인부(인)", "0.23"], ["장비(90%)", "유압식백호우"]], } assert detect_form(table, "9")[0] == "requirement" # ── 밑수는 표 안이 아니라 **표 바로 위 본문**에 있다 (2026-09-07) ──── # # 서브 창이 「기준 단위 없음으로 도는 단가가 125 중 122」를 보고했고 파 보니 뿌리가 여기였다. # 「10㎡당」 표를 1㎡당으로 알면 **곱셈이 10배 틀린다.** # ⚠ 앞서 확인한 「밑수가 **밀렸나**」와는 다른 물음이다 — 이번은 「**아예 안 적혔나**」다. def _basis(text_lines: list[str]) -> tuple[float | None, str | None]: from B08_Quantity.B08_Quantity_Build_WorkItemMaster import basis_from_source # 마지막 줄이 표라고 보고, 그 줄 번호(1-기반)를 넘긴다. return basis_from_source(text_lines, len(text_lines)) def test_본문에서_밑수를_읽을것() -> None: assert _basis(["### 12-2. 표면 마무리", "", "(단위: ㎡당)", "", "| 구 분 |"]) == (1.0, "㎡") assert _basis(["### 5-1-4. 떼채취", "", "(100㎡당)", "", "| 구 분 |"]) == (100.0, "㎡") def test_분모가_밑수인_꼴() -> None: """`(단위: 인/㎡당)` — 값의 단위(인)를 밑수로 읽으면 뜻이 뒤집힌다.""" assert _basis(["### 13-4-1. 메쌓기(인력)", "", "(단위: 인/㎡당)", "", "| 뒷길이 |"]) == ( 1.0, "㎡", ) def test_당도_단위도_없으면_밑수가_아님() -> None: """⚠ 만들다 실제로 걸린 자리 — `(무한궤도,0.7㎥)` 를 「0.7㎥당」으로 읽어 5건이 잘못 잡혔다.""" assert _basis(["| 장비 | 유압식백호우 (무한궤도,0.7㎥) |", "", "| 구 분 |"]) == (None, None) def test_앞_표의_밑수를_물어오지_않을것() -> None: """위로 거슬러 보되 다른 표에 닿으면 멈춘다 — 남의 밑수를 물어 오면 조용히 틀린다.""" lines = ["(100㎡당)", "| 앞 표 |", "| 값 |", "", "### 9-21. 제근", "", "| 종 류 |"] assert _basis(lines) == (None, None) def test_묶음_기준_판정() -> None: from B08_Quantity.B08_Quantity_Build_WorkItemMaster import basis_quantity_is_grouped assert basis_quantity_is_grouped(10.0) and basis_quantity_is_grouped(100.0) assert not basis_quantity_is_grouped(1.0) assert not basis_quantity_is_grouped(None) def test_산출물에_밑수_미확보_목록이_나올것() -> None: """빈칸으로 두면 「1단위당」으로 오해된다 — 곱하면 안 되는 줄을 받는 쪽이 가릴 수 있게 낸다.""" import json path = ROOT / "resources" / "data_work_item_master" / "basis_missing_2026-01-01.json" payload = json.loads(path.read_text(encoding="utf-8")) assert payload["items"] # 곱할 값이 아닌 형태(참조·계수)는 목록에 안 담는다 — 잡음이 되면 안 본다. assert {item["pum_form"] for item in payload["items"]} <= {"requirement", "productivity"} def test_실물_묶음_기준이_실제로_실릴것() -> None: """13-6-2 찰쌓기는 원문이 `(단위: 10㎡당)` 이다 — 1 로 두면 10배 틀린다.""" import json path = ROOT / "resources" / "data_work_item_master" / "work_item_master_2026-01-01.json" master = json.loads(path.read_text(encoding="utf-8")) tables = {t["pum_table_id"]: t for i in master["work_items"] for t in (i.get("tables") or [])} assert tables["F0420"]["basis_quantity"] == 10.0 assert tables["F0420"]["basis_unit"] == "㎡" assert tables["F0420"]["basis_source"] == "본문" # ── 값 자리에 식이 적힌 칸 (2026-09-07 서브 창 제보) ──────────────── # # `0.2 × 30%`(기초잡석 소할)처럼 계산이 그대로 적혀 있으면 **값이 숫자로 안 읽혀 # 그 성분이 조용히 빠진다.** 형태 판정은 통과하므로 어떤 형태 검사에도 안 걸린다. # ⚠ 식을 **계산하지 않는다** — 뜻을 잘못 읽으면 조용히 틀리므로 드러내기만 한다. def test_식이_적힌_칸을_찾을것() -> None: from B08_Quantity.B08_Quantity_Build_WorkItemMaster import expression_cells table = { "headers": ["구 분", "적 용", "비 고"], "rows": [ ["부설다짐", "할석공(인)", "0.6"], ["소할(30%)", "할석공(인)", "0.2 × 30%"], ], } assert expression_cells(table) == ["0.2 × 30%"] def test_평범한_숫자는_식이_아님() -> None: from B08_Quantity.B08_Quantity_Build_WorkItemMaster import expression_cells assert expression_cells({"rows": [["보통인부(인)", "0.16"], ["계", "1,234.5"]]}) == [] def test_긴_설명문은_식이_아님() -> None: """⚠ 좁게 잡는다 — 비고의 설명문까지 걸면 목록이 잡음이 되어 결국 안 본다.""" from B08_Quantity.B08_Quantity_Build_WorkItemMaster import expression_cells rows = [["비고", "- 지주목을 세우지 않을 때는 인력품의 10%를 감한다"]] assert expression_cells({"rows": rows}) == [] def test_기초잡석이_실제로_잡힐것() -> None: """서브가 실물에서 부딪힌 그 표 — 부설다짐 0.6인만 서고 소할 몫이 빠져 있었다.""" import json path = ROOT / "resources" / "data_work_item_master" / "work_item_master_2026-01-01.json" master = json.loads(path.read_text(encoding="utf-8")) tables = {t["pum_table_id"]: t for i in master["work_items"] for t in (i.get("tables") or [])} assert tables["F0373"]["expression_cells"] == ["0.2 × 30%"] # 배분율 딱지가 없어 `partial_ratio` 로는 안 걸리는 자리다 — 그래서 깃발이 따로 필요했다. assert tables["F0373"]["partial_ratio"] is False # ── 작업조 표 · 「단 위」 표지 제거 (2026-09-07 서브 창 제보) ──────── # # ⚠ 「형틀목공 4인 + 보통인부 1인 / 시공량 35㎡」의 **「4」는 소요량이 아니라 인원**이다. # 행-자원으로 그냥 읽으면 **35배 부푼다**. 값을 바꾸지 않고 표시만 한다. def test_작업조_표를_알아볼것() -> None: from B08_Quantity.B08_Quantity_Build_WorkItemMaster import crew_table table = { "headers": ["구 분", "단 위", "수 량", "시 공 량 (㎡)"], "rows": [ ["복 잡", "보 통", "간 단"], ["형틀목공 보통인부", "인 인", "4 1", "25", "35", "40"], ], } assert crew_table(table) is True def test_평범한_소요량표는_작업조가_아님() -> None: """⚠ 짝 시험 — 넓게 잡으면 멀쩡한 소요량표가 다 걸린다.""" from B08_Quantity.B08_Quantity_Build_WorkItemMaster import crew_table assert ( crew_table({"headers": ["구 분", "단 위", "수 량"], "rows": [["미 장 공", "인", "0.34"]]}) is False ) def test_헤더_단위_만으로_참조가_되지_않을것() -> None: """⚠ 이 시험이 이번 건의 핵심 — 「단위 열이 있다」는 소요량표의 흔한 모양이지 참조표의 표지가 아니다. 그 표지 하나로 **41건이 버려지고 있었고** 그 안에 **돌쌓기(장비) 13-4-5**(우리 매핑이 실제로 쓰는 코드)가 있었다.""" table = { "headers": ["구 분", "단 위", "수 량"], "rows": [["돌 쌓 기 공", "인", "0.32"], ["보통인부", "인", "0.28"]], } assert detect_form(table, "13")[0] == "requirement" def test_돌쌓기_장비가_실제로_공종으로_설것() -> None: """매핑이 `masonry_wet → FP-13-04-05` 로 쓰는 표다 — 참조로 버려지면 단가가 안 선다.""" import json path = ROOT / "resources" / "data_work_item_master" / "work_item_master_2026-01-01.json" master = json.loads(path.read_text(encoding="utf-8")) tables = {t["pum_table_id"]: t for i in master["work_items"] for t in (i.get("tables") or [])} assert tables["F0414"]["pum_form"] == "requirement" # 13-4-5 찰쌓기(장비) assert tables["F0407"]["pum_form"] == "requirement" # 13-4-2 메쌓기(장비) def test_작업조_깃발이_산출물에_실릴것() -> None: import json path = ROOT / "resources" / "data_work_item_master" / "work_item_master_2026-01-01.json" master = json.loads(path.read_text(encoding="utf-8")) tables = {t["pum_table_id"]: t for i in master["work_items"] for t in (i.get("tables") or [])} assert tables["F0395"]["crew_table"] is True # 유로폼 설치·해체 assert tables["F0132"]["crew_table"] is True # 평떼 시비 — 서브가 실물에서 부딪힌 표 assert tables["F0334"]["crew_table"] is False # 표면 마무리는 평범한 소요량표 # ── 이름 표기 갈림 · 공식 기호 줄 (2026-09-07 서브 창 제보) ───────── # # ⚠ 같은 표 묶음 안에서도 표기가 갈린다 — `13-6-1` 은 「굴 삭 기 (무한궤도)」, # 바로 옆 `13-6-2` 는 「굴착기 (무한궤도)」. 받는 쪽이 이름으로 자원을 찾으므로 # 표기가 갈리면 **그 줄이 통째로 빠진다.** # ⚠⚠ **여기서 이름을 고치지 않는다.** 정규화는 값을 살리지만 **잘못된 줄도 함께 살린다** — # 서브 실례: 이름 정규화 직후 버킷계수 `K` 를 소요량으로 읽어 사용료가 이중이 됐다. def test_자간_공백_자원_이름을_찾을것() -> None: from B08_Quantity.B08_Quantity_Build_WorkItemMaster import spaced_names table = { "rows": [["굴 삭 기 (무한궤도)", "0.8㎥", "h", "3.84"], ["작업반장", "", "인", "0.83"]] } assert spaced_names(table) == ["굴 삭 기 (무한궤도)"] def test_머리글_재료명은_안_걸릴것() -> None: """⚠ 짝 시험 — 「단 위」·「모 래」까지 걸면 101건이 되어 목록이 잡음이 된다. **단위 칸과 수치가 함께 있는 자원 줄**만 본다.""" from B08_Quantity.B08_Quantity_Build_WorkItemMaster import spaced_names assert spaced_names({"rows": [["단 위", "㎥"], ["모 래", "6"]]}) == [] def test_공식_기호_줄을_찾을것() -> None: """`K`·`f`·`E` 는 시공능력 공식 파라미터다 — 자원으로 세면 이중계상.""" from B08_Quantity.B08_Quantity_Build_WorkItemMaster import formula_rows table = {"rows": [["k", "0.9"], ["f", "1/1.3"], ["보통인부", "인", "0.23"]]} assert set(formula_rows(table)) == {"k", "f"} def test_서브가_부딪힌_표가_실제로_잡힐것() -> None: import json path = ROOT / "resources" / "data_work_item_master" / "work_item_master_2026-01-01.json" master = json.loads(path.read_text(encoding="utf-8")) tables = {t["pum_table_id"]: t for i in master["work_items"] for t in (i.get("tables") or [])} assert tables["F0419"]["spaced_names"] == ["굴 삭 기 (무한궤도)"] # 13-6-1 메쌓기 assert tables["F0423"]["spaced_names"] == ["굴 삭 기 (무한궤도)"] # 13-7-2 찰붙이기 # 바로 옆 13-6-2 찰쌓기는 공백이 없다 — 그래서 표기가 갈린다. assert tables["F0420"]["spaced_names"] == [] assert "k" in tables["F0258"]["formula_rows"] # 9-12-1 측구터파기 토사 # ── 규격 표기 특수문자 (2026-09-07 갈래 계약) ────────────────────── # # ⚠ 원문이 한 종류로 안 쓴다 — 물결표만 셋(`∼` 662 · `~` 459 · `~` 2), 곱셈표 둘. # **두 창이 각자 갈래 키를 조립하면 글자 하나로 영영 안 맞는다.** # 그래서 인계는 키를 조립하지 않고 **저장 원본값만** 보낸다. 여기서는 표시만 한다. def test_특수문자_종류를_찾을것() -> None: from B08_Quantity.B08_Quantity_Build_WorkItemMaster import special_glyphs table = {"rows": [["직경 40㎝이상∼60㎝미만", "600 x 1,200mm"]]} found = special_glyphs(table) assert "∼(U+223C)" in found and "x(U+0078)" in found def test_평범한_글자는_안_걸릴것() -> None: """⚠ 짝 시험 — 넓게 잡으면 목록이 잡음이 된다.""" from B08_Quantity.B08_Quantity_Build_WorkItemMaster import special_glyphs assert special_glyphs({"rows": [["보통인부", "인", "0.16"]]}) == [] def test_물결표가_두_종류_섞인_표가_실제로_있을것() -> None: """⚠ 이것이 계약을 바꾼 근거다 — 같은 절 안에서도 표기가 갈린다.""" import json path = ROOT / "resources" / "data_work_item_master" / "work_item_master_2026-01-01.json" master = json.loads(path.read_text(encoding="utf-8")) tables = {t["pum_table_id"]: t for i in master["work_items"] for t in (i.get("tables") or [])} mixed = [ tid for tid, t in tables.items() if sum(1 for g in t.get("special_glyphs") or [] if "U+223C" in g or "U+FF5E" in g) >= 2 ] assert mixed, "물결표가 섞인 표를 못 찾았다 — 계약 근거가 사라졌는지 확인할 것" # ── 참조 표지 — 41건을 지웠던 그 규칙의 짝 시험 (2026-09-08 ㉘) ──────────────── # 넓히면 정상 공종이 사라지고, 좁히면 기준표가 공종으로 선다. 양쪽을 다 박는다. def _표(headers: list[str], rows: list[list[str]] | None = None) -> dict: return {"headers": headers, "rows": rows or [["보통인부", "인", "1.0"]]} def test_참조_표지가_기준표를_잡을것() -> None: from B08_Quantity.B08_Quantity_Build_WorkItemMaster import REFERENCE_MARKS, detect_form for mark in REFERENCE_MARKS: form, why = detect_form(_표([mark, "구분", "비율"]), "13") assert form == "reference", f"'{mark}' 를 못 잡는다 — {why}" def test_단위_열이_있다고_공종을_버리지_말것() -> None: """⚠ 2026-09-07 사고 — 헤더 「단 위」 하나로 **41건**이 참조로 버려졌다. 그 안에 돌쌓기(장비) `13-4-5`·`13-4-2` 처럼 **우리 매핑이 실제로 쓰는 공종**이 있었다. 「단위 열이 있다」는 소요량표의 흔한 모양이지 참조표의 표지가 아니다. """ from B08_Quantity.B08_Quantity_Build_WorkItemMaster import REFERENCE_MARKS, detect_form assert "단위" not in [m.replace(" ", "") for m in REFERENCE_MARKS] form, _why = detect_form(_표(["명칭", "규격", "단 위", "수량"]), "13") assert form != "reference" def test_참조_표지를_부분일치로_넓히지_말것() -> None: """정상 공종 이름에 표지 글자가 스쳐도 걸리면 안 된다.""" from B08_Quantity.B08_Quantity_Build_WorkItemMaster import detect_form for headers in (["명칭", "규격", "단위", "수량"], ["직종", "단위", "수량"]): form, _why = detect_form(_표(headers), "9") assert form != "reference", headers # ── 배수관 유입부 집수정 — 부속 줄 규칙의 짝 시험 (2026-09-08 ㉘) ────────────── def test_부속은_그_칸이_있을_때만_줄이_선다() -> None: from B08_Quantity.B08_Quantity_Engine_UnitQuantity import attachments_of 관 = {"structure_id": "s1", "type_id": "pipe", "options": {}} assert attachments_of(관) == [], "집수정 형식을 안 골랐는데 줄이 섰다" 관["options"] = {"inlet_basin_form": "돌집수정 ㄷ형"} 붙은것 = attachments_of(관) assert [r["type_id"] for r in 붙은것] == ["pipe_inlet_basin"] assert 붙은것[0]["attachment_of"] == "s1" assert 붙은것[0]["structure_id"] != "s1", "부모와 같은 id 면 두 줄이 한 줄로 뭉친다" def test_부속이_없는_종류에는_아무것도_안_붙는다() -> None: from B08_Quantity.B08_Quantity_Engine_UnitQuantity import attachments_of 옹벽 = {"structure_id": "s2", "type_id": "retaining_wall", "options": {"inlet_basin_form": "ㄷ형"}} assert attachments_of(옹벽) == [] # ── 빈 단가산출서 서식 (2026-09-08 ㉙, 서브가 찾아 준 것) ───────────────────── def test_빈_단가산출서_서식은_공종이_아님() -> None: """품셈이 실어 둔 **채워 넣으라고 둔 양식** — 값이 아예 없다. 이것을 공종으로 세우면 「값이 있는데 안 서는 자리」로 오해된다. 실측: 이 규칙 하나로 견적 쪽 못 맞춘 자원이 **882 → 497** 로 줄었다. """ from B08_Quantity.B08_Quantity_Build_WorkItemMaster import detect_form 표 = { "headers": ["ha당 참나무시들음병방제 단가산출서(예시)", "", ""], "rows": [["위치 및 임․소반", "1-0-1-0 또는 00임반 00소반", "비고"]], } form, why = detect_form(표, "4") assert form == "reference", why def test_머리글이_없어도_서식_모양으로_가름() -> None: from B08_Quantity.B08_Quantity_Build_WorkItemMaster import detect_form 표 = { "headers": [], "rows": [ ["위치 및 면적", "00임반 00소반", "비 고"], ["구분", "작업량", "단위품 (인원,수량,요율)", "소요품", "단가(원)"], ], } assert detect_form(표, "3")[0] == "reference" def test_서식_규칙이_정상_표를_지우지_말것() -> None: """⚠ 「구분」·「단가」는 정상 표에도 흔하다 — 하나만 보면 정상 표가 사라진다.""" from B08_Quantity.B08_Quantity_Build_WorkItemMaster import detect_form 소요량표 = {"headers": ["명칭", "규격", "단위", "수량"], "rows": [["보통인부", "", "인", "1.0"]]} assert detect_form(소요량표, "13")[0] == "requirement" 구분표 = { "headers": [], "rows": [["구 분", "소요인력", "인력구성"], ["작업로 예정선 선정", "1.0", "초급기술자"]], } assert detect_form(구분표, "3")[0] != "reference" def test_마스터에_빈_서식이_공종으로_남아있지_않을것() -> None: """정본 대조 — 다시 만든 마스터에 그 표가 소요량표로 서 있으면 깨진다.""" import json from pathlib import Path master = json.loads( ( Path(__file__).resolve().parents[2] / "resources" / "data_work_item_master" / "work_item_master_2026-01-01.json" ).read_text(encoding="utf-8") ) 남은것: list[str] = [] def walk(node: object) -> None: if isinstance(node, dict): for table in node.get("tables") or []: head = "".join( str(c) for row in (table.get("raw_row") or [])[:1] for c in row ).replace(" ", "") if "위치및" in head and table.get("pum_form") != "reference": 남은것.append(str(table.get("pum_table_id"))) for value in node.values(): walk(value) elif isinstance(node, list): for value in node: walk(value) walk(master) assert not 남은것, f"빈 서식이 아직 공종으로 서 있다: {sorted(set(남은것))}" # ── 밑수가 표 아래 [주] 에만 있는 자리 (2026-09-08, B09 제보) ────────────────── def test_규준틀_밑수를_표_아래_주에서_읽는다() -> None: """⚠ 「원문에 있었음」 아홉 번째 — 이번엔 표 **아래** [주] 였다. 11-2·11-3 은 표에도 표 위 본문에도 밑수가 없고, [주]③ 「목재의 손율은 **1개소 사용당** 50%로 한다」에만 「개소」가 나온다. 뜻으로도 개소가 맞다 — [주]② 가 「본 품은 …한 비탈규준틀의 제작·도색·가설·철거를 포함한 것」이다. """ from B08_Quantity.B08_Quantity_Build_WorkItemMaster import basis_from_source lines = [ "### 11-2. 토공의 비탈 규준틀", "| 종 류 | 단 위 | 수 량 |", "|---|---|---|", "| 건축목공 | 인 | 0.16 |", "[주] ① 비탈길이 10m 이상 20m마다 설치한다.", "- ③ 목재의 손율은 1개소 사용당 50%로 한다.", ] assert basis_from_source(lines, 2) == (1.0, "개소") def test_개소당_평균면적_같은_흔한_말에는_안_걸린다() -> None: """⚠ 넓히면 조림 2장이 통째로 걸린다 — 실측 19건. 「사용」이 있어야 잡는다.""" from B08_Quantity.B08_Quantity_Build_WorkItemMaster import basis_from_source lines = [ "### 2-1. 풀베기", "| o 집단화 정도(1-4-3) | 개소당 평균면적이 1~3ha 미만 | 5% |", ] assert basis_from_source(lines, 2) == (None, None) def test_아래_주를_보되_다음_절까지_넘어가지_말것() -> None: """남의 [주] 를 물어 오면 조용히 틀린다.""" from B08_Quantity.B08_Quantity_Build_WorkItemMaster import basis_from_source lines = [ "### 11-9. 밑수가 없는 표", "| 종류 | 단위 | 수량 |", "|---|---|---|", "### 11-2. 토공의 비탈 규준틀", "- ③ 목재의 손율은 1개소 사용당 50%로 한다.", ] assert basis_from_source(lines, 2) == (None, None) def test_마스터의_규준틀_둘에_밑수가_실려있을것() -> None: """정본 대조 — 다시 만든 마스터에 값이 실제로 들어갔는가.""" import json from pathlib import Path master = json.loads( ( Path(__file__).resolve().parents[2] / "resources" / "data_work_item_master" / "work_item_master_2026-01-01.json" ).read_text(encoding="utf-8") ) 찾음: dict[str, tuple] = {} def walk(node: object) -> None: if isinstance(node, dict): code = node.get("work_item_code") if code in ("FP-11-02", "FP-11-03"): for table in node.get("tables") or []: 찾음[str(code)] = (table.get("basis_quantity"), table.get("basis_unit")) for value in node.values(): walk(value) elif isinstance(node, list): for value in node: walk(value) walk(master) assert 찾음 == {"FP-11-02": (1.0, "개소"), "FP-11-03": (1.0, "개소")}, 찾음