Files
Aislo/resources/tester/test_masterdata_check.py
T
eomsangdonandClaude Opus 5 0d7f6a8950 feat(master_data): 건설품셈 기계설비 1장 로직 19개 — 배관공사
- 강관·동관·스테인리스·주철관·경질관·연질관 접합 및 배관(일당 표는 시공량으로 나눠 m·개소당)
- 고소작업대 시공(품 감산·시공량 가산) · 시공위치 요율 · TIG 가산은 입력 고르기 · 겹침은 공통 1-4-2 합산
- 공구손료 및 경장비 덧줄(인력품 %) · Brazing·TIG 소모재료 줄 · 재료_자체 3줄(플럭스·TIG 용접봉·아르곤가스)
- 기계설비 1~6장 계수 표 열쇠를 「절 이름 계수」로 — 건축 계수 표와 열쇠 겹침(먼저 읽힌 건축 표가 잡히던 것)
- check_master 로직 검사에 열쇠 겹침 · 시험 2개

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PAdA5ThqmtVSsbzjusk1cJ
2026-09-19 19:13:43 +09:00

391 lines
16 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
SAMPLES = [
"계수_건설품셈_공통8장.json",
"계수_산림품셈_9장.json",
"기계_건설품셈.json",
"로직_건설품셈_공통3장.json",
"로직_건설품셈_공통4장.json",
"로직_건설품셈_공통7장.json",
"로직_건설품셈_공통8장.json",
"로직_건설품셈_토목1장.json",
"로직_건설품셈_토목2장.json",
"로직_산림품셈_03장.json",
"로직_산림품셈_04장.json",
"로직_산림품셈_05장.json",
"로직_산림품셈_06장.json",
"로직_산림품셈_07장.json",
"로직_산림품셈_08장.json",
"로직_산림품셈_10장.json",
"로직_산림품셈_11장.json",
"로직_산림품셈_13장.json",
"로직_산림품셈_14장.json",
"로직_산림품셈_9장.json",
"소요량_산림품셈_13장.json",
]
EXCAVATOR = "건설품셈:8 굴착기(무한궤도) 시간당 경비"
@pytest.fixture(scope="module")
def whole():
return cm.master()
def cents(x) -> str:
return str(Decimal(x).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
def test_메쌓기(whole):
got = mf.run(
whole,
"산림품셈:13-4-1 메쌓기",
{"뒷길이": 35, "돌": "견치돌", "쌓기": "골쌓기", "높이": 2, "초과증가율": 80},
)
assert cents(got["계"]) == "203281.20"
def test_메쌓기_높이_증가율(whole):
got = mf.run(
whole,
"산림품셈: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):
assert mf.run(whole, "산림품셈:9-16-1 노체포설 작업량", {})["결과"] == Decimal("24.255")
got = mf.run(whole, "산림품셈: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, "산림품셈: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, "산림품셈:9-4-2 암절취 집토 작업량", {})["결과"] == Decimal("17.325")
def test_범위규칙_이하_위끝만(whole):
"""13-4-2 뒷길이 「35cm 이하 · 55cm 이하」 — 36 은 55 칸."""
region = {"지역": "전국평균", "보정작업": "해당 없음", "잡석량": 0}
qty = [
mf.run(whole, "산림품셈: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, "산림품셈:13-6-1 큰돌 메쌓기", given)["줄"][0]["수량"] == Decimal("0.68")
with pytest.raises(mf.FormulaError):
mf.run(whole, "산림품셈:13-6-1 큰돌 메쌓기", {**given, "직경": 101})
def test_굴착기_부착용_집게(whole):
"""잡재료 16%(8-4-8 주⑤) · 집게 손료 7,783천원×7435×10⁻⁷ 버림."""
got = mf.run(
whole,
"건설품셈: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, "산림품셈: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, "산림품셈: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, "산림품셈: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, "산림품셈: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, "산림품셈: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, "산림품셈: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, "건설품셈: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, "건설품셈: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%."""
got = mf.run(whole, "건설품셈:3-9-1 벌목", {"나무높이": 6, "집재거리": 250})
assert got["줄"][0]["수량"] == Decimal("4.48")
def test_건설_흙깎기_암_치즐(whole):
"""공통 3-2-3 — 풍화암 65㎥/일 · 굴착기+대형브레이커 8/65 시간 · 치즐 1%."""
given = {"암분류": "풍화암", "집토": "안 함", "지역": "전국평균", "보정작업": "해당 없음"}
got = mf.run(whole, "건설품셈: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, "건설품셈: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, "건설품셈: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, "건설품셈:2-3-3 블록 붙이기(기계)", given)
assert [line["수량"] for line in got["줄"][2:]] == [0, Decimal("0.063")]
def test_범위_입력은_막음(whole):
with pytest.raises(mf.FormulaError):
mf.run(
whole,
"산림품셈: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_열쇠_겹침을_잡음():
"""다른 부문 표와 같은 열쇠면 먼저 읽힌 표만 찾기에 걸림."""
files = {
"계수_건설품셈_건축1장.json": {
"그룹": "계수",
"원문": "건설품셈",
"표": [{"열쇠": "1-1-1 계수"}],
},
"계수_건설품셈_기계설비1장.json": {
"그룹": "계수",
"원문": "건설품셈",
"표": [{"열쇠": "1-1-1 계수"}],
},
}
assert len(cm.duplicate_keys(files)) == 1
assert cm.duplicate_keys(cm.load()) == []
def test_기계설비_강관_용접배관(whole):
"""1-1-2 관규격 15 · (배관공 3인 × 247,897 + 보통인부 1인 × 172,068) ÷ 83m · 기계경비 2%."""
got = mf.run(
whole,
"건설품셈: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):
row = {
"열쇠": "시험",
"입력": [{"이름": "지역", "고르기": ["전국평균", "없는곳"]}],
"중간": [{"이름": "x", "식": "찾기(계수:산림품셈:9-16-1 노체포설, 없는조건='K').적용"}],
"호표": [
{"종류": "재료", "요소": "재료:오피넷유가:경유:{지역}", "수량": "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 = {
"열쇠": "시험",
"조건": {"구분": "고르기", "높이": "고르기"},
"줄": [
{"구분": "비계공", "수량": 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("찾기(소요량:건설품셈:시험, 높이='7m이하').'시공량(m)'")[2] == "시공량(m)"
assert (
mf.parse("찾기(소요량:건설품셈:시험, '규준틀 높이'=높이).수량")[1][2][0][0] == "규준틀 높이"
)
assert (
mf.parse("찾기(소요량:건설품셈:5-1-1 P.P마대 쌓기, 구분=구분).수량")[1][1]
== "소요량:건설품셈:5-1-1 P.P마대 쌓기"
)
def test_범위규칙_이상이하():
table = {
"열쇠": "시험",
"조건": {"길이": "범위"},
"범위규칙": "이상·이하 · 겹친 누적 구간",
"줄": [
{"길이": [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)})