Files
Aislo/resources/tester/test_masterdata_check.py
T
eomsangdonandClaude Opus 5 a7643887ca feat(master_data): 로직 입력에 설명 칸 · 산림 테스트 대상 10개 입력 설명 채움
- `_틀.md` 7장 — 입력 칸 목록과 `설명`(쉬운 말 한 줄) · `시험입력` · 고르기 후보 단위 거르기 · 조건 값의 `{입력}` 자리
- 테스트 컨테이너 대상 10개 로직의 입력 97개에 설명 채움 — 서버는 줄을 그대로 내려보내 화면 도움말로 쓸 수 있음
- 로직_건설품셈_09장_미장공사.json 머리에 겹친 「부문」 칸 지움
- 시험 하나 더함

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj
2026-09-20 23:14:48 +09:00

987 lines
44 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""마스터 데이터 검사 도구 — 견본 셋 손 계산 · 견본 파일 검사 통과 (`resources/master_data/scripts/check_master.py`)."""
import sys
from decimal import ROUND_HALF_UP, Decimal
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "master_data/scripts"))
import check_master as cm # noqa: E402
import master_formula as mf # noqa: E402
import master_keys as mk # noqa: E402
import master_material as mm # noqa: E402
SAMPLES = [
"계수_건설품셈_08장_건설기계.json",
"계수_산림품셈_09장_토공.json",
"기계.json",
"로직_건설품셈_03장_토공사.json",
"로직_건설품셈_04장_조경공사.json",
"로직_건설품셈_07장_돌공사.json",
"로직_건설품셈_08장_건설기계.json",
"로직_건설품셈_01장_도로포장공사.json",
"로직_건설품셈_02장_하천공사.json",
"로직_건설품셈_03장_터널공사.json",
"로직_건설품셈_04장_궤도공사.json",
"로직_건설품셈_05장_강구조공사.json",
"로직_건설품셈_06장_관부설_및_접합공사.json",
"로직_건설품셈_07장_항만공사.json",
"로직_건설품셈_08장_지반조사.json",
"로직_건설품셈_09장_측량.json",
"로직_산림품셈_03장_작업장_관리.json",
"로직_산림품셈_04장_나무베기.json",
"로직_산림품셈_05장_식재_및_녹화.json",
"로직_산림품셈_06장_생육보조.json",
"로직_산림품셈_07장_집재_및_운재.json",
"로직_산림품셈_08장_방제.json",
"로직_산림품셈_10장_자재․장비_운반.json",
"로직_산림품셈_11장_가설.json",
"로직_산림품셈_13장_구조물.json",
"로직_산림품셈_14장_부대공.json",
"로직_산림품셈_09장_토공.json",
"소요량_산림품셈_13장_구조물.json",
]
EXCAVATOR = "GC000267" # 공통 8 굴착기(무한궤도) 시간당 경비
@pytest.fixture(scope="module")
def whole():
return cm.master()
@pytest.fixture(scope="module")
def faked():
"""값 없는 인력 줄(미확보·미공표) — 시험용 가짜 값(파일엔 안 씀)."""
files = cm.load()
for row in files["인력.json"]["줄"]:
if row["값"] is None:
row["값"] = Decimal(1000)
return mf.Master(files)
def cents(x) -> str:
return str(Decimal(x).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
def test_메쌓기(whole):
got = mf.run(
whole,
"GF000219", # 13-4-1 메쌓기
{"뒷길이": 35, "돌": "견치돌", "쌓기": "골쌓기", "높이": 2, "초과증가율": 80},
)
assert cents(got["계"]) == "203281.20"
def test_메쌓기_높이_증가율(whole):
got = mf.run(
whole,
"GF000219", # 13-4-1 메쌓기
{"뒷길이": 35, "돌": "견치돌", "쌓기": "골쌓기", "높이": 5, "초과증가율": 80},
)
assert [line["수량"] for line in got["줄"]] == [Decimal("0.70"), Decimal("0.56")]
def test_굴착기(whole):
got = mf.run(
whole, EXCAVATOR, {"기계": "0201-0070", "지역": "전국평균", "보정작업": "해당 없음"}
)
assert [cents(got[k]) for k in ("노무비", "재료비", "경비", "계")] == [
"59020.21",
"26130.11",
"24554.00",
"109704.32",
]
def test_굴착기_암석_보정(whole):
got = mf.run(
whole,
EXCAVATOR,
{"기계": "0201-0070", "지역": "전국평균", "보정작업": "암석작업(연암・보통암・경암)"},
)
assert got["중간"]["손료"] == 28322
assert cents(got["계"]) == "113472.32"
def test_불도저_덤프_로더_손료_보정(whole):
# 손료 = 가격 × 1000 × ((상각 + 정비) × (1 + 가산) + 관리) ÷ 10^7 · 원 미만 버림(8-1-7 1)
ref = "GC000269" # 공통 8 기계 시간당 경비(손료 보정)
rock, boulder = "암석작업(연암・보통암・경암)", "전석섞인토사"
for machine, work, cost in (
("0602-0150", rock, 24285), # 덤프트럭 25%
("0602-0150", "해당 없음", 20659),
("0101-0010", boulder, 32794), # 불도저 10%
("0301-0095", rock, 20985), # 로더(무한궤도) 20%
):
given = {"기계": machine, "지역": "전국평균", "보정작업": work}
assert mf.run(whole, ref, given)["중간"]["손료"] == cost
plain = mf.run(whole, "GC000268", {"기계": "0602-0150", "지역": "전국평균"})
assert plain["중간"]["손료"] == 20659
# 주① 불도저 19톤 이상은 가산 0
big = {"기계": "0101-0019", "지역": "전국평균"}
assert (
mf.run(whole, ref, {**big, "보정작업": rock})["중간"]["손료"]
== mf.run(whole, "GC000268", big)["중간"]["손료"]
)
def test_손료만_로직_보정과_불도저_운반(whole):
rock = "암석작업(연암・보통암・경암)"
ref = "GC000274" # 공통 8 기계 시간당 손료(손료 보정)
assert mf.run(whole, ref, {"기계": "0201-0070", "보정작업": rock})["중간"]["손료"] == 28322
assert mf.run(whole, ref, {"기계": "7620-0003", "보정작업": rock})["중간"]["손료"] == 1232
# 산림 10-11 발파암 = 암석작업 보정 경비 × 1/작업량
given = {"재료": "발파암", "기계": "0101-0010", "지역": "전국평균"}
got = mf.run(whole, "GF000149", given)
hour = mf.run(
whole,
"GC000269", # 공통 8 기계 시간당 경비(손료 보정)
{"기계": "0101-0010", "지역": "전국평균", "보정작업": rock},
)
assert got["줄"][0]["단가"] == hour["계"]
def test_노체포설(whole):
assert mf.run(whole, "GF000118", {})["결과"] == Decimal("24.255")
got = mf.run(whole, "GF000119", {"지역": "전국평균", "보정작업": "해당 없음"})
assert [cents(got[k]) for k in ("노무비", "재료비", "경비", "계")] == [
"2433.32",
"1077.31",
"1012.33",
"4522.96",
]
def test_범위규칙_이상_미만(whole):
"""9-2 횡단경사 「60% 미만 · 60% 이상」 — 60 은 윗줄."""
qty = [mf.run(whole, "GF000086", {"횡단경사": x})["줄"][0]["수량"] for x in (59.9, 60)]
assert qty == [5, 10]
def test_분수_글자_값(whole):
"""9-4-2 f 원문 「1/1.40」 — 3600×0.7×0.55×(1/1.40)×0.35/20."""
assert mf.run(whole, "GF000090", {})["결과"] == Decimal("17.325")
def test_범위규칙_이하_위끝만(whole):
"""13-4-2 뒷길이 「35cm 이하 · 55cm 이하」 — 36 은 55 칸."""
region = {"지역": "전국평균", "보정작업": "해당 없음", "잡석량": 0}
qty = [
mf.run(whole, "GF000220", {"뒷길이": x, **region})["줄"][0]["수량"] for x in (35, 36, 75)
]
assert qty == [Decimal("0.1"), Decimal("0.09"), Decimal("0.08")]
def test_범위규칙_마지막칸_이하_포함(whole):
"""13-6-1 직경 「80㎝이상∼100㎝이하」 — 100 은 맞고 101 은 막힘."""
given = {"직경": 100, "지역": "전국평균", "보정작업": "해당 없음"}
assert mf.run(whole, "GF000226", given)["줄"][0]["수량"] == Decimal("0.68")
with pytest.raises(mf.FormulaError):
mf.run(whole, "GF000226", {**given, "직경": 101})
def test_굴착기_부착용_집게(whole):
"""잡재료 16%(8-4-8 주⑤) · 집게 손료 7,783천원×7435×10⁻⁷ 버림."""
got = mf.run(
whole,
"GC000270", # 공통 8 굴착기+부착용 집게 시간당 경비
{"기계": "0201-0060", "집게": "7206-0070", "지역": "전국평균", "보정작업": "해당 없음"},
)
assert got["중간"]["집게손료"] == 5786
assert cents(got["계"]) == "110075.69"
def test_불도저_운반_작업량(whole):
"""10-11 — q=3.2×0.96 · ㎝=20/55+20/70+0.25 · Q=60×q×(1/1.30)×0.55/㎝."""
got = mf.run(whole, "GF000148", {"재료": "토사"})
assert got["결과"] == Decimal("86.709")
def test_중기운반_트레일러_횟수(whole):
"""10-4 — ㎝=20+30+20+0.42 · N=60×0.9/㎝ · 1회 = 1/N 시간."""
given = {"t2": 30, "지역": "전국평균", "운반수단": "트레일러운반(20TON)"}
got = mf.run(whole, "GF000137", given)
assert got["줄"][0]["수량"].quantize(Decimal("0.0001")) == Decimal("1.3041")
def test_예정지정리_할증_합산(whole):
"""3-5 — 바이오매스 10 + 침엽수림 10 + 개벌지 10 + 산불 5×2/10 = 31% · 2.4×50%×1.31."""
given = {
"작업내용": "보완사업지 정리",
"정리량": 120,
"임상": "침엽수림",
"개벌지": "700㎡ 이하(또는 벌채폭 30m)",
"산불피해면적": 2,
"조림면적": 10,
}
got = mf.run(whole, "GF000007", given)
assert [line["수량"] for line in got["줄"]] == [Decimal("1.572"), Decimal("1.572")]
def test_작업안전_보조_올림(whole):
"""4-6 — 벌목부 4인당 보통인부 1인 · 5인이면 2인."""
qty = [mf.run(whole, "GF000015", {"벌목부": n})["줄"][0]["수량"] for n in (3, 4, 5, 8)]
assert qty == [1, 1, 2, 2]
def test_동력상하차기_잔존목_할증(whole):
"""7-11 — 40m · 4160㎥ · 1.2m = 22.03㎥ · 잔존목 10% · 소모성경비 0.02105×380만/50."""
given = {
"최대집재거리": 40,
"집재재적": 50,
"원목길이": 1.2,
"잔존목": "잔존목 100본 이상 130본 이하",
"지역": "전국평균",
}
got = mf.run(whole, "GF000069", given)
assert got["줄"][0]["수량"] == Decimal("1.1") / Decimal("22.03")
assert got["중간"]["소모성경비"] == Decimal("1599.8")
def test_방제_벌목조재_감_할증(whole):
"""8-3 — 경급 6 = 1.13㎥/인 · 소군락 −30% · 잣나무 +20% · 벌목부 50%."""
given = {
"경급": 6,
"소군락": "소군락 모두베기",
"방제수종": "잣나무",
"체인톱가격": 1200000,
"체인오일단가": 5000,
"지역": "전국평균",
}
got = mf.run(whole, "GF000076", given)
want = Decimal("0.7") * Decimal("1.2") * Decimal("0.5") / Decimal("1.13")
assert got["줄"][0]["수량"].quantize(Decimal("0.000001")) == want.quantize(Decimal("0.000001"))
def test_건설_석재판_습식_공구손료(whole):
"""공통 7-4-1 — 바닥부 테라조판 석공 3 · 줄눈공 1 · 보통인부 2 ÷ 15㎡ · 공구손료 인력품 2%."""
got = mf.run(whole, "GC000266", {"시공부위": "바닥부", "자재": "테라조판"})
assert [line["수량"] for line in got["줄"][:3]] == [
Decimal(3) / 15,
Decimal(1) / 15,
Decimal(2) / 15,
]
assert got["경비"] == got["노무비"] * Decimal("0.02")
def test_건설_정원석_난이도_가산(whole):
"""공통 4-4-1 — 쌓기 20ton 미만 조경공 1.212 × 1.1 · 굴착기 0.7㎥ 0.657hr 그대로."""
given = {
"작업": "쌓기",
"중량": "20ton 미만",
"난이도가산": 10,
"지역": "전국평균",
"보정작업": "해당 없음",
}
got = mf.run(whole, "GC000156", given)
assert [line["수량"] for line in got["줄"][:2]] == [Decimal("1.3332"), Decimal("0.657")]
def test_건설_벌목_집재거리_가산(whole):
"""공통 3-9-1 — 나무높이 6m 벌목부 2.8 · 집재 250m = 100m 초과 두 번 × 30%."""
given = {"나무높이": 6, "집재거리": 250, "지역": "전국평균", "보정작업": "해당 없음"}
got = mf.run(whole, "GC000129", given)
assert got["줄"][0]["수량"] == Decimal("4.48")
def test_건설_흙깎기_암_치즐(whole):
"""공통 3-2-3 — 풍화암 65㎥/일 · 굴착기+대형브레이커 8/65 시간 · 치즐 1%."""
given = {"암분류": "풍화암", "집토": "안 함", "지역": "전국평균", "보정작업": "해당 없음"}
got = mf.run(whole, "GC000087", given)
brk = got["줄"][1]
assert brk["수량"] == Decimal(8) / 65
assert got["줄"][-1]["금액"] == brk["금액"] / 100
def test_토목_동상방지층_인력_살수(whole):
"""토목 1-2-1 — 165㎥/일 · 인력 살수면 살수차 0 · 보통인부 100㎡당 1인 = 두께 20㎝ 에서 1/20 인/㎥."""
given = {"살수": "인력 살수", "두께": 20, "지역": "전국평균", "보정작업": "해당 없음"}
got = mf.run(whole, "GC000278", given)
qty = {line["이름"]: line["수량"] for line in got["줄"]}
assert qty["굴착기 0.6㎥"] == Decimal(8) / 165
assert qty["살수차 5,500"] == 0
assert got["줄"][-1]["수량"] == Decimal(1) / 20
def test_토목_보도블록_감(whole):
"""토목 1-7-1 — B-Type 190㎡/일 × 0.9(유도블록) · 정밀절단 아니면 특별인부 2 - 1."""
given = {
"형식": "B-Type",
"유도블록": "유도・점자블록",
"정밀절단": "아님",
"지역": "전국평균",
"보정작업": "해당 없음",
}
got = mf.run(whole, "GC000303", given)
assert got["줄"][1]["수량"] == Decimal(1) / Decimal(171)
def test_토목_블록붙이기_굴착기_대체(whole):
"""토목 2-3-3 주④ — 크레인 대신 굴착기 0.2㎥ 0.063hr · 크레인 0."""
given = {"장비": "굴착기", "크레인": "2104-0010", "지역": "전국평균", "보정작업": "해당 없음"}
got = mf.run(whole, "GC000345", given)
assert [line["수량"] for line in got["줄"][2:]] == [0, Decimal("0.063")]
def test_칸_이름_정리_뒤_기계_줄(whole):
"""공통 3-4-8 — 괄호·빈칸 칸 이름을 고친 뒤 모래지정은 플레이트 콤팩터만(8/110)."""
given = {"지정": "모래지정", "지역": "전국평균", "보정작업": "해당 없음"}
got = mf.run(whole, "GC000110", given)
qty = {line["이름"]: line["수량"] for line in got["줄"]}
assert round(qty["플레이트 콤팩터 1.5ton"], 4) == Decimal("0.0727")
assert qty["진동롤러(핸드가이드식) 0.7ton"] == 0
def test_토목_관부설_시공조건_요율(whole):
"""토목 6-2-2 — 열쇠 「K.P」 점을 식이 읽음 · 시공조건 B 는 시공량 133%."""
given = {"관경": 80, "시공조건": "시공조건 B", "압륜가산": 0, "요율구간": "300mm이하"}
got = mf.run(whole, "GC000385", given)
assert round(got["줄"][0]["수량"], 4) == round(Decimal(3) / (Decimal(13) * Decimal("1.33")), 4)
def test_토목_터널굴착_장대할증(whole):
"""토목 3-2-5 — A군 싸이클 480분 · 갱구 1,200m(α1 20%) = 1인 × 480/60/8 × 1.2."""
given = {
"공법군": "발파굴착A군",
"싸이클": 480,
"착암공인원": 3,
"보통인부인원": 3,
"갱구거리": 1200,
"병렬터널": "아님",
}
got = mf.run(whole, "GC000346", given)
assert got["줄"][0]["수량"] == Decimal("1.2")
def test_토목_궤광조립_직결형_단선감(faked):
"""토목 4-3-1 — 직결형 시공량은 분리형 250m(병합 칸) · 단선 5% 감 = 궤도공 16 ÷ 237.5."""
given = {"단선감": 5, "구분": "침목매립식(직결형)", "지역": "전국평균", "보정작업": "해당 없음"}
got = mf.run(faked, "GC000355", given)
assert got["줄"][0]["수량"] == Decimal(16) / Decimal("237.5")
def test_토목_강교_제작공수_보정(whole):
"""토목 5-1-2 — 연수 2 는 「이상·이하」 [2,2] · 총중량 표 범위규칙 뒤 괄호 설명은 뺌."""
given = {
"형식": "박스거더",
"고강도강비율": Decimal("0.5"),
"대형단품평균중량": 12,
"부품평균중량": 120,
"연수": 2,
"총중량": 600,
"사각": 60,
"곡률반경": 200,
"높이곡선변화": "있음",
"제작도": "별도 계상",
}
got = mf.run(whole, "GC000373", given)
# 0.95 × (1 + 0.25×0.5) × 0.97 × 0.95 × (1 0.03 0.02 + 0.25 + 0.11)
want = Decimal("0.95") * Decimal("1.125") * Decimal("0.97") * Decimal("0.95") * Decimal("1.31")
assert got["줄"][0]["수량"] == want
def test_자체_직종으로_세운_절(faked):
"""건설노임에 없는 직종(고급·중급기술자 · 중급숙련기술자)을 엔지니어링노임으로 세운 줄."""
def qty(ref, given):
return {x["이름"]: x["수량"] for x in mf.run(faked, ref, given)["줄"]}
# 3-11-1 조립 1일 + 해체 1일 — 인 × 2
got = qty("GC000132", {}) # 공통 3-11-1 머신 가이던스(MG) 굴착기 장비조립・해체
assert [got[w] for w in ("고급기술자(건설)", "중급기술자(건설)", "용접공")] == [2, 2, 2]
# 5-1-3 주① 제작도 — 박스거더 0.4인/톤 × 본체 보정계수 1.31
given = {
"형식": "박스거더",
"고강도강비율": 0,
"대형단품평균중량": 12,
"부품평균중량": 120,
"연수": 2,
"총중량": 600,
"사각": 60,
"곡률반경": 200,
"높이곡선변화": "있음",
"제작도": "이 품에 포함",
}
# 토목 5-1-1 기본제작공수
assert qty("GC000373", given)["중급숙련기술자(건설)"] == Decimal("0.4") * Decimal("1.31")
def test_토목_배송관_철거_크레인_규격(whole):
"""토목 7-4-1 — 관경 760 이상은 15톤 크레인 · 철거 감률은 인력·장비 모두."""
given = {
"관경": "840",
"접합방식": "플랜지접합",
"작업": "철거",
"철거감률": 30,
"지역": "전국평균",
}
got = {x["이름"]: x["수량"] for x in mf.run(whole, "GC000427", given)["줄"]}
assert got["배관공(수도)"] == Decimal("0.12") * Decimal("0.7")
assert got["트럭탑재형 크레인 10ton"] == 0
assert got["트럭탑재형 크레인 15ton"] == Decimal("0.47") * Decimal("0.7")
def test_토목_보링_심도_가산():
"""토목 8-1-2 — 깊이 35m = 20m 넘는 15m 를 10m 단위 올림 2번 × 5% · 인력만 · 쵸핑비트는 호박돌층만."""
files = cm.load()
for row in files["인력.json"]["줄"]:
if row["값"] is None: # 관리자가 채울 값 — 시험용 가짜 값(파일엔 안 씀)
row["값"] = Decimal(1000)
files["재료_자재품목.json"]["줄"].append(
{"키": "MT999999", "원문번호": "가짜", "물가자료": Decimal(1000)}
)
for row in files["재료_품셈재료.json"]["줄"]:
row["연결"] = row["연결"] or "MT999999" # 못 이은 재료 — 시험용 가짜 연결
given = {"층": "점토층", "규격": "BX", "깊이": 35, "심도가산율": 5}
got = mf.run(mf.Master(files), "GC000431", given)
qty = {x["이름"]: x["수량"] for x in got["줄"]}
assert qty["중급기술자(건설)"] == Decimal("0.16") * Decimal("1.1")
assert qty["싱글코아바렐"] == Decimal("0.01")
assert qty["쵸핑비트"] == 0
def test_토목_측량_계산예():
"""토목 9-1-2 원문 계산예 — 구하는점 6·주어진점 4·산지 = 인원 × 10/16 × 1.2 × 1.12 · 9-4-3-1 설계예 1km 당 × 연장."""
files = cm.load()
for row in files["인력.json"]["줄"]:
if row["값"] is None: # 관리자가 채울 값 — 시험용 가짜 값(파일엔 안 씀)
row["값"] = Decimal(1000)
master = mf.Master(files)
given = {"구하는점": 6, "주어진점": 4, "지형": "산지"}
got = {x["이름"]: x["수량"] for x in mf.run(master, "GC000453", given)["줄"]}
names = ("특급기술자(건설)", "고급기술자(건설)", "중급기술자(건설)", "인부")
assert [round(got[w], 2) for w in names] == [
Decimal("2.52"),
Decimal("20.58"),
Decimal("33.60"),
Decimal("8.40"),
]
given = {"도로연장": 10, "지형": "그 외 지역"}
got = {x["이름"]: x["수량"] for x in mf.run(master, "GC000482", given)["줄"]}
assert got["특급기술자(건설)"] == 17 and got["중급기술자(건설)"] == 198
def test_범위_입력은_막음(whole):
with pytest.raises(mf.FormulaError):
mf.run(
whole,
"GF000219", # 13-4-1 메쌓기
{"뒷길이": 35, "돌": "견치돌", "쌓기": "골쌓기", "높이": 8, "초과증가율": 120},
)
def test_견본_파일_검사_통과(whole):
files = cm.load(SAMPLES)
assert len(files) == len(SAMPLES)
assert [x for name, data in files.items() for x in cm.check_form(name, data)] == []
# 9장 계수 표는 같은 절의 소요량 표와 함께 대조 — 소요량 파일 자체의 알림은 그 창 몫
with_09 = files | cm.load(["소요량_산림품셈_09장_토공.json"])
# 절 통째를 출처로 둔 다른 창 표(9-13 · 9-20)의 결손도 그 창 몫
# 8-4-8 주⑤ 표는 절의 운전경비 수가 기계 요소(표 그룹 밖)에 있어 결손으로 뜸
others = ("CF000037 9-13", "CF000041 9-20", "CC000074 8-4-8")
body = cm.check_body(with_09)
assert [
x for x in body if not x.startswith("소요량_산림품셈_09장") and not x.endswith(others)
] == []
assert cm.check_logics(files, whole) == []
def test_키_겹침과_대장을_잡음():
"""키는 전체에서 하나 · 대장의 원문번호(옛 열쇠)·다음 번호와 맞음."""
book = {"다음": {"CC": 2}, "키": {"CC000001": {"원문번호": "건축 1-1-1 계수", "파일": "가"}}}
row = {"키": "CC000001", "원문번호": "1-1-1"}
files = {
"계수_건설품셈_01장_철골공사.json": {"그룹": "계수", "원문": "건설품셈", "표": [row]},
"계수_건설품셈_01장_배관공사.json": {"그룹": "계수", "원문": "건설품셈", "표": [row]},
}
got = cm.duplicate_keys(files, book)
assert len(got) == 1 and "키 겹침" in got[0] # 원문번호(절 번호)는 겹쳐도 됨
got = cm.duplicate_keys(
{"가": {"그룹": "계수", "원문": "건설품셈", "표": [{**row, "키": "CC000002"}]}}, book
)
assert any("대장에 없는 키" in x for x in got)
assert cm.duplicate_keys(cm.load()) == []
# 표·로직 원문번호는 절 번호만(부문·제목 뗌) · 키 모양은 파일의 테이블ID
one = {"그룹": "계수", "원문": "건설품셈", "부문": "건축", "차례": Decimal(1), "판": "2026"}
name = "계수_건설품셈_01장_철골공사.json"
got = cm.check_form(name, {**one, "표": [{"키": "CC000001", "원문번호": "건축 1-1-1 계수"}]})
assert any("절 번호만" in x for x in got)
got = cm.check_form(name, {**one, "표": [row]})
assert not any("절 번호만" in x or "키 모양" in x for x in got)
got = cm.check_form(name, {**one, "표": [{**row, "키": "LB000001"}]})
assert any("키 모양" in x for x in got)
assert any("파일 이름" in x for x in cm.check_form("계수_건설품셈_건축1장.json", one))
def test_기계설비_강관_용접배관(whole):
"""1-1-2 관규격 15 · (배관공 3인 × 247,897 + 보통인부 1인 × 172,068) ÷ 83m · 기계경비 2%."""
got = mf.run(
whole,
"GC000725", # 기계설비 1-1-2 강관 용접배관
{"관규격": 15, "시공방법": "인력시공", "시공위치": "옥내일반배관"},
)
labor = (3 * Decimal(247897) + Decimal(172068)) / 83
assert cents(got["노무비"]) == cents(labor)
assert cents(got["경비"]) == cents(labor * Decimal("0.02"))
def test_기계설비_사각댐퍼_면적_가산(whole):
"""2-2-4 방화댐퍼 0.35㎡ · 0.1㎡ 초과 0.25㎡ = 증 3번 · 0.415 + 0.125×3 = 0.79인."""
got = mf.run(whole, "GC000750", {"댐퍼": "방화댐퍼", "댐퍼면적": 0.35})
assert got["줄"][0]["수량"] == Decimal("0.790")
def test_로직_검사가_틀린_열쇠를_잡음(whole):
row = {
"키": "GF999999",
"원문번호": "시험",
"입력": [{"이름": "지역", "고르기": ["전국평균", "없는곳"]}],
"중간": [{"이름": "x", "식": "찾기(CF000097, 없는조건='K').적용"}],
"호표": [{"종류": "재료", "요소": "MO:경유:{지역}", "수량": "y", "비목": "재료비"}],
"덧줄": [],
}
found, _ = mf.check_logic(whole, row)
assert any("없는조건" in x for x in found)
assert any("경유:없는곳" in x for x in found)
assert any("「y」" in x for x in found)
def test_돌고_도는_참조():
assert mf.find_loops({"가": {"나"}, "나": {"가"}}) == [["가", "나", "가"]]
def test_찾기_값칸_있는_줄만():
table = {
"키": "QC999999",
"조건": {"구분": "고르기", "높이": "고르기"},
"줄": [
{"구분": "비계공", "수량": 3},
{"높이": "4m이하", "시공량(m)": 100},
{"높이": "7m이하", "시공량(m)": 70},
],
}
assert mf.find(table, {"구분": "비계공"}, "수량")["수량"] == 3
assert mf.find(table, {"높이": "7m이하"}, "시공량(m)")["시공량(m)"] == 70
with pytest.raises(mf.FormulaError):
mf.find(table, {"높이": "7m이하"})
assert mf.parse("찾기(QC:시험, 높이='7m이하').'시공량(m)'")[2] == "시공량(m)"
assert mf.parse("찾기(QC:시험, '규준틀 높이'=높이).수량")[1][2][0][0] == "규준틀 높이"
assert mf.parse("찾기(QC:5-1-1 P.P마대 쌓기, 구분=구분).수량")[1][1] == "QC:5-1-1 P.P마대 쌓기"
def test_범위규칙_이상이하():
table = {
"키": "QC999999",
"조건": {"길이": "범위"},
"범위규칙": "이상·이하 · 겹친 누적 구간",
"줄": [
{"길이": [0, 10], "값": 1},
{"길이": [0, 20], "값": 2},
{"길이": [30, 40], "값": 3},
],
}
assert mf.find(table, {"길이": Decimal(10)})["값"] == 1
assert mf.find(table, {"길이": Decimal(15)})["값"] == 2
assert mf.find(table, {"길이": Decimal(30)})["값"] == 3
with pytest.raises(mf.FormulaError):
mf.find(table, {"길이": Decimal(25)})
def test_미공표_준용():
"""준용이 있으면 그 직종 값 · 없으면 제 값(미공표 직종은 4-라 산정값) · 둘 다 비면 멈춤."""
files = cm.load()
lines = [
{
"종류": "인력",
"이름": "연마공",
"요소": "LB:1032",
"수량": "2",
"비목": "노무비",
}
]
files["_시험"] = {"그룹": "로직", "원문": "자체", "줄": [{"키": "GX999999", "호표": lines}]}
row = mf.Master(files).get("LB:1032")
common = mf.Master(files).get("LB:1002")["키"] # 보통인부
assert row["상태"] == "산정" and row["값"] == Decimal(211645) # 회차마다 원 미만 버림
assert row["비고"].startswith("4-라 산정: 2025.9 210,772원")
got = mf.run(mf.Master(files), "GX999999", {})
assert "출처" not in got["줄"][0] and got["노무비"] == 2 * Decimal(211645)
row["준용"] = common # 값이 있어도 준용이 앞섬
got = mf.run(mf.Master(files), "GX999999", {})
assert got["줄"][0]["출처"] == "준용: 보통인부"
assert got["노무비"] == 2 * Decimal(172068)
lines[0]["요소"] = "LB:절단공" # 값도 준용도 없으면 멈춤 — 준용을 골라야 값이 섬
alias = mf.Master(files).get("LB:절단공")
alias["상태"], alias["값"], alias["준용"] = "미확보", None, None
with pytest.raises(mf.FormulaError):
mf.run(mf.Master(files), "GX999999", {})
alias["준용"] = common
assert mf.run(mf.Master(files), "GX999999", {})["줄"][0]["출처"] == "준용: 보통인부"
lines[0]["요소"] = "LB:1003" # 옛 직종 갱부를 품은 현행 줄(특별인부) — 값 그대로
got = mf.run(mf.Master(files), "GX999999", {})
assert got["노무비"] == 2 * Decimal(226122)
assert mf.Master(files).get("LB:1003")["옛이름"] == ["갱부"]
def test_추정은_준용_값에_표시(whole):
"""추정 = 작업 범위로 짐작한 대응 — 값은 준용 직종 것 그대로 · 출처는 근거 문서 · 호표에 「추정(준용: ○○)」."""
row = whole.get("LB:제관공")
target = whole.get("LB:1057")
assert row["상태"] == "추정" and row["구분"] == "건설업" and row["옛이름"] == []
assert row["값"] == target["값"] and row["단위"] == target["단위"]
assert row["출처"] == "건설품셈 기계설비 13-06"
assert whole.value("LB:제관공") == (target["값"], "추정(준용: 플랜트제관공)")
cut = whole.get("LB:절단공") # 사용자 지정 추정(옛 미확보 9 직종)
assert cut["상태"] == "추정" and cut["출처"] == "건설노임 통합표 2010.1"
assert whole.value("LB:절단공") == (whole.get("LB:1010")["값"], "추정(준용: 철판공)")
def test_노임_갈래_파일_원문_대조():
"""인력 한 테이블 — 구분별 줄 수 · 이름 표본 · 열 한 벌 · 상세구분 · 일시간."""
files = cm.load()
data = files["인력.json"]
rows = data["줄"]
count = {b: sum(r["구분"] == b for r in rows) for b in data["구분"]}
assert count == {
"건설업": 146, # 추정 14 = 제관공류 5 + 사용자 지정 9(옛 미확보)
"제조업": 132, # 추정 3 = 제도공·공작기계공·옵셋인쇄공
"엔지니어링": 57, # 추정 1 = 정보처리기사
"측량": 20,
"건설사업관리": 4,
"SW": 17,
"산림": 2,
}
assert {tuple(r) for r in rows} == {cm.LABOR_COLS}
# 「미공표」·「미확보」 는 남지 않음 — 미공표는 「산정」(원 미만 버림) · 미확보는 「추정」
assert {r["상태"] for r in rows} == set(cm.LABOR_STATUS) - {"미공표", "미확보"}
whole = mf.Master(files)
assert whole.value("LB:기술사(건설)")[0] == 467217
assert whole.value("LB:측량중급기술자")[0] == 251596
assert whole.value("LB:특급건설사업관리기술인")[0] == 399247
assert whole.value("LB:IT PM")[0] == 492039
assert whole.value("LB:나무의사")[0] == 326359
# 제조 미공표 산정 — 조사월마다 원 미만 버림(미공표직종_추적.md 5절 채택 열)
assert whole.value("LB000137")[0] == 90148 # 제과제빵떡제조원
assert whole.get("LB000137")["상태"] == "산정"
assert whole.get("LB:측량중급기술자")["구분"] == "측량"
assert whole.get("LB:기술사(건설)")["상세구분"] == "건설"
assert whole.get("LB:도화중급기능사")["상세구분"] == "기능계 도화"
assert data["구분"]["측량"]["상세구분"][0] == "기술계"
assert whole.get("LB:1020")["일시간"] == 6 # 잠수부(원문 4-가)
assert {r["일시간"] for r in rows} == {6, 8}
def test_품셈재료_연결_낮은_값과_출처(whole):
"""연결 하나 — 값 열 다섯 가운데 낮은 값 · 출처 = 「<키>.<열>」."""
row = whole.get("MT004176") # 시멘트
low = min(row[s] for s in mm.SLOTS if row.get(s) is not None)
value, source = mf.element(whole, "MP:시멘트", {})
assert value == low and source.split(".")[0] == row["키"]
def test_전력은_계약종별로_고름(whole):
"""전력은 자재품목이 아니라 유가전력 줄 — 계약종별은 로직 입력 · 그 안에서 낮은 값."""
assert whole.index["MT"].get("MT000006") is None # 자재품목 전력 2줄 뺌
with pytest.raises(mf.FormulaError): # 계약종별 없이는 값이 안 섬
mf.element(whole, "MP:전력", {})
value, source = mf.element(whole, "MP:전력", {mf.TARIFF: "주택용 저압"})
assert value == 120 and source == "MO000058.값"
def test_자재품목_열_한_벌():
"""모든 줄이 같은 열 한 벌(원문번호 없음) · 관급은 조달 값 · 기간은 비고 · 남은 가격정보는 비고에만."""
data = cm.load(["재료_자재품목.json"])["재료_자재품목.json"]
rows = {r["키"]: r for r in data["줄"]}
assert {tuple(r) for r in rows.values()} == {cm.COLS["자재품목"]}
assert "원문번호" not in cm.COLS["자재품목"] # 남의 품번 — 대조는 대장에만
steel = rows["MT001658"]
assert steel["관급"] == Decimal(1301590) and steel["비고"].startswith(
"관급: 조달청 다수공급자계약"
)
assert all(steel[s] is None for s in ("물가자료", "유통물가", "물가정보", "거래가격"))
# 제조사별로 값이 갈린 줄 — 같은 규격 가운데 최저가와 그 제조사를 비고에
low = rows["MT001637"]
assert low["관급"] == Decimal(1228830) and "최저" in low["비고"]
left = [r for r in rows.values() if str(r["비고"] or "").startswith("가격정보")]
assert len(left) == 67 and all(r["관급"] is None for r in left)
# 유류 다섯 줄(값이 비고 글뿐)은 뗌 — 대장에 폐기로 남음
book = mk.load_book()
fuel = [f"MT{i:06d}" for i in range(1, 8)] # 6·7 = 전력 — 재료_유가전력으로 감
assert all(k not in rows and k not in book["키"] and k in book["폐기"] for k in fuel)
assert all(r["구분"] for r in rows.values()) # 채움 — build_자재품목_분류.py
def test_품셈재료_자재지역_후보(whole):
"""지역 재료 — 입력 없음 · 그 지역 줄 없음 · 규격 여럿은 멈춤 · 하나면 낮은 값."""
with pytest.raises(mf.FormulaError, match="입력 「자재지역」 없음"):
mf.element(whole, "MP:모래", {})
with pytest.raises(mf.FormulaError, match="지역 값 없음"):
mf.element(whole, "MP:모래", {"자재지역": "울진"})
with pytest.raises(mf.FormulaError, match="후보 여럿"):
mf.element(whole, "MP:레미콘", {"자재지역": "울진"})
value, source = mf.element(whole, "MP:모래", {"자재지역": "수원"})
assert source.startswith("MT") and value > 0
# ── 재료 고르기 조건 (`_틀.md` 7장) ────────────────────────────────────
def _pipes() -> mf.Master:
"""고르기 견본 — 자재품목 넉 줄 · 로직 한 줄(호표 요소가 고르기 조건)."""
rows = [
{
"키": "MT900001",
"구분": "관·수로",
"상세구분": "흄관",
"이름": "흄관",
"규격": "D300 B종",
"단위": "m",
"물가자료": None,
"관급": None,
},
{
"키": "MT900002",
"구분": "관·수로",
"상세구분": "흄관",
"이름": "흄관",
"규격": "D 300 B종",
"단위": "m",
"물가자료": Decimal(7000),
"관급": Decimal(6500),
},
{
"키": "MT900003",
"구분": "관·수로",
"상세구분": "흄관",
"이름": "흄관",
"규격": "D600 B종",
"단위": "m",
"물가자료": Decimal(12000),
"관급": None,
},
{
"키": "MT900004",
"구분": "관·수로",
"상세구분": "파형강관",
"이름": "파형강관",
"규격": "D300",
"단위": "m",
"물가자료": Decimal(9000),
"관급": None,
},
]
logic = {
"키": "GX900001",
"이름": "흄관 부설",
"입력": [],
"호표": [
{
"종류": "재료",
"요소": {"구분": "관·수로", "상세구분": "흄관", "규격": "D300"},
"이름": "흄관",
"단위": "m",
"수량": "2",
"비목": "재료비",
}
],
}
return mf.Master(
{
"재료_자재품목.json": {"그룹": "재료", "원문": "자재품목", "줄": rows},
"로직_자체.json": {"그룹": "로직", "원문": "자체", "줄": [logic]},
}
)
def test_재료_고르기_조건이_후보를_좁힘():
"""구분 → 상세구분 → 규격 낱말(빈칸·대소문자 안 가림) 차례로 좁아짐."""
whole = _pipes()
assert len(mm.candidates(whole, {"구분": "관·수로"})) == 4
assert len(mm.candidates(whole, {"구분": "관·수로", "상세구분": "흄관"})) == 3
narrow = mm.candidates(whole, {"구분": "관·수로", "상세구분": "흄관", "규격": "D300 B종"})
assert narrow == ["MT900001", "MT900002"] # 「D 300 B종」 도 같은 줄로 봄
assert mm.candidates(whole, {"구분": "없는구분"}) == []
def test_재료_고르기_시험계산은_대표_줄_없으면_값_있는_첫_줄():
whole = _pipes()
cond = {"구분": "관·수로", "상세구분": "흄관", "규격": "D300"}
assert mm.pick(whole, cond, {}) == "MT900002" # MT900001 은 값이 없어 건너뜀
value, source = mf.element(whole, cond, {})
assert value == 6500 and source == "MT900002.관급" # 값 열 다섯 가운데 낮은 값
assert mm.pick(whole, {**cond, "대표": "MT900001"}, {}) == "MT900001"
with pytest.raises(mf.FormulaError, match="후보 없음"):
mm.pick(whole, {"구분": "없는구분"}, {})
def test_재료_고르기_호표_줄이_돎():
"""호표 `요소` 가 조건 묶음이어도 옛 방식과 같은 길로 금액이 섬."""
got = mf.run(_pipes(), "GX900001", {})
assert got["재료비"] == 13000 and got["줄"][0]["단가"] == 6500
def test_재료_고르기_조건_검사():
whole = _pipes()
ok = {"구분": "관·수로", "상세구분": "흄관"}
assert mm.check_pick(whole, "어디", ok) == []
assert "조건 모양" in mm.check_pick(whole, "어디", {**ok, "지역": "울진"})[0]
assert "조건 모양" in mm.check_pick(whole, "어디", {"상세구분": "흄관"})[0]
assert "없는 구분" in mm.check_pick(whole, "어디", {"구분": "없는구분"})[0]
assert "없는 상세구분" in mm.check_pick(whole, "어디", {**ok, "상세구분": "없음"})[0]
assert "조건 밖" in mm.check_pick(whole, "어디", {**ok, "대표": "MT900004"})[0]
assert "자재품목에 없음" in mm.check_pick(whole, "어디", {**ok, "대표": "MT000001"})[0]
# 호표 줄 검사에도 같이 실림 — 종류가 재료가 아니면 잡음
row = dict(whole.get("GX900001"))
row["호표"] = [{**row["호표"][0], "종류": "기계"}]
assert any("종류 「재료」" in x for x in mf.check_logic(whole, row)[0])
def test_재료_고르기_자재지역():
"""지역이 갈리는 품목 — 이름에 그 지역이 든 줄부터 · 그런 줄이 없으면 조건 안 전부."""
rows = [
{
"키": "MT900011",
"구분": "레미콘",
"상세구분": None,
"이름": "레미콘-경상북도(포항시)",
"규격": "25-24-15",
"단위": "㎥",
"물가자료": Decimal(80000),
"관급": None,
},
{
"키": "MT900012",
"구분": "레미콘",
"상세구분": None,
"이름": "레미콘-경상북도(울진군)",
"규격": "25-24-15",
"단위": "㎥",
"물가자료": Decimal(90000),
"관급": None,
},
]
whole = mf.Master({"재료_자재품목.json": {"그룹": "재료", "원문": "자재품목", "줄": rows}})
cond = {"구분": "레미콘"}
assert mm.candidates(whole, cond, {mm.REGION: "울진"}) == ["MT900012"]
assert len(mm.candidates(whole, cond, {mm.REGION: "제주"})) == 2 # 없으면 조건 안 전부
assert mf.element(whole, cond, {mm.REGION: "울진"})[0] == 90000
def test_품셈재료_고르기_칸_검사():
"""품셈재료 「고르기」 = 조건 묶음이나 null · 조건은 호표 줄과 같은 검사를 거침."""
whole = _pipes()
pipes = whole.index["MT"]
linked = {
"키": "MP900001",
"원문번호": "흄관",
"이름": "흄관",
"규격": "D300",
"단위": "m",
"요구절": ["산림품셈 13-6-1"],
"연결": None,
"고르기": {"구분": "관·수로", "상세구분": "흄관"},
}
files = {
"재료_자재품목.json": {"그룹": "재료", "원문": "자재품목", "줄": list(pipes.values())},
"재료_품셈재료.json": {"그룹": "재료", "원문": "품셈재료", "줄": [linked]},
}
assert cm.check_links(mf.Master(files)) == []
linked["고르기"] = {"구분": "없는구분"}
assert any("없는 구분" in x for x in cm.check_links(mf.Master(files)))
linked["고르기"] = "관·수로"
assert any("고르기 모양" in x for x in cm.check_links(mf.Master(files)))
linked["고르기"] = None
assert cm.check_links(mf.Master(files)) == []
def test_고르기_조건_글자_열쇠():
"""화면 단가 미리보기가 조건 줄을 찾는 이름 — 빈 칸은 빈 글자."""
assert mm.cond_text({"구분": "관·수로", "상세구분": "흄관"}) == "관·수로|흄관||"
assert mm.cond_text({"구분": "레미콘", "대표": "MT900002"}) == "레미콘|||MT900002"
def test_품셈재료_고르기_칸이_실파일에_있음():
"""일괄 변환의 바탕 — 모든 품셈재료 줄에 고르기 칸 · 조건은 자재품목 묶음 안."""
whole = cm.master()
rows = whole.index["MP"]
assert rows and all("고르기" in row for row in rows.values())
assert any(isinstance(row["고르기"], dict) for row in rows.values())
assert cm.check_links(whole) == []
def test_로직_일괄_시험계산():
"""로직 전부를 시험 입력으로 돌림 — 멈춤은 데이터 결손(관리자 값·규격 미지정)만."""
files = cm.load()
passed, stuck = cm.calc_all(files, mf.Master(files))
assert passed >= 1272
남은_까닭 = (
"관리자가 채울 값",
"규격 선택 필요",
"지역 값 없음",
"맞는 줄",
"고르기 후보 없음",
)
assert [x for x in stuck if not any(c in x for c in 남은_까닭)] == []
def test_시험입력이_기본값을_이김():
"""「시험입력」 은 시험 계산 전용 칸 — 고르기 첫 값·범위 위끝을 덮어씀."""
row = {
"입력": [{"이름": "지역", "고르기": ["전국평균", "포항"]}, {"이름": "깊이", "범위": [1, 9]}]
}
assert cm.시험입력(row) == {"지역": "전국평균", "깊이": 9}
row["시험입력"] = {"지역": "포항"}
assert cm.시험입력(row)["지역"] == "포항"
def test_고르기_후보는_줄_단위와_같은_것만(whole):
"""호표 줄 단위와 다른 자재품목 줄은 후보에서 뺌 — 우드칩(㎥)에 원목후로링(㎡)이 섞이지 않음."""
cond = {"구분": "목재"}
전부 = mm.candidates(whole, cond)
한단위 = mm.candidates(whole, cond, None, "㎥")
assert 0 < len(한단위) < len(전부)
assert {mm.plain_unit(whole.get(k)["단위"]) for k in 한단위} == {mm.plain_unit("㎥")}
쓸줄 = mm.pick(whole, cond, {}, "㎥")
assert mm.plain_unit(whole.get(쓸줄)["단위"]) == mm.plain_unit("㎥")
def test_단위_경고는_로직_검사와_따로():
"""단위 경고는 조건을 좁히라는 알림 — 로직 검사(틀린 데이터)에 섞지 않음."""
files = cm.load()
whole = mf.Master(files)
assert cm.check_logics(files, whole) == []
warned = cm.check_units(files, whole)
assert warned and all("단위" in x for x in warned)
def test_고르기_조건의_입력_자리_채움(whole):
"""관경 같은 입력이 규격에 든 고르기 조건 — 계산 때 채워지고, 검사에서는 그 칸을 뺌."""
cond = {"구분": "관·이음관", "상세구분": "파형강관", "규격": "{관경}"}
assert mm.static(cond) == {"구분": "관·이음관", "상세구분": "파형강관"}
assert mf.check_pick(whole, "시험", cond) == []
채움 = {k: mf.fill(v, {"관경": Decimal(800)}) for k, v in cond.items()}
assert mm.candidates(whole, 채움, None, "m")
TEST_KEYS = (
"GF000181",
"GF000177",
"GF000173",
"GF000174",
"GF000160",
"GF000182",
"GF000069",
"GF000219",
"GF000119",
"GF000137",
)
def test_테스트_대상_로직_입력에_설명(whole):
"""화면 도움말 — 테스트 컨테이너 대상 10 개는 입력마다 쉬운 말 한 줄."""
for key in TEST_KEYS:
specs = whole.logic(key).get("입력") or []
assert specs, key
빈칸 = [s["이름"] for s in specs if not str(s.get("설명") or "").strip()]
assert 빈칸 == [], (key, 빈칸)