- 로직 줄에 「시험입력」 칸 — 시험 계산 전용(정본 계산은 화면 값만 씀) · 규격·직경은 원문 표 첫 값 · 자재지역은 울진(모래 줄 없는 넷은 포항) - check_master 에 `시험입력()` · `calc_all()` · 「계산전부」 모드 - 일괄 시험 계산 통과 1,219 → 1,270 · 멈춤 135 → 84(모두 데이터 결손 — 관리자 값 65 · 품셈재료 규격 미지정 13 · 지역 줄 없음 1 · 고르기 조합 없음 5) - 시험 둘 추가(test_masterdata_check.py) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj
929 lines
42 KiB
Python
929 lines
42 KiB
Python
"""마스터 데이터 검사 도구 — 견본 셋 손 계산 · 견본 파일 검사 통과 (`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 · 41∼60㎥ · 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 >= 1270
|
||
남은_까닭 = ("관리자가 채울 값", "규격 선택 필요", "지역 값 없음", "맞는 줄")
|
||
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)["지역"] == "포항"
|