"""마스터 데이터 검사 도구 — 견본 셋 손 계산 · 견본 파일 검사 통과 (`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): """화면은 고르기 첫 값으로 열림 — 그 조합이 표에 있어야 함(13장 돌쌓기·돌붙임).""" rows = { str(row["키"]): row for data in cm.load(["로직_산림품셈_13장_구조물.json"]).values() for row in data["줄"] } for key in ("GF000219", "GF000223"): # 13-4-1 메쌓기 · 13-5-1 돌붙임 row = rows[key] given = { spec["이름"]: spec["고르기"][0] if spec.get("고르기") else (spec["범위"][1] if spec.get("범위") else Decimal(1)) for spec in row["입력"] } try: mf.run(whole, key, given) except mf.FormulaError as err: # 다른 막힘(재료 값 없음)은 여기 몫이 아님 assert "맞는 줄 0개" not in str(err), f"{key} · {err}" 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 >= 1279 남은_까닭 = ( "관리자가 채울 값", "규격 선택 필요", "지역 값 없음", "맞는 줄", "고르기 후보 없음", ) 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, 빈칸) def test_표_조건_조합이_없어_멈추지_않음(): """표에 없는 조합(오거비트 혼합층 등)으로는 시험 계산을 돌리지 않음 — 「맞는 줄 0개」 0건.""" files = cm.load() _, stuck = cm.calc_all(files, mf.Master(files)) assert [x for x in stuck if "맞는 줄" in x] == [] def test_고르기_조건_규격에_입력_변수(whole): """규격 칸의 `{관경}` 은 계산 때 그 입력값으로 바뀌어 관경마다 다른 줄을 고름(고무링).""" cond = {"구분": "관·이음관", "상세구분": "PVC관", "규격": "D={관경}mm"} for 관경, want in ((800, "MT009721"), (1000, "MT009723"), (1200, "MT009725")): env = {"관경": Decimal(관경)} filled = {k: mf.fill(v, env) for k, v in cond.items()} assert mm.candidates(whole, filled, env, "개") == [want] assert mm.element(whole, filled, env, "개")[1].startswith(want) def test_고르기_조건_대표에_입력_변수(whole): """대표 칸의 `{철선}` 도 계산 때 채워짐 — 입력이 고른 자재품목 줄이 그대로 단가가 됨.""" cond = {"구분": "철근·강재", "상세구분": "철선", "대표": "{철선}"} for key in ("MT000199", "MT000164"): env = {"철선": key} filled = {k: mf.fill(v, env) for k, v in cond.items()} assert mm.pick(whole, filled, env, "kg") == key assert mm.element(whole, filled, env, "kg") == mm.element(whole, key, env, "kg") def test_고르기_조건_변수는_검사에서_뺌(whole): """`{입력}` 이 든 칸은 계산 때 채워지므로 조건 검사·단위 검사가 안 잡음.""" cond = {"구분": "관·이음관", "상세구분": "PVC관", "규격": "D={관경}mm"} assert mm.check_pick(whole, "시험", cond) == [] assert mm.check_unit(whole, "시험", cond, "개") == [] assert mm.check_pick(whole, "시험", {"구분": "{품목}", "대표": "{품목줄}"}) == [] def test_고르기_조건의_모르는_변수를_잡음(whole): """조건 칸의 `{이름}` 이 그 로직의 입력·중간이 아니면 로직 검사가 잡음.""" row = { "키": "GF999998", "원문번호": "시험", "입력": [{"이름": "관경", "고르기": [800]}], "호표": [ { "종류": "재료", "요소": {"구분": "관·이음관", "상세구분": "PVC관", "규격": "D={구경}mm"}, "이름": "고무링", "단위": "개", "수량": "1", "비목": "재료비", } ], "덧줄": [], } found, _ = mf.check_logic(whole, row) assert any("{구경} 가 입력·중간 아님" in x for x in found) row["호표"][0]["요소"]["규격"] = "D={관경}mm" found, _ = mf.check_logic(whole, row) assert [x for x in found if "입력·중간 아님" in x] == [] def test_변수_참조_줄은_고르기_조건으로_바뀜(whole): """일감24 — `MT:{철선}` 꼴 옛 참조 19줄을 고르기 조건으로 옮김. VR관(GF000173)은 PLAN 7-4b 뒤 절반에서 1본=2.5m 환산으로 마저 옮김 — 남은 1줄은 커플링밴드(GF000176, 접속티와 안 갈리는 문제 — sub2 가 이름 칸을 더하는 중)뿐.""" 남음 = [ (str(row["키"]), item["요소"]) for data in cm.load().values() if data.get("그룹") == "로직" for row in data.get("줄", []) for item in row.get("호표", []) if isinstance(item.get("요소"), str) and item["요소"][:2] in ("MP", "MT") and "{" in item["요소"] ] assert sorted(남음) == [ ("GF000176", "MP:커플링밴드 ∅{관경}㎜"), ]