tmp/ 가 창끼리 안 건너가는 것이 확정돼(랩탑이 시간 두고 두 번 확인) 시험·예외가 저절로 건너가도록 git 안으로 옮김. 사용자 확정. - 내용은 하나도 안 고침 — 자리만 옮김. tmp/tests 는 남겨 둠. - 같은 이름이 이미 있던 64 개는 랩탑 것을 그대로 두고 건너뜀. - helper_b05_*.js 둘은 랩탑이 .cjs 로 이미 올린 것과 **줄바꿈만 다른 같은 내용**이라 복사본을 도로 뺌(시험이 .cjs 를 부름). - resources/tester/ 에서 전체 1176 통과 · 29 건너뜀 · 실패 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
753 lines
33 KiB
Python
753 lines
33 KiB
Python
"""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, "개소")}, 찾음
|