**① 확인 대기 가르기** — 「다 사유 있음」으로 뭉쳐 있던 것을 갈래로 나눠 **무엇이 진짜 할 일인지** 보이게 함
㉮ **영영 할 일 아님 41** — 「일당」은 품의 단위 · 작업조 표(넣으면 호표가 사라짐) · 「인/조」 ·
사람 판정으로 닫힌 표 성격 7 · 비목 구성표 7 따위. **집계에서 갈라 둠**(다음 바퀴에 또 안 세어짐)
㉯ **남이 고쳐야 풀림 23** — [주] 17(코덱스 md 읽기 넓히기) · 밑수 5(마스터가 맞고 md 가 덜 읽음) ·
F0333 원문 md 의심(코덱스 PDF 대조)
㉰ **사용자 판단 필요 11** — ⚠ 아래 셋이 제근 9-21 같은 자리
· F0046·F0068 마스터 밑수가 「1인」 — 「인」은 밑수가 아니라는 2026-09-09 판정과 어긋남
· F0076 「1km」는 반쪽 — 원문은 「인/1일 1km당」으로 분모가 둘
· 그 밖 부록 표 셋(떼어 낼지) · F0405 증가율표 성격 · 토량환산계수 2 · 이형철근 범위 · rebar 1
**② 「당」 없이 몫으로만 적힌 자리를 읽게 함** — 「(인/100㎡)」·「(인/ha)」·「(인/㎡)」·「(대/ton)」.
⚠ 이 구멍으로 다섯 표가 「원문 부재」로 **잘못 세어지고 있었음**(실은 원문이 적어 둔 것).
가림을 넓히고 그 다섯을 사람 판정으로 채움 ⇒ 밑수 확보 246 → **251** · 미확보 84 → 79
(원문 부재 69 + 미판독 10)
시험 — 장부 비교에서 **사람이 적은 메모 칸**(`why`·`class`·`class_why`)을 빼도록 함(자료가 아니라 설명)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsGw1Dz9HmhuAxGisxmDPF
586 lines
31 KiB
Python
586 lines
31 KiB
Python
"""공종 축 불변 열쇠 검사 — 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` 가 자리를 잼 · 단가표 대조는 그 파일 머리.
|
||
BASELINE_WORK_ITEMS_SHA = "803898a371e82c8a66692807af189a646c9b6fb3f077a4054cf6ef6030d8341b"
|
||
|
||
#: 열쇠 작업이 새로 얹은 칸. 지문을 잴 때만 떼어낸다.
|
||
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"]))
|
||
assert len(keys) == 332 # 갈래 수
|
||
assert len(set(keys)) == len(keys) # 겹침 0
|
||
# 한 공종 안에서 번호는 1부터 빠짐없이
|
||
for it in items:
|
||
got = [v["variant_key"] for v in it.get("variants") or []]
|
||
assert got == [f"{i + 1:02d}" for i in range(len(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) == 332 # 2026-09-17 헛단위 13-3 갈래 3 뺌(335 → 332)
|
||
tilde = collections.Counter(ch for k in keys for ch in k if ch in "∼~")
|
||
assert tilde == {"∼": 26, "~": 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
|
||
assert stats["basis_absent_in_source"] == 69
|
||
# 2026-09-18 「당」 없이 몫으로만 적힌 자리(「(인/100㎡)」·「(대/ton)」)를 가리게 해 다섯을 더 채움.
|
||
# 미판독 12 → 9 → 10(가림이 넓어져 새로 드러난 것 포함) · 확보 243 → 251.
|
||
# ⚠ 남은 아홉은 **거절 자리**다 — 「(단위 : 인)」 셋(2026-09-09 오독 판정) ·
|
||
# 「㎥/1인/1일」·「1ha당, 100본당」·「1대 1조, 시간당」·「인 당」은 분모가 둘 이상이다.
|
||
assert stats["basis_unparsed"] == 10
|
||
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:
|
||
"""원문 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 작업로 선정은 원문이 「인/1일 1km당」이다
|
||
assert by_id["F0076"]["basis_unit"] != "ha"
|
||
# ⚠ **작업조 표는 밑수를 비워 둔다** — 원문이 「(단위: 일 당)」이라도 넣지 않는다.
|
||
# 밑수가 시공량 열이라 「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"
|