Files
Aislo/resources/tester/test_masterdata_check.py
T
eomsangdonandClaude Opus 5 6c9ac97b29 feat(master_data): 키 개편 — 테이블ID+6자리 키 · 인력·기계 한 테이블 · 장 파일 이름
- 모든 요소·표·로직 줄의 열쇠 → 키(LB000123 꼴) · 옛 열쇠·원문 번호는 원문번호 칸 · 대장 _키대장.json (키 36,840 · 다음 번호)
- 변수 적는 법 키로 — 로직 식·연결·준용·통합·후보·조달 연결 일괄 변환 · {이름} 낀 참조는 ID:원문번호
- 인력 8 파일 → 인력.json(줄마다 조사 · 머리 조사 묶음) · 기계 2 파일 → 기계.json(세부분류)
- 소요량·계수·로직 파일 이름 = 그룹_원문_NN장_장 제목 · 머리 부문·차례
- 엔진(값 찾기 · 알림에 키 옆 이름) · check_master(키 모양·겹침·대장·끊긴 키 · 기계 요소 줄도 본문 결손 대조) · M01 서버(새 줄 키는 대장 다음 번호 · 키 못 고침) · 화면 칸 이름만 맞춤
- 로직 일괄 시험 계산 1,351 줄 — 옮기기 전후 줄마다 결과·금액 같음

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

