"""마스터 데이터 검사 도구 — 견본 셋 손 계산 · 견본 파일 검사 통과 (`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 · 41∼60㎥ · 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): """2-2-4 방화댐퍼 0.35㎡ · 0.1㎡ 초과 0.25㎡ = 증 3번 · 0.415 + 0.125×3 = 0.79인.""" got = mf.run( whole, "건설품셈:2-2-4 일반댐퍼(사각) 설치", {"댐퍼": "방화댐퍼", "댐퍼면적": 0.35} ) assert got["줄"][0]["수량"] == Decimal("0.790") 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)})