"""공종 축 불변 열쇠 검사 — 2026-09-17 브레인 ①. 여기서 못박는 것은 하나다. **금액이 움직이면 안 된다.** 열쇠를 얹는 일은 값을 바꾸는 일이 아니므로, 새 칸을 떼어내면 옛 벌과 **한 글자도 달라선 안 된다.** 그 자리를 지키는 것이 `test_옛_값이_한_글자도_안_바뀜` 의 지문(sha256)이다. 나머지는 열쇠의 약속 — 안 겹침·꼴·두 번 지어도 같음 · **짐작으로 안 이음** · 계층(`parent_mode`)이 그대로 있음 · 총칙 2장을 지우지 않았음(B09 연료·손료가 읽는다). """ from __future__ import annotations import copy import hashlib import json import re 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 OUT_DIR # noqa: E402 from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Keys import ( # noqa: E402 assign_keys, axis_role, empty_registry, assign_variant_keys, format_key, general_provision_roots, load_policy, path_names, toc_edition, variant_display, variant_match, toc_slot, ) MASTER = OUT_DIR / "work_item_master_2026-01-01.json" #: 열쇠를 얹기 **전** 벌(`work_items`)의 지문. 2026-09-17 데스크탑_서브가 뜬 값. #: ⚠ 이 값이 흔들리면 값이 움직였다는 뜻이다 — 지문을 고치지 말고 **원인을 먼저 찾을 것.** #: 2026-09-17 두 번 갱신 — 둘 다 **더하기만**(덮어쓴 것 0 · 사라진 것 0): #: ① 표머리 몫꼴(`인/ha`·`인/100kg`)에서 밑수 18개를 새로 읽음 #: ② 형태 미판정 14표를 사람 판정으로 가름(안티그래비티 원문 대조) · 그중 5표는 밑수도 확인 #: ③ 원문 전수 대조 60건 중 **원문 괄호가 분명한 16표**의 밑수를 채움 #: ⚠ 그 가운데 **하나(F0430)만 값이 바뀜** — 원문이 「1일당」인데 「개」로 서 있던 왜곡을 고침 #: ④ 표 아래 `[주]` 글을 `notes` 칸으로 실음(순수 더하기 — 원문 셀·밑수·형태 다 그대로) #: ⑤ 그 `[주]` 추출의 오염을 걷어냄 — 원문이 절 제목을 목록 줄로 적어 제목·다음 절 [주]까지 #: 끌어오고 있었음(산림 표 320 → 306 · 건설 1,761 → 1,106) #: ⑥ ⚠ 그 걷어내기가 **진짜 계수 34건까지 잘랐던 것**을 되살림(고철 공제·위험목 100% 가산 …) #: ⑦ 실값인데 「참고」로 빠져 있던 13표를 `sub_requirement` 로 — 값은 실값, 자리는 상위 공종 종속 #: ⑨ 원천 벌을 다시 뽑아 지음 — 밑수 238 → 242 · ⚠ 5-26-1·2·3 밑수를 사람 판정 100㎡ → 원문 992㎡ 로 물림 #: ⑧ 못 읽던 [주] 세 꼴을 읽게 함 — 별표 줄 · 인용 산출예시 뒤 줄 · **긴 표의 [주]**(줄 수 제한 없앰) #: ⇒ 곱하면 안 되던 줄이 곱할 수 있게 된 것이지, 있던 값이 바뀐 것이 아니다. #: 2026-09-17 랩탑 메인 — 목차 오기 둘을 본문 번호로 바로잡아(브레인 지시) **네 줄만** 바뀌어 새로 뜸 #: (옛 f8b8fba1…). 네 줄 모습은 `test_목차_오기는_본문_번호로_바로잡고_열쇠는_그대로` 가 잼 · #: 미판정·밑수 목록 파일은 한 글자도 안 바뀜. #: 2026-09-17 랩탑 메인 둘째 — 헛단위 뺌(인용을 제목으로 읽은 13-3 표 셋 · 부록 사례 번호 겹침 일곱)으로 다시 뜸 #: (옛 be88f730…). 바뀐 줄은 13-3·4-1·4-2·2-1·2-2·3-1·3-2·3-3·13-4-1·13-5-2 의 표 귀속뿐 — #: `test_work_item_master_toc` 가 자리를 잼 · 단가표 대조는 그 파일 머리. #: 2026-09-18 랩탑 메인 — **밑수 읽기를 넓혀** 원문에 있던 두 자리를 더 읽어 다시 뜸(옛 803898a3…). #: 2-2-4 배부식분무기 「(덩굴 약제처리, ha당)」 · 9-11-1 「(단위: 일당)」 — 둘 다 원문 그대로다. #: ⚠ 건설 표 판정층은 **산림이 미판정일 때만** 도므로 산림 형태는 한 글자도 안 바뀌었고, #: 원가계산서(직접공사비 5,983,724)도 그대로다 — 값이 움직인 것이 아니라 **못 읽던 밑수를 읽은 것**이다. #: 2026-09-18 합침(랩탑 메인 · 데스크탑 서브 9f27d619 와 한 점) — 위 넓힘 + 데스크탑 서브의 #: F0046·F0068 밑수 되돌림·F0076 형태 판정·원천 벌 재추출을 한 벌로 다시 뽑아 지문이 또 뜸 #: (옛 837eb8aa… · 옛 6f223c69…). 양쪽 다 **원문을 더 읽은 것**이고 금액은 그대로다. BASELINE_WORK_ITEMS_SHA = "7919a44eab4ecf9a09edb39f0c4b48f5576caa8c5d491fe6d3e5fb89ba655436" #: 열쇠 작업이 새로 얹은 칸. 지문을 잴 때만 떼어낸다. ADDED_FIELDS = ( "work_item_key", "toc_code", "toc_number", "toc_edition", "path_name", "axis_role", "variants", ) @pytest.fixture(scope="module") def master() -> dict: return json.loads(MASTER.read_text(encoding="utf-8")) def _fingerprint(items: list[dict]) -> str: stripped = [{k: v for k, v in it.items() if k not in ADDED_FIELDS} for it in items] blob = json.dumps(stripped, ensure_ascii=False, sort_keys=True, separators=(",", ":")) return hashlib.sha256(blob.encode()).hexdigest() def test_옛_값이_한_글자도_안_바뀜(master: dict) -> None: """금액을 낳는 값(표·밑수·형태)이 그대로인가 — 새 칸만 떼고 지문을 맞댄다.""" assert _fingerprint(master["work_items"]) == BASELINE_WORK_ITEMS_SHA def test_모든_줄에_열쇠가_있고_안_겹침(master: dict) -> None: items = master["work_items"] keys = [it["work_item_key"] for it in items] assert all(re.fullmatch(r"FW-\d{5}", k) for k in keys) assert len(set(keys)) == len(items) def test_목차_번호는_칸으로만_남음(master: dict) -> None: """`FP-…` 는 지우지 않고 「그 판의 목차 번호」 칸으로 남았는가 — 164 파일이 아직 쓴다.""" assert master["policy"]["row_key"] == "work_item_key" assert master["policy"]["toc_is_column_not_key"] is True assert master["toc_edition"] == "산림청고시제2025-82호" for it in master["work_items"]: assert it["toc_code"] == it["work_item_code"] assert it["toc_number"] == it["number"] assert it["toc_edition"] == master["toc_edition"] def test_장부가_있으면_같은_열쇠가_다시_나옴(master: dict) -> None: """두 번 지어도 같은가 — 장부를 그대로 두고 다시 발급해 본다.""" registry = json.loads((OUT_DIR / "work_item_keys.json").read_text(encoding="utf-8")) before = copy.deepcopy(registry) nodes = [ { "work_item_code": it["work_item_code"], "number": it["number"], "name": it["name"], "sort_order": it["sort_order"], } for it in master["work_items"] ] newly, _ = assign_keys(nodes, registry, edition=master["toc_edition"]) assert newly == [] # 다시 발급하지 않는다 assert registry["next_serial"] == before["next_serial"] assert [n["work_item_key"] for n in nodes] == [ it["work_item_key"] for it in master["work_items"] ] def test_장부에_없는_코드는_새_열쇠를_받음() -> None: """⚠ 짐작으로 안 이음 — 이름이 같아도 옛 열쇠를 물려주지 않고 새로 내고 목록에 싣는다.""" registry = empty_registry() old = [{"work_item_code": "FP-09-03", "number": "9-3", "name": "토사깍기", "sort_order": 256}] assign_keys(old, registry, edition="산림청고시제2025-82호") # 새 판에서 번호가 밀린 같은 이름의 절 new = [{"work_item_code": "FP-09-04", "number": "9-4", "name": "토사깍기", "sort_order": 256}] newly, _ = assign_keys(new, registry, edition="산림청고시제9999-99호") assert new[0]["work_item_key"] != old[0]["work_item_key"] assert [n["work_item_key"] for n in newly] == [new[0]["work_item_key"]] def test_열쇠_차례는_목차_차례() -> None: registry = empty_registry() nodes = [ {"work_item_code": "FP-02", "number": "2", "name": "둘", "sort_order": 512}, {"work_item_code": "FP-01", "number": "1", "name": "하나", "sort_order": 256}, ] assign_keys(nodes, registry, edition="e") assert nodes[1]["work_item_key"] == format_key(1) assert nodes[0]["work_item_key"] == format_key(2) def test_목차_오기는_본문_번호로_바로잡고_열쇠는_그대로(master: dict) -> None: """⚠ 품셈 목차가 12-17-2·12-24-1 을 두 번 적었음 — 본문은 12-17-3 무근진동기 제외 · 12-27-1 지수판 설치 (2026-09-17 코덱스 원문 대조). 목차를 믿으면 뒤 줄이 앞 표를 덮어써 혼성 줄이 서고 F0364·F0375 가 사라짐. 열쇠는 같은 공종이라 그대로(장부에 손으로 이음 · `relinked`).""" assert master["toc_duplicate_rows"] == [] assert [(c["toc_number"], c["body_number"]) for c in master["toc_corrections"]] == [ ("12-17-2", "12-17-3"), ("12-24-1", "12-27-1"), ] by_key = {it["work_item_key"]: it for it in master["work_items"]} for key, number, name, tables in ( ("FW-00387", "12-17-2", "철근, 펌프카 0-15m", ["F0363"]), ("FW-00388", "12-17-3", "무근진동기 제외", ["F0364"]), ("FW-00396", "12-24-1", "뒷채움", ["F0371"]), ("FW-00401", "12-27-1", "지수판 설치", ["F0375"]), ): it = by_key[key] assert (it["number"], it["name"], [t["pum_table_id"] for t in it["tables"]]) == ( number, name, tables, ) assert not {o["pum_table_id"] for o in master["orphan_tables"]} & {"F0364", "F0375"} def test_슬롯_이름() -> None: assert toc_slot("FP-12-17-02", 1) == "FP-12-17-02" assert toc_slot("FP-12-17-02", 2) == "FP-12-17-02#2" def test_잎_경로_이름이_안_겹침(master: dict) -> None: """「인력」 3곳 · 「수확」 6곳처럼 겹치던 잎 이름이 경로 이름으로 갈리는가.""" items = master["work_items"] has_kid = {it.get("parent_code") for it in items} leaves = [it for it in items if it["work_item_code"] not in has_kid and it["tables"]] names = [it["name"] for it in leaves] paths = [it["path_name"] for it in leaves] assert len(set(names)) < len(names) # 이름만으로는 겹친다 — 경로가 필요한 까닭 assert len(set(paths)) == len(paths) 사람 = next(it for it in items if it["work_item_code"] == "FP-09-03-01") assert 사람["path_name"] == "토공 › 토사깍기 › 인력" def test_계층과_계산_규칙은_그대로(master: dict) -> None: """`parent_mode` 가 계산 규칙을 지닌다 — 묶는 마디를 지우지 않았는가.""" items = master["work_items"] modes = [it.get("parent_mode") for it in items] assert modes.count("choose_one") == 91 assert modes.count("sum_steps") == 3 발파암 = next(it for it in items if it["work_item_code"] == "FP-09-05") assert [s["code"] for s in 발파암["steps"]] == ["FP-09-05-01", "FP-09-05-02", "FP-09-05-03"] def test_총칙은_지우지_않고_구실로_가름(master: dict) -> None: """⚠ `B09_Estimation_Consumables` 가 총칙 2장 표를 읽는다 — 지우면 연료·손료가 사라진다.""" by_code = {it["work_item_code"]: it for it in master["work_items"]} for code, table_id in ( ("FP-02-01-01", "F0042"), # 체인톱 보통휘발유 ("FP-02-02-01", "F0064"), # 체인톱 손료계수 ("FP-02-02-06", "F0069"), # 양수기 손료계수 ): node = by_code[code] assert node["axis_role"] == "general_provision" assert table_id in [t["pum_table_id"] for t in node["tables"]] roles = master["stats"]["axis_roles"] assert sum(roles.values()) == len(master["work_items"]) assert roles["general_provision"] == 78 def test_구실_가름_규칙() -> None: assert ( axis_role({"work_item_code": "FP-01-02", "tables": []}, has_children=True) == "general_provision" ) assert ( axis_role({"work_item_code": "FP-09-03-01", "tables": [{}]}, has_children=False) == "work_item" ) assert axis_role({"work_item_code": "FP-09-03", "tables": []}, has_children=True) == "group" assert axis_role({"work_item_code": "FP-09-01", "tables": []}, has_children=False) == "empty" def test_총칙_범위는_코드가_아니라_자료가_정함() -> None: """⚠ 뿌리를 코드에 박으면 관리자가 못 고친다 — `axis_policy.json` 이 정본.""" assert general_provision_roots("forest") == ("FP-01", "FP-02") const = load_policy("const") assert const["key_prefix"] == "CW" assert const["settled"] is True # 2026-09-17 안티그래비티 조사 · 브레인 확정 # 건설 뿌리는 **장 파일 이름**으로 적는다 — 뽑는 코드가 목차 코드로 바꿔 준다(랩탑 메인 `file_place`). # ⚠ 바뀐 코드에는 부문 자리가 들어간다(`CP-01-01`) — 부문마다 장 번호가 1부터 다시 시작해서다. assert const["general_provision_roots"] == [ "01_공통부문/제1장_적용기준.md", "01_공통부문/제8장_건설기계.md", # 산림 FP-02(소요재료·기계손료)와 짝 ] # 유지관리 제1장 「공 통」은 총칙이 아니라 **공종** — 미확정 목록이 비었다 assert const["general_provision_pending"] == [] # 뿌리를 바꿔 주면 구실도 따라 바뀐다 — 잣대가 자료에 있다는 증거 assert ( axis_role({"work_item_code": "FP-09-03", "tables": []}, has_children=True, roots=("FP-09",)) == "general_provision" ) def test_건설_총칙_뿌리가_실제로_붙음() -> None: """잣대가 건설 산출에 닿았는가 — 뿌리 아래 37줄이 `general_provision` 으로 섰다.""" doc = json.loads( (OUT_DIR / "const_work_item_master_2026-01-01.json").read_text(encoding="utf-8") ) roles = doc["stats"]["axis_roles"] assert roles["general_provision"] == 112 # 적용기준 37 + 건설기계 75 assert sum(roles.values()) == len(doc["work_items"]) == 1367 for root, count in (("CP-01-01", 37), ("CP-01-08", 75)): under = [ it for it in doc["work_items"] if it["work_item_code"] == root or it["work_item_code"].startswith(root + "-") ] assert len(under) == count assert {it["axis_role"] for it in under} == {"general_provision"} # 유지관리 제1장 「공 통」은 공종으로 남는다(총칙 아님) 공통 = next(it for it in doc["work_items"] if it["work_item_code"] == "CP-05-01") assert 공통["axis_role"] != "general_provision" def test_갈래에도_불변_열쇠가_있다(master: dict) -> None: """갈래 열쇠는 `FW-00105#01` — **글자가 아니라 번호**다(2026-09-17 브레인). 글자를 열쇠에 담으면 원문 자간·물결표가 다듬어지는 날 열쇠가 갈린다. 공종 열쇠와 같은 병이다. """ items = master["work_items"] keys, pairs = [], [] for it in items: for v in it.get("variants") or []: assert re.fullmatch(r"\d{2}", v["variant_key"]), v keys.append(f"{it['work_item_key']}#{v['variant_key']}") pairs.append((v["name"], v["name_clean"])) # 2026-09-18 합침 332 → 337. 다른 창이 원문 md 의 뭉친 표를 줄마다 풀자 읽는 길이 한동안 # 못 따라가 268 까지 떨어졌다 — **풀린 줄을 도로 뭉쳐 읽게** 고쳐 되돌렸고(축 셋 · 뭉친 줄), # 덤으로 6-7-1 교목 시비 다섯이 새로 섰다. 사라진 갈래 0. assert len(keys) == 337 # 갈래 수 assert len(set(keys)) == len(keys) # 겹침 0 # 한 공종 안에서 번호는 **오름차순 · 안 겹침**. ⚠ 1부터 이어 붙지는 않는다 — # 갈래가 사라지면 그 번호는 **비워 둔 채 다시 안 쓴다**(옛 일위대가가 가리키던 자리). # 2026-09-18 11-1 콘테이너가 01~20 을 잃고 21~25 를 받은 것이 그 자리다. for it in items: got = [v["variant_key"] for v in it.get("variants") or []] assert got == sorted(set(got)), it["work_item_key"] # 이름 칸 둘 — 원문은 손대지 않고, 다듬은 것이 따로 raw = [it.get("variant_keys") or [] for it in items] assert [n for it in items for n in (it.get("variant_keys") or [])] == [p[0] for p in pairs] assert sum(1 for a, b in pairs if a != b) == 70 # 자간·물결표가 다듬어진 줄 assert raw # 원문 목록은 그대로 남아 있다 def test_갈래_열쇠는_글자가_바뀌어도_안_흔들린다() -> None: """맞대는 꼴이 공백·물결표를 흡수한다 — 이름 다듬기가 들어와도 번호가 그대로다.""" reg = empty_registry() node = { "work_item_key": "FW-00105", "sort_order": 1, "variant_keys": ["1.1∼1.5", "간 단"], } assign_variant_keys([node], reg) before = [v["variant_key"] for v in node["variants"]] # 원문을 다듬어도(물결표 바꿈 · 자간 정리) 같은 번호가 나와야 한다 node2 = {"work_item_key": "FW-00105", "sort_order": 1, "variant_keys": ["1.1~1.5", "간 단"]} newly = assign_variant_keys([node2], reg) assert [v["variant_key"] for v in node2["variants"]] == before assert newly == [] # 새 번호를 내지 않는다 assert variant_match("간 단") == variant_match("간 단") == "간단" assert variant_display("직경 60㎝이상 ~80㎝미만") == "직경 60㎝이상 ∼80㎝미만" def test_갈래_문자열이_열쇠라_흔들림을_못박음(master: dict) -> None: """⚠ 갈래가 줄 열쇠에 그대로 들어간다(`@id` = `FW-00105#1.1∼1.5`). 품셈 원문이 물결표를 두 가지로 쓰고 자간 공백도 들쭉날쭉이라, 문자열을 손질하면 **열쇠가 갈린다.** 지금 몇 개가 그런지 못박아 둔다 — 늘면 손질이 들어간 것이고, 줄면 열쇠가 바뀐 것이다. """ import collections keys = [k for it in master["work_items"] for k in (it.get("variant_keys") or [])] assert len(keys) == 337 # 2026-09-18 합침 — 6-7-1 교목 시비 다섯이 새로 섬(332 → 337) tilde = collections.Counter(ch for k in keys for ch in k if ch in "∼~") # 2026-09-18 합침 26 → 30 — 새로 선 6-7-1 교목 시비 다섯 가운데 넷이 「∼」를 쓴다 assert tilde == {"∼": 30, "~": 8}, "물결표 쓰임이 바뀜 — 열쇠가 갈렸는지 볼 것" squeezed = collections.defaultdict(set) for k in keys: squeezed["".join(k.split()).replace("~", "∼")].add(k) messy = {n: v for n, v in squeezed.items() if len(v) > 1} assert len(messy) == 6, f"공백·물결표만 다른 짝이 {len(messy)} — 6 이던 것" def test_산림_형태_미판정이_없다(master: dict) -> None: """2026-09-17 안티그래비티 원문 대조로 마지막 14표를 가름 — 미판정이 0 이 됐다. ⚠ 형태가 뒤집히면 값이 20배쯤 조용히 틀린다. 미판정으로 남겨 두는 편이 안전했으나, 14표 모두 **금액 대상**이라 비워 두면 그 공종이 통째로 안 선다. """ from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Forms import BASIS_JUDGMENTS, judged_form forms = [t["pum_form"] for it in master["work_items"] for t in it["tables"]] assert "undetermined" not in forms assert master["stats"]["form_undetermined"] == 0 # 열넷이 실제로 사람 판정으로 섰는가 가른것 = { "F0140": "requirement", "F0141": "requirement", "F0142": "requirement", "F0195": "productivity", "F0248": "coefficient", "F0322": "requirement", "F0323": "requirement", "F0333": "requirement", "F0346": "requirement", "F0169": "productivity", "F0304": "requirement", "F0356": "productivity", "F0257": "coefficient", "F0349": "requirement", } # fmt: skip by_id = {t["pum_table_id"]: t for it in master["work_items"] for t in it["tables"]} for table_id, form in 가른것.items(): assert by_id[table_id]["pum_form"] == form, table_id assert by_id[table_id]["form_basis"].startswith("사람 판정"), table_id # 미판정 열넷 가운데 머리가 소실돼 자동으로는 못 읽는 표의 밑수를 손으로 적었다 # (그 뒤 원문 전수 대조로 다른 표들도 들어와 `BASIS_JUDGMENTS` 는 더 넓다) assert {"F0140", "F0322", "F0333", "F0346"} <= set(BASIS_JUDGMENTS) assert by_id["F0140"]["basis_quantity"] == 992.0 and by_id["F0140"]["basis_unit"] == "㎡" assert by_id["F0322"]["basis_quantity"] == 1000.0 and by_id["F0322"]["basis_unit"] == "본" assert judged_form("F0140", "5-26-1")[0] == "requirement" def test_표_아래_주석이_실린다(master: dict) -> None: """`[주]` 에 **금액에 바로 닿는 조건**이 적혀 있는데 마스터가 그 글을 안 싣고 있었다. 발파 가산 20% · 고철 공제 50kg/㎥ · 공구손료 3% · 고소 감속 9% — 받는 쪽이 볼 길이 없었다 (2026-09-17 안티그래비티 전수 대조 ④). ⚠ **읽기만 싣는다 — 여기서 셈하지 않는다.** """ by_id = {t["pum_table_id"]: t for it in master["work_items"] for t in it["tables"]} 쓰인것 = [t for t in by_id.values() if t.get("notes")] assert len(쓰인것) == 309 발파 = " ".join(by_id["F0245"]["notes"]) assert "인건비의 20%를 가산" in 발파 유로폼 = " ".join(by_id["F0395"]["notes"]) assert "인력품의 3%" in 유로폼 # ⚠ **남의 글을 물어 오지 않는다**(2026-09-17 랩탑 서브 전수 확인으로 드러난 오염을 고침) from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Table import ( is_bare_title, trim_page_break, ) 줄 = [n for t in 쓰인것 for n in t["notes"]] assert all(not n.startswith("#") for n in 줄) # 원문이 절 제목을 목록 줄로 적는다 — 「- 3. 노상 및 노반재료」·「- 라. 체적환산계수(f)표」 assert not [n for n in 줄 if is_bare_title(n)] assert is_bare_title("- 3. 노상 및 노반재료") assert not is_bare_title("- ② 장비는 무한궤도 굴착기(0.7㎥)를 적용한다.") # 쪽이 넘어가며 줄 **끝에** 눌어붙은 쪽번호·장 제목도 뗀다 assert trim_page_break("- ⑥ 적재운반 적하는 1인을 기준으로 한다.15제1장 적용기준") == ( "- ⑥ 적재운반 적하는 1인을 기준으로 한다." ) # 제 [주] 가 없는 표가 제목 건너 남의 [주] 를 빌려오지 않는다(F0009 는 강재류 것을 빌려왔었다) by_id2 = {t["pum_table_id"]: t for it in master["work_items"] for t in it["tables"]} assert not by_id2["F0009"].get("notes") def test_잘려_나갔던_진짜_계수가_살아_있다(master: dict) -> None: """⚠ [주] 오염을 걷다가 **있어야 할 계수까지 잘렸던** 자리(2026-09-18 2바퀴 검사 34건). 까닭 둘 — 「> 【산출 예시】」 인용이 [주] 중간에 끼면 거기서 끊었고, 「- 4. 고철공제 : A=…」처럼 **번호를 달고 이어지는 진짜 규칙**을 절 제목으로 보고 잘랐다. ⇒ 잣대는 **지우는 것보다 남기는 것** — 알맹이(콜론·숫자) 없는 짧은 줄만 제목으로 본다. """ from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Table import is_bare_title by_id = {t["pum_table_id"]: t for it in master["work_items"] for t in it["tables"]} for table_id, 낱말 in ( ("F0252", "고철공제"), # 50kg/㎥ 공제 ("F0088", "산림병해충"), # 인력 제거 시 보통인부 100% 가산 ("F0086", "관목류"), # 보통인부 30% 할증 ("F0191", "잔존목"), # 주행·잔존목 할증 ): assert 낱말 in " ".join(by_id[table_id]["notes"]), table_id # 알맹이 없는 제목만 자른다 — 규칙을 담은 번호 줄은 남긴다 assert is_bare_title("- 3. 노상 및 노반재료") assert is_bare_title("- 라. 체적환산계수(f)표") assert not is_bare_title("- 4. 고철공제 : A=1㎥×0.008×7,850kg/㎥×80%") assert not is_bare_title("- 2. 철근절단 : 철근콘크리트(T=30㎝ 미만)품을 적용한다.") def test_실값인데_참고로_빠져_있던_표(master: dict) -> None: """⚠ `requirement` 로 올리지 않는다 — 상위 공종이 이미 세는 몫을 두 번 세게 된다. `reference` 로 두면 금액이 0원으로 증발하고, `requirement` 면 이중 계상된다. 그래서 `sub_requirement`(값은 실값 · 자리는 상위 공종 종속)로 둔다(2026-09-18 안티그래비티 권고). """ by_id = {t["pum_table_id"]: t for it in master["work_items"] for t in it["tables"]} 실값 = [k for k, t in by_id.items() if t["pum_form"] == "sub_requirement"] assert len(실값) == 13 assert {"F0369", "F0386", "F0393", "F0408", "F0410"} <= set(실값) # 설계 치수·조건 분류표 일곱은 그대로 참고다 for table_id in ("F0396", "F0400", "F0406", "F0411", "F0412", "F0413", "F0446"): assert by_id[table_id]["pum_form"] == "reference", table_id def test_사람_판정은_원문이_나오면_물러난다(master: dict) -> None: """⚠ 5-26-1·2·3 — 앞서 100㎡ 로 메워 둔 밑수를 **원문 「(인/992㎡당)」** 으로 물렸다. 머리가 소실돼 원문을 못 읽던 때 흔한 밑수로 메운 값이었다. 이 고리의 잣대는 「원문이 정본」이므로 사람 판정이 물러난다(2026-09-18 브레인 · 코덱스가 PDF 에서 되살린 줄). ⚠ 100 과 992 는 열 배 가까이 달라 **금액이 크게 갈리는 자리**다 — 되돌릴 때도 근거를 보고 할 것. """ by_id = {t["pum_table_id"]: t for it in master["work_items"] for t in it["tables"]} for table_id in ("F0140", "F0141", "F0142"): assert by_id[table_id]["basis_quantity"] == 992.0, table_id assert by_id[table_id]["basis_unit"] == "㎡", table_id assert "992" in by_id[table_id]["basis_source"], table_id def test_밑수_미확보를_둘로_가른다(master: dict) -> None: """⚠ 「원문 부재」는 **채울 것이 아니다** — 없는 값을 지어내면 안 된다. 미확보 87 중 **75 는 원문에 밑수가 아예 없고**, **12 만 원문이 적었는데 우리가 못 읽은 것**이다 (2026-09-18 안티그래비티 3바퀴 · 브레인). 목록에서 갈라 두면 다음 바퀴에 75 가 또 「할 일」로 세어지지 않는다. """ import json as _json from pathlib import Path as _Path stats = master["stats"] assert stats["basis_missing"] == 79 # 2026-09-18 합침 70 → 71 → 78 — **거절 자리를 「채울 것 아님」 쪽으로 옮겼다.** # 원문이 적은 것이 「(인/일)」·「㎥/hr」·「회당」이면 그것은 값의 단위이지 밑수가 아니다. assert stats["basis_absent_in_source"] == 78 # 2026-09-18 「당」 없이 몫으로만 적힌 자리(「(인/100㎡)」·「(대/ton)」)를 가리게 해 다섯을 더 채움. # 미판독 12 → 9 → 10(가림이 넓어져 새로 드러난 것 포함) · 확보 243 → 251. # ⚠ 남은 아홉은 **거절 자리**다 — 「(단위 : 인)」 셋(2026-09-09 오독 판정) · # 「㎥/1인/1일」·「1ha당, 100본당」·「1대 1조, 시간당」·「인 당」은 분모가 둘 이상이다. # 2026-09-18 10 → 8 — **표 머리 칸까지 읽게 하자** 8-6-2·8-6-3 드론방제 「ha당」 둘이 채워짐 # 2026-09-18 8 → 1 — 거절 자리(빗금 단위·회당·밑수 둘)를 갈라내니 **진짜 할 일은 하나**다 # (F0237 「1대 1조, 시간당」 — 무엇이 밑수인지 사람이 봐야 함). assert stats["basis_unparsed"] == 1 assert stats["basis_absent_in_source"] + stats["basis_unparsed"] == stats["basis_missing"] items = _json.loads( _Path("resources/data_work_item_master/basis_missing_2026-01-01.json").read_text( encoding="utf-8" ) )["items"] assert all(i["why"] for i in items) # 사유 없는 줄이 없어야 한다 # 「공㎥」 처럼 단위 목록에 없던 자리는 사람 판정으로 채웠다 — 목록을 넓히면 규격이 밑수가 된다 by_id = {t["pum_table_id"]: t for it in master["work_items"] for t in it["tables"]} assert by_id["F0366"]["basis_unit"] == "공㎥" for table_id in ("F0050", "F0052", "F0224", "F0090"): assert by_id[table_id]["basis_quantity"] == 1.0 and by_id[table_id]["basis_unit"] == "ha" # 「당」 글자 없이 몫으로만 적힌 자리도 읽는다 — 「(인/100㎡)」·「(대/ton)」 assert by_id["F0089"]["basis_quantity"] == 100.0 and by_id["F0089"]["basis_unit"] == "㎡" assert by_id["F0191"]["basis_unit"] == "톤" def test_밑수가_아닌_글을_밑수로_안_읽는다(master: dict) -> None: """⚠ 비고의 「1인당」은 **기계 한 대에 붙는 인원** 말이지 공종 밑수가 아니다. 2026-09-18 안티그래비티 PDF 대조 — 「인은 밑수가 아니다」는 2026-09-09 판정이 옳았고 **파서가 틀린 것**이었다(제근 9-21 때와 반대 — 그때는 판정이 물러났다). ⇒ 사람 판정으로 **비워 두고**, 미확보 목록에 「원문 부재」 사유를 달아 다음 바퀴에 또 안 세어지게 한다. """ import json as _json from pathlib import Path as _Path by_id = {t["pum_table_id"]: t for it in master["work_items"] for t in it["tables"]} for table_id in ("F0046", "F0068"): assert by_id[table_id]["basis_quantity"] is None, table_id assert by_id[table_id]["basis_unit"] is None, table_id items = { i["pum_table_id"]: i for i in _json.loads( _Path("resources/data_work_item_master/basis_missing_2026-01-01.json").read_text( encoding="utf-8" ) )["items"] } assert items["F0046"]["why"].startswith("원문 부재") # 집계가 이 낱말로 가른다 assert "F0068" not in items # 계수표라 애초에 목록에 안 든다 # 3-2 작업로 선정은 목적물 1km 가 **정당한 밑수**다 — 값은 소요인력이라 소요량형 assert by_id["F0076"]["pum_form"] == "requirement" assert by_id["F0076"]["basis_quantity"] == 1.0 and by_id["F0076"]["basis_unit"] == "km" def test_원문_대조_밑수_판정(master: dict) -> None: """원문 md ↔ 마스터 전수 대조(안티그래비티 02544795)에서 나온 밑수를 반영했는가. ⚠ **보고가 적은 단위를 그대로 믿지 않는다.** 보고는 「인」을 밑수로 적은 자리가 셋 있었고 그 자리(8-6-2 드론방제·8-6-3 지상방제)는 2026-09-09 에 오독으로 판정된 바로 그 표다. 원문 줄을 열어 괄호 글을 확인한 것만, 그것도 **분모가 하나일 때만** 싣는다. """ by_id = {t["pum_table_id"]: t for it in master["work_items"] for t in it["tables"]} for table_id, quantity, unit in ( ("F0082", 1.0, "ha"), # (단위 : 인/ha) ("F0311", 1.0, "기"), # (단위: 1기당) ("F0424", 1.0, "㎡"), # (단위: 인/㎡) ("F0430", 1.0, "일"), # ⚠ 왜곡 고침 — 「개」로 서 있었음 ): assert by_id[table_id]["basis_quantity"] == quantity, table_id assert by_id[table_id]["basis_unit"] == unit, table_id # 「인」은 밑수가 아니다 — 보고가 그렇게 적었어도 안 넣는다 for table_id in ("F0228", "F0229", "F0230"): assert by_id[table_id]["basis_unit"] != "인", table_id # ⚠ 3-2 작업로 선정은 2026-09-18 PDF 대조로 **목적물 1km 가 정당**함이 확인됐다(반쪽 아님) assert by_id["F0076"]["basis_unit"] == "km" # ⚠ **작업조 표는 밑수를 비워 둔다** — 원문이 「(단위: 일 당)」이라도 넣지 않는다. # 밑수가 시공량 열이라 「1일」을 박으면 그 길이 막혀 호표가 통째로 사라진다 # (2026-09-17 유로폼 12-38-3 에서 실제로 사라져 드러남). for table_id in ("F0339", "F0340", "F0341", "F0395", "F0449"): assert by_id[table_id]["crew_table"] is True, table_id assert by_id[table_id]["basis_unit"] is None, table_id def test_표머리_몫꼴_밑수_읽기() -> None: """「당」 없이 몫으로만 적힌 표머리에서 밑수를 읽는다 — 분모가 밑수다(명세 꼴 B). ⚠ 반쪽만 읽느니 「미확보」가 낫다 — 분모가 둘이거나(`ℓ/일,대`) 표머리끼리 어긋나면 거절한다. """ from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Table import basis_from_header, detect_basis assert basis_from_header("(단위 : 인/ha)") == (1.0, "ha") assert basis_from_header("소요인력 (인/100kg)") == (100.0, "kg") assert basis_from_header("소요인력(인/본당)") == (1.0, "본") assert basis_from_header("재료비(1km 소요량기준)") == (1.0, "km") assert basis_from_header("주연료 (ℓ/일,대)") == (None, None) # 분모가 둘 — 거절 assert basis_from_header("기계명(주재료)") == (None, None) # 몫이 아님 # 표머리끼리 어긋나면 표 전체를 미확보로 둔다(건설 8장 기계경비표) 표 = {"headers": ["분류번호", "주연료 (ℓ/hr)", "조종원 (인/일)"], "rows": []} assert detect_basis(표) == (None, None) # 하나로 모이면 읽는다 표2 = {"headers": ["구 분", "정리산물(㎥/ha)", "비고"], "rows": []} assert detect_basis(표2) == (1.0, "ha") def test_밑수_채움은_더하기만_함(master: dict) -> None: """⚠ 이미 있던 밑수를 덮지 않는다 — 덮으면 금액이 조용히 움직인다. 표 안 칸(`BASIS_RE`)이 먼저고, 표머리 몫꼴은 **그 뒤**에만 본다. """ from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Table import detect_basis 표 = {"headers": ["(단위 : 인/ha)", "100㎡당"], "rows": []} assert detect_basis(표) == (100.0, "㎡") # 「당」이 붙은 쪽이 이긴다 def test_어느_구실도_지우지_않음() -> None: """브레인 승인(2026-09-17) — 「덜어내기」가 아니라 「가름만」.""" import json as _json from pathlib import Path as _Path policy = _json.loads( _Path("resources/data_work_item_master/axis_policy.json").read_text(encoding="utf-8") ) assert policy["policy"]["never_delete"] is True assert policy["policy"]["flat_list_filter"] == "pum_tables > 0" assert all(role["keep"] for role in policy["roles"].values()) assert list(policy["roles"]) == ["work_item", "group", "general_provision", "empty"] def test_경로_이름_잇기() -> None: nodes = [ {"work_item_code": "A", "parent_code": None, "name": "토공"}, {"work_item_code": "A-1", "parent_code": "A", "name": "토사깍기"}, {"work_item_code": "A-1-1", "parent_code": "A-1", "name": "인력"}, ] assert path_names(nodes)[2] == "토공 › 토사깍기 › 인력" def test_고시_번호를_원문_경로에서_읽음() -> None: sources = [ {"path": "resources/knowledge/original/…/(산림청고시 제2025-82호) 산림사업 표준품셈.md"} ] assert toc_edition(sources, "2026-01-01") == "산림청고시제2025-82호" assert toc_edition([], "2026-01-01") == "2026-01-01"