697 lines
31 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_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 = "GC:공통 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,
"GF:13-4-1 메쌓기",
{"뒷길이": 35, "돌": "견치돌", "쌓기": "골쌓기", "높이": 2, "초과증가율": 80},
)
assert cents(got["계"]) == "203281.20"
def test_메쌓기_높이_증가율(whole):
got = mf.run(
whole,
"GF: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 ("노무비", "재료비", "경비", "계")] == [
"35412.13",
"26130.11",
"24554.00",
"86096.24",
]
def test_굴착기_암석_보정(whole):
got = mf.run(
whole,
EXCAVATOR,
{"기계": "0201-0070", "지역": "전국평균", "보정작업": "암석작업(연암・보통암・경암)"},
)
assert got["중간"]["손료"] == 28322
assert cents(got["계"]) == "89864.24"
def test_불도저_덤프_로더_손료_보정(whole):
# 손료 = 가격 × 1000 × ((상각 + 정비) × (1 + 가산) + 관리) ÷ 10^7 · 원 미만 버림(8-1-7 1)
ref = "GC:공통 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, "GC:공통 8 기계 시간당 경비", {"기계": "0602-0150", "지역": "전국평균"})
assert plain["중간"]["손료"] == 20659
# 주① 불도저 19톤 이상은 가산 0
big = {"기계": "0101-0019", "지역": "전국평균"}
assert (
mf.run(whole, ref, {**big, "보정작업": rock})["중간"]["손료"]
== mf.run(whole, "GC:공통 8 기계 시간당 경비", big)["중간"]["손료"]
)
def test_손료만_로직_보정과_불도저_운반(whole):
rock = "암석작업(연암・보통암・경암)"
ref = "GC:공통 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, "GF:10-11 불도저 운반", given)
hour = mf.run(
whole,
"GC:공통 8 기계 시간당 경비(손료 보정)",
{"기계": "0101-0010", "지역": "전국평균", "보정작업": rock},
)
assert got["줄"][0]["단가"] == hour["계"]
def test_노체포설(whole):
assert mf.run(whole, "GF:9-16-1 노체포설 작업량", {})["결과"] == Decimal("24.255")
got = mf.run(whole, "GF:9-16-1 노체포설", {"지역": "전국평균", "보정작업": "해당 없음"})
assert [cents(got[k]) for k in ("노무비", "재료비", "경비", "계")] == [
"1459.99",
"1077.31",
"1012.33",
"3549.63",
]
def test_범위규칙_이상_미만(whole):
"""9-2 횡단경사 「60% 미만 · 60% 이상」 — 60 은 윗줄."""
qty = [
mf.run(whole, "GF:9-2 노선 굴진 보조원", {"횡단경사": 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, "GF:9-4-2 암절취 집토 작업량", {})["결과"] == Decimal("17.325")
def test_범위규칙_이하_위끝만(whole):
"""13-4-2 뒷길이 「35cm 이하 · 55cm 이하」 — 36 은 55 칸."""
region = {"지역": "전국평균", "보정작업": "해당 없음", "잡석량": 0}
qty = [
mf.run(whole, "GF:13-4-2 메쌓기(장비)", {"뒷길이": 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, "GF:13-6-1 큰돌 메쌓기", given)["줄"][0]["수량"] == Decimal("0.68")
with pytest.raises(mf.FormulaError):
mf.run(whole, "GF:13-6-1 큰돌 메쌓기", {**given, "직경": 101})
def test_굴착기_부착용_집게(whole):
"""잡재료 16%(8-4-8 주⑤) · 집게 손료 7,783천원×7435×10⁻⁷ 버림."""
got = mf.run(
whole,
"GC:공통 8 굴착기+부착용 집게 시간당 경비",
{"기계": "0201-0060", "집게": "7206-0070", "지역": "전국평균", "보정작업": "해당 없음"},
)
assert got["중간"]["집게손료"] == 5786
assert cents(got["계"]) == "86467.61"
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, "GF:10-11 불도저 운반 작업량", {"재료": "토사"})
assert got["결과"] == Decimal("86.709")
def test_중기운반_트레일러_횟수(whole):
"""10-4 — ㎝=20+30+20+0.42 · N=60×0.9/㎝ · 1회 = 1/N 시간."""
got = mf.run(whole, "GF:10-4 중기운반 트레일러", {"t2": 30, "지역": "전국평균"})
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, "GF:3-5 예정지정리", 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, "GF:4-6 벌목부 작업안전 보조", {"벌목부": 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, "GF:7-11 동력상하차기 집재", 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, "GF:8-3 벌목조재", 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, "GC:공통 7-4-1 습식공법", {"시공부위": "바닥부", "자재": "테라조판"})
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, "GC:공통 4-4-1 정원석 쌓기 및 놓기", 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, "GC:공통 3-9-1 벌목", given)
assert got["줄"][0]["수량"] == Decimal("4.48")
def test_건설_흙깎기_암_치즐(whole):
"""공통 3-2-3 — 풍화암 65㎥/일 · 굴착기+대형브레이커 8/65 시간 · 치즐 1%."""
given = {"암분류": "풍화암", "집토": "안 함", "지역": "전국평균", "보정작업": "해당 없음"}
got = mf.run(whole, "GC:공통 3-2-3 흙깎기(기계) 암", 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, "GC:토목 1-2-1 인력식 소규모장비 포설", 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, "GC:토목 1-7-1 보도용 블록 설치(소형)", 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, "GC:토목 2-3-3 블록 붙이기(기계)", 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, "GC:공통 3-4-8 기초지정", 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, "GC:토목 6-2-2 K.P 메커니컬 접합 및 부설 인력", 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, "GC:토목 3-2-5 작업인원", given)
assert got["줄"][0]["수량"] == Decimal("1.2")
def test_토목_궤광조립_직결형_단선감(faked):
"""토목 4-3-1 — 직결형 시공량은 분리형 250m(병합 칸) · 단선 5% 감 = 궤도공 16 ÷ 237.5."""
given = {"단선감": 5, "구분": "침목매립식(직결형)", "지역": "전국평균", "보정작업": "해당 없음"}
got = mf.run(faked, "GC:토목 4-3-1 궤광조립(콘크리트)", 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, "GC:토목 5-1-1 기본제작공수", 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, "GC:" + ref, given)["줄"]}
# 3-11-1 조립 1일 + 해체 1일 — 인 × 2
got = qty("공통 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,
"높이곡선변화": "있음",
"제작도": "이 품에 포함",
}
assert qty("토목 5-1-1 기본제작공수", 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, "GC:토목 7-4-1 배송관 접합", 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)
fake = {"시중": {"물가자료": Decimal(1000)}, "조달": {"값": None, "출처": None}}
files["재료_시중물가.json"]["줄"].append({"키": "MT999999", "원문번호": "가짜", "값": fake})
for row in files["재료_품셈재료.json"]["줄"]:
row["연결"] = row["연결"] or "MT999999" # 못 이은 재료 — 시험용 가짜 연결
given = {"층": "점토층", "규격": "BX", "깊이": 35, "심도가산율": 5}
got = mf.run(mf.Master(files), "GC:토목 8-1-2 천공(토사·자갈·호박돌층)", 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, "GC:토목 9-1-2 1급 기준점 측량", given)["줄"]
}
assert [round(got[w], 2) for w in ("특급기술자", "고급기술자", "중급기술자", "인부")] == [
Decimal("2.52"),
Decimal("20.58"),
Decimal("33.60"),
Decimal("8.40"),
]
given = {"도로연장": 10, "지형": "그 외 지역"}
got = {
x["이름"]: x["수량"]
for x in mf.run(master, "GC:토목 9-4-3-1 MMS측량 자료 이용", given)["줄"]
}
assert got["특급기술자"] == 17 and got["중급기술자"] == 198
def test_범위_입력은_막음(whole):
with pytest.raises(mf.FormulaError):
mf.run(
whole,
"GF: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 = ("9-13 관보호공 인력 가산", "9-20 대상 근주", "공통 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) == 2 and "키 겹침" in got[0] and "원문번호 겹침" in got[1]
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,
"GC:기계설비 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, "GC:기계설비 2-2-4 일반댐퍼(사각) 설치", {"댐퍼": "방화댐퍼", "댐퍼면적": 0.35}
)
assert got["줄"][0]["수량"] == Decimal("0.790")
def test_로직_검사가_틀린_열쇠를_잡음(whole):
row = {
"키": "GF999999",
"원문번호": "시험",
"입력": [{"이름": "지역", "고르기": ["전국평균", "없는곳"]}],
"중간": [{"이름": "x", "식": "찾기(CF:9-16-1 노체포설, 없는조건='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_미공표_준용():
"""값 없는 직종 — 차례 값 → 준용 → 통합 → 산정 · 호표 출처에 「준용: 이름」·「통합: 이름」·「산정: …」 · 다 없으면 멈춤."""
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["값"] is None and "준용" in row and row["산정"]["값"] == Decimal("211645.83")
got = mf.run(mf.Master(files), "GX999999", {})
assert got["줄"][0]["출처"] == "산정: 원문 4-라 · 마지막 공표 2025.9.1"
guess = row.pop("산정")
with pytest.raises(mf.FormulaError):
mf.run(mf.Master(files), "GX999999", {})
row["산정"] = guess
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:기술사")
assert alias["값"] is None and alias["준용"] is None and alias["조사"] == "준용대상"
with pytest.raises(mf.FormulaError):
mf.run(mf.Master(files), "GX999999", {})
alias["준용"] = common
assert mf.run(mf.Master(files), "GX999999", {})["줄"][0]["출처"] == "준용: 보통인부"
lines[0]["요소"] = "LB:갱부" # 원문 통합 목록의 옛 직종 — 새 직종 값
got = mf.run(mf.Master(files), "GX999999", {})
assert got["줄"][0]["출처"] == "통합: 특별인부"
def test_노임_갈래_파일_원문_대조():
"""인력 한 테이블 — 조사별 줄 수 · 이름 표본 · 준용대상에서 측량중급기술자 옮김."""
files = cm.load()
rows = files["인력.json"]["줄"]
count = {b: sum(r["조사"] == b for r in rows) for b in files["인력.json"]["조사"]}
assert count == {
"건설업": 132,
"제조업": 129,
"엔지니어링": 56,
"측량": 20,
"건설사업관리": 4,
"SW": 17,
"산림": 2,
"준용대상": 48,
}
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
assert whole.get("LB:측량중급기술자")["조사"] == "측량"
def test_품셈재료_연결_낮은_값과_출처(whole):
"""연결 하나 — 시중 다섯 칸 · 조달 가운데 낮은 값 · 출처 = 그 칸."""
row = whole.get("MT:M0005250064")["값"]
low = min(v for k, v in row["시중"].items() if k != "면수" and v is not None)
value, source = mf.element(whole, "MP:전력", {})
assert value == low and source.startswith(whole.get("MT:M0005250064")["키"] + ".시중.")
def test_시중물가_조달_묶음():
"""관급 줄 가격정보 → 조달 · 나라장터 짝은 값 없이 연결만 · 채택·견적가 칸 없음."""
master = cm.master()
steel = master.get("MT:M0005251695")["값"]
assert steel["조달"] == {"값": Decimal(1228830), "출처": "가격정보", "기준": "26.6.12~"}
assert (
steel["시중"]["가격정보"] is None and "채택" not in steel and "견적가" not in steel["시중"]
)
bolt = master.get("MT:M0005252462")
nara = master.get("MN:10023398")
assert bolt["값"]["조달"] == {"값": None, "출처": "나라장터:" + nara["키"], "기준": None}
assert (nara["값"], nara["키"]) in mm._offers(master, bolt["키"])
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