"""관급 단가 짝짓기 — 작은 견본 응답으로 레미콘 지역·규격 · 아스콘 · 값 갈림 보류 · 지역 불일치 미채움 (`resources/master_data/scripts/update_관급단가.py`).""" import sys from pathlib import Path import pytest sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "master_data/scripts")) import update_관급단가 as up # noqa: E402 import update_관급단가_느슨 as slow # noqa: E402 def raw_row( spec, region, price, corp="테스트조합", maker="조합공통품목", cond="", bgn="2026-06-01" ): return { "prdctSpecNm": spec, "prdctSplyRgnNm": region, "cntrctPrceAmt": str(price), "cntrctCorpNm": corp, "prdctMakrNm": maker, "prdctDlvryCndtnNm": cond, "cntrctBgnDate": bgn, "rgstDt": bgn, "cntrctMthdNm": "다수 공급자 계약", "vatAplDivNm": "포함", "prdctUnit": "㎥", } def item(key, name, spec): return {"키": key, "이름": name, "규격": spec, "관급": 1, "비고": "조달 26.7"} @pytest.fixture(autouse=True) def no_estx(monkeypatch): """다산 옛 값(원문 estx)은 안 읽음 — 느슨한 짝은 값을 주는 시험만 켬.""" monkeypatch.setattr(slow, "old_of", lambda key: None) RAW = { "MASCntrct_레미콘": [ raw_row("조합공통품목, 25-18-080", "경기도 수원시", 100000), raw_row("조합공통품목, 25-18-080", "강원도 원주시", 90000), raw_row("조합공통품목, 25-21-080", "경기도 수원시", 105000), raw_row("삼표, 25-18-080", "경기도 수원시", 1, maker="삼표"), # 제조사 줄 — 조합공통 아님 ], "MASCntrct_아스팔트콘크리트": [ raw_row("아스팔트콘크리트 조합공통품목, WC-2, 가열, 3등급", "경기도 수원시", 70000), raw_row("순환 아스팔트콘크리트 조합공통품목, WC-2, 가열, 3등급", "경기도 수원시", 60000), ], "MASCntrct_시멘트": [ raw_row("시멘트, 갑, 포틀랜드시멘트(1종), 포장40kg", "전지역(제주제외)", 5820, maker="갑"), raw_row("시멘트, 갑, 포틀랜드시멘트(1종), 포장40kg", "제주", 6470, maker="갑"), ], "MASCntrct_조경석": [ raw_row( "조경석, 을산업, B-1, 300×400×500mm, R5이상~15미만, 보령성주석", "충남", 29980, maker="을산업", ), raw_row( "조경석, 병산업, C-1, 300×400×500mm, R5이상~15미만, 함양석", "충남", 20000, maker="병산업", ), ], "Ucntrct_주철관": [ raw_row("수도용덕타일주철관, Φ80mm×6m, 2종, KP메커니컬/시멘트라이닝", "전지역", 148240), raw_row("하수도용덕타일주철관, Φ80mm×6m, 3종, KP메커니컬/시멘트라이닝", "전지역", 118940), ], "MASCntrct_봉강": [ raw_row("SD400, D10", "전지역(제주제외)", 827640, maker="가철강", cond="생산공장 상차도"), raw_row("SD400, D10", "전지역(제주제외)", 830000, maker="나철강", cond="생산공장 상차도"), ], } def fill(x): (r,) = up.refill([x], RAW) return r, up.decide(x, r, "2026-09-20") def test_레미콘_지역_규격_짝(): r, (val, note) = fill(item("a", "레미콘(관급)-경기 수원", "조합공통품목, 25-18-80")) # 80 = 080 assert val == 100000 and "조달청" in note r, (val, _) = fill(item("b", "레미콘(관급)-경기 수원", "조합공통품목, 25-21-080")) assert val == 105000 def test_레미콘_지역_불일치는_안_채움(): r, (val, note) = fill(item("c", "레미콘(관급)-울진", "조합공통품목, 25-18-080")) assert val is None and "지역 못 찾음" in note def test_레미콘_규격_없으면_안_채움(): r, (val, note) = fill(item("d", "레미콘(관급)-경기 수원", "조합공통품목, 30-24-150")) assert val is None and "강도 없음" in note def test_아스콘_순환_구분(): _, (val, _) = fill(item("e", "아스콘(관급)-경기 수원", "조합공통품목, WC-2, 가열, 3등급")) assert val == 70000 _, (val, _) = fill(item("f", "순환아스콘(관급)-경기 수원", "조합공통품목, WC-2, 가열, 3등급")) assert val == 60000 def test_제조사별_값_갈리면_최저_제조사_비고(): _, (val, note) = fill(item("g", "이형철근(SD400)-관급", "HD=10mm 생산공장 상차도")) assert val == 827640 and "가철강 최저" in note def test_짝_없는_갈래는_안_채움(): _, (val, note) = fill(item("h", "KP 접합부속(관급)", "D80mm")) assert val is None and "접합부속 세트 품명이 없음" in note def test_호출_오류에_키_안_찍힘(monkeypatch): def boom(url, **kw): raise OSError(f"연결 실패 {url}") monkeypatch.setattr(up.cc, "fetch", boom) try: up.call("op", "SECRETKEY123", a=1) except RuntimeError as e: assert "SECRETKEY123" not in str(e) and "***" in str(e) else: raise AssertionError("예외가 나야 함") def test_시멘트_제주는_제주_줄만(): _, (val, _) = fill(item("i", "시멘트 (관급)", "40kg入(포장품)")) assert val == 5820 _, (val, _) = fill(item("j", "시멘트, 제주 (관급)", "40kg入(포장품)")) assert val == 6470 def test_조경석_산지가_규격에_있으면_그_줄만(): spec = "300×400×500,R5이상~15미만,보령성주석(을산업)" _, (val, _) = fill(item("k", "조경석(관급)", spec)) assert val == 29980 # 더 싼 병산업 줄이 아니라 산지·업체가 맞는 줄 def test_주철관_종별이_맞는_줄만(): _, (val, _) = fill(item("l", "KP 주철직관(2종)시멘트라이닝/관급", "D80mm×6m")) assert val == 148240 # 3종(118,940)은 안 씀 def test_조달청에만_있는_품목은_같은_규격_최저가로_새_줄(): import update_관급단가_기타 as misc def r(price, maker): x = raw_row( f"금속돌망태, {maker}, M-1, 1000×1000×1000mm, 사각상자형", "전지역", price, maker=maker ) x["prdctClsfcNoNm"] = "금속돌망태" return x raw = {"MASCntrct_돌망태": [r(46800, "가"), r(44000, "나")]} g = misc.groups(raw) assert list(g) == ["조달청 금속돌망태 1000×1000×1000mm, 사각상자형"] name, spec, rows = g["조달청 금속돌망태 1000×1000×1000mm, 사각상자형"] val, note = up.decide( None, {"새값": 44000, "까닭": up.pick(rows)[1], "조달청": up.info(up.pick(rows)[0])}, "2026-09-20", ) assert val == 44000 and "나 최저" in note def test_교량받침_이름_괄호_제조사와_관급_꼬리(): up_raw = { "MASCntrct_교량받침": [ raw_row( "교량받침, 부흥시스템, BHS-C-700-110-F, 250×300×60mm, 700kN, 고정단, 일체형탄성받침", "전지역", 599000, maker="부흥시스템", ), raw_row( "교량받침, 타사, X-700-F, 250×300×60mm, 700kN, 고정단", "전지역", 500000, maker="타사", ), ] } x = item("m", "교량받침(부흥시스템/예산)-관급", "250×300×60mm,700kN,고정단") (r,) = up.refill([x], up_raw) assert up.decide(x, r, "2026-09-20")[0] == 599000 # 더 싼 타사가 아니라 이름의 제조사 def test_파형강관_t표기와_PE피복_글자(): def cor(spec, price): return raw_row(spec, "전지역", price, maker="가") raw = { "MASCntrct_파형강관": [ cor("파형강관, 가, A, Φ300×t1.6mm, 직관(1RS), PE내면피복", 33820), cor("파형강관, 가, B, Φ300×t1.6mm, 1RS, PE양면피복", 36310), cor("파형강관, 가, C, Φ300×t1.6mm, 1RS, 아연도금", 31870), ] } _, (val, _) = fill_raw(item("n", "파형강관(관급)-거양", "Φ300×t1.6mm, 1RS, PE양면피복"), raw) assert val == 36310 def fill_raw(x, raw): (r,) = up.refill([x], raw) return r, up.decide(x, r, "2026-09-20") def test_벤치플룸_표기_달라도_모델_치수_종별로_짝(): def bp(spec, price, region="전지역(제주,도서지역 제외)"): return raw_row(f"철근콘크리트벤치플룸관, 가, {spec}", region, price, maker="가") raw = { "MASCntrct_플룸": [ bp("500C, 500×500×2000mm, 3종", 97100), bp("500C, 500×500×2000mm, 3종", 86900, "전북(도서지역 제외)"), bp("500C, 500×500×2000mm, 2종", 50000), bp("(부품)440×80×500mm, 수로관뚜껑", 1), bp("300, 300×200×2000mm, 2종", 40000), ] } _, (val, _) = fill_raw(item("o", "벤치플륨관(3종)(관급)-전북", "500C(500*500)"), raw) assert val == 86900 # 3종만 · 전북 줄 중 최저 · 부품 줄 아님 _, (val, _) = fill_raw(item("p", "벤치플륨관(2종)", "300×200, 140kg"), raw) assert val == 40000 # kg 는 조달청 표기에 없음 def test_소형고압블록_형_두께_색(): def sb(spec, price, region="전지역(제주,도서 산간지역 제외)"): return raw_row(f"보차도용콘크리트블록, 가, X, {spec}", region, price) raw = { "MASCntrct_블록": [ sb("220×110×60mm, 소형고압블록, 불투수", 7000), # 색 안 적힘 = 회색 sb("220×110×60mm, 소형고압블록, 불투수, 컬러", 11000), sb("220×110×60mm, 잡초방지소형고압블록, 불투수", 1), sb("200×100×60mm, 소형고압블록, 불투수", 6500), ] } name = "소형고압블록(관급)-대구경북" # 붙여 쓴 두 도 assert fill_raw(item("q", name, "U형 T=6cm, 회색"), raw)[1][0] == 7000 assert fill_raw(item("r", name, "U형 T=6cm, 칼라"), raw)[1][0] == 11000 assert fill_raw(item("s", name, "I형 T=6cm, 회색"), raw)[1][0] == 6500 assert fill_raw(item("t", name, "U형 T=6cm, 적색"), raw)[1][0] is None def test_무게수로관_모델_치수(): raw = { "MASCntrct_배수로": [ raw_row( "철근콘크리트용배수로관, 가, M-400C, 400×400×2000mm, 이형수로관", "전지역", 90800 ), raw_row("철근콘크리트용배수로관, 나, CM-400C, 400×400×2000mm, 이형수로관", "전지역", 1), ] } _, (val, _) = fill_raw(item("u", "무게수로관-관급", "M-400C(400*400*120),680kg"), raw) assert val == 90800 def test_보강_같은_치수만_채움_없으면_비움(): import update_관급단가_합침 as boost def c( name, spec, price, unit="개", note="관급: 조달청 다수공급자계약 2026-09-20 · 계약 2020-01-01~ · 전지역 · 도착도 · 포함", ): return {"이름": name, "규격": spec, "단위": unit, "관급": price, "비고": note} def row(name, spec, unit="개"): return {"키": "k", "이름": name, "규격": spec, "단위": unit, "출처": "자재품목 X"} P, PV = up.places, up.PROVINCE g = lambda r, *cs: boost._same(r, cs[0], P, PV) # noqa: E731 grate = row("스틸그레이팅", "700×1000×75(I-75×7×4)") assert g(grate, c("스틸그레이팅", "700×1000×75mm, 7×4mm, 뚜껑, 격자형", 1)) assert not g(grate, c("스틸그레이팅", "700×1000×75mm, 7×4mm, 뚜껑, 투수형", 1)) # 특수형 assert not g(grate, c("스틸그레이팅", "700×1000×75mm, 5×3mm, 뚜껑", 1)) # 바 치수 다름 assert not g(grate, c("스틸그레이팅", "(부품)700×1000×75mm, 7×4mm, 뚜껑", 1)) assert not g(grate, c("스틸그레이팅", "710×1000×75mm, 7×4mm, 뚜껑", 1)) # 가까운 치수로 안 이음 assert g(row("갈매기표지판(단면)", "300×450mm", "EA"), c("갈매기표지판", "300×450×2mm", 1)) assert not g( row("갈매기표지판(단면)", "300×450mm", "EA"), c("갈매기표지판", "300×450×2mm, 양면", 1) ) assert not g( row("갈매기표지판(단면)", "300×450mm", "EA"), c("갈매기표지판", "300×450×23mm, 태양광LED", 1), ) assert not g( row("주철맨홀뚜껑(원형)", "Φ648 KS", "EA"), c("주철맨홀뚜껑", "원형, Φ648×110mm", 1) ) assert g(row("보행매트(관급)", "1,000×t30mm", "M"), c("보행매트", "1000×t30mm", 1, "m")) assert not g( row("보행매트(관급)", "1,000×t30mm", "M"), c("보행매트", "1000×t30mm", 1, "개") ) # 단위 다름 def test_보강_새_줄은_목록_품명만_같은_규격_한_줄(): import update_관급단가_합침 as boost assert "스틸그레이팅" not in boost.NEW and "주철맨홀뚜껑" not in boost.NEW # 다산에 있는 품명 assert boost.NEW["철제가드레일"] == "안전시설" keys = {} rows = boost.new_rows( {"키": {}, "다음": {}}, lambda b, t, n, f: keys.setdefault(n, f"MT{len(keys) + 1:06d}"), {("낙석방지책", "존재")}, ) assert all(r["이름"] in boost.NEW and r["출처"].startswith("자재품목 조달청 ") for r in rows) assert len({r["키"] for r in rows}) == len(rows) # ── 느슨한 두 번째 짝짓기 — 다산 옛 값 ±5% 안일 때만 채움 ───────────────────────────── def loose(name, spec, raw, old, monkeypatch): """엄격한 짝이 안 서는 줄을 다산 옛 값 `old` 로 다시 봄 → (새 값, 비고).""" monkeypatch.setattr(slow, "old_of", lambda key: old) (r,) = up.refill([item("z", name, spec)], raw) return r["새값"], up.decide(item("z", name, spec), r, "2026-09-20")[1] def test_H형강_치수가_없어도_강종_단가가_치수와_무관하면_채움(monkeypatch): def hb(spec, price, corp="가"): return raw_row(f"H빔, {corp}, X, {spec}", "전지역", price, corp=corp, maker=corp) raw = { "MASCntrct_H빔": [hb("100×100×6×8mm, SS275", 1228830), hb("150×150×7×10mm, SS275", 1228830)] } val, note = loose("H 형강(관급)", "SS275, 248*124*5*8mm", raw, 1228830, monkeypatch) assert val == 1228830 and "느슨" in note val, note = loose("H 형강(관급)", "SS275, 248*124*5*8mm", raw, 1400000, monkeypatch) assert val is None and "5% 넘게 다름" in note # 다산 값과 ±5% 밖이면 안 채움 def test_H형강_치수마다_단가가_다르면_안_채움(monkeypatch): def hb(spec, price): return raw_row(f"H빔, 가, X, {spec}", "전지역", price, corp="가", maker="가") raw = { "MASCntrct_H빔": [hb("100×100×6×8mm, SS275", 1200000), hb("150×150×7×10mm, SS275", 1300000)] } val, note = loose("H 형강(관급)", "SS275, 248*124*5*8mm", raw, 1200000, monkeypatch) assert val is None and "치수마다 톤당 단가가 달라" in note def test_아스콘_제외_묶음은_뺀_구가_조달청_제외_목록에_드는_줄로(monkeypatch): def asc(region, price): return raw_row("아스팔트콘크리트 조합공통품목, BB-2, 가열, 3등급", region, price) raw = { "MASCntrct_아스팔트콘크리트": [ asc( "서울 전지역(강북구,노원구,도봉구 제외),인천(전지역),경기 전지역(가평군 제외)", 100000, ), asc("서울시 전지역(강서구,관악구 제외) 경기도 전지역(과천시 제외)", 105000), ] } spec = "조합공통품목, BB-2, 가열, 3등급" val, note = loose( "아스콘(관급)-서울,인천,경기(강북,노원,도봉구 제외)", spec, raw, 101000, monkeypatch ) assert val == 100000 and "제외 목록" in note val, _ = loose("아스콘(관급)-서울,경기(강서,관악구 제외)", spec, raw, 104000, monkeypatch) assert val == 105000 val, note = loose("아스콘(관급)-서울,경기(강남,강동구 제외)", spec, raw, 104000, monkeypatch) assert val is None and "제외 목록도 안 맞음" in note # 뺀 구가 어느 묶음에도 없음 · 값도 갈림 def test_아스콘_관리번호_A3_W_는_떼고_짝(monkeypatch): raw = { "MASCntrct_아스팔트콘크리트": [ raw_row("아스팔트콘크리트 조합공통품목, BB-2, 중온, 3등급", "충청남도(천안시)", 121830) ] } x = item("y", "아스콘(관급)-충남(천안시)", "조합공통품목, A3-W-BB2, BB-2, 중온, 3등급") (r,) = up.refill([x], raw) assert r["새값"] == 121830 def test_돌망태_개비온_m_cm_표기를_mm_로_맞추고_값이_넓게_갈리면_가까운_줄(monkeypatch): def gb(spec, price, corp): return raw_row(f"금속돌망태, {corp}, X, {spec}", "전지역", price, corp=corp, maker=corp) raw = { "MASCntrct_돌망태": [ gb("1500×1000×1000mm, 아연도금철선4종Φ3.2mm, 사각상자형", 35800, "가"), gb("1500×1000×1000mm, 아연도금철선4종Φ3.2mm, 사각상자형", 43040, "나"), gb("1500×1000×1000mm, 아연도금철선4종Φ3.2mm, 사각매트리스형", 1, "다"), # 매트리스는 뺌 ] } val, note = loose("사각개비온(3.2mm)(관급)", "1.5L×1.0W×1.0H", raw, 43040, monkeypatch) assert val == 43040 and "가장 가까운 줄" in note and "나" in note val, _ = loose("사각개비온(3.2mm)(관급)", "1.5L×1.0W×1.0H", raw, 36000, monkeypatch) assert val == 35800 # 최저가가 ±5% 안이면 최저가 def test_관_같은_지름_후보를_함께_보고_다산_값에_가장_가까운_줄(monkeypatch): def pvc(dia, price, grade): return raw_row( f"압력용경질폴리염화비닐관, 고리, X-{grade}, Φ{dia}mm×4m, HIVP/삼중벽, 직관, {grade}", "전지역", price, ) raw = {"MASCntrct_경질폴리염화비닐관": [pvc(16, 2550, "PN10"), pvc(16, 3930, "PN20")]} val, note = loose("HI-VP(삼중벽)직관(관급)", "D16mm×4m", raw, 3930, monkeypatch) assert val == 3930 and "가장 가까운 줄" in note val, note = loose("HI-VP(삼중벽)직관(관급)", "D16mm×4m", raw, 3000, monkeypatch) assert val is None and "5% 넘게 다름" in note # 어느 줄도 ±5% 안이 아님 def test_VR관은_진동및전압철근콘크리트관_보통관_최저가(monkeypatch): def vr(dia, price, corp): return raw_row( f"진동및전압철근콘크리트관, {corp}, X, 보통관/A형, Φ{dia}×2500mm", "전지역", price, corp=corp, maker=corp, ) raw = { "MASCntrct_콘크리트관": [vr(300, 60440, "가"), vr(300, 70720, "나"), vr(400, 69890, "가")] } val, note = loose("VR관(관급)", "D=300mm", raw, 60000, monkeypatch) assert val == 60440 and "다수공급자계약 가 " in note and "2가지 중 최저" in note def test_무게수로관_뚜껑은_모델과_치수_순서_달라도_짝(monkeypatch): raw = { "MASCntrct_배수로": [ raw_row( "철근콘크리트용배수로관, 대명, M-400T, (부품)640×150×2000mm, 수로관뚜껑", "전지역", 76600, ), raw_row( "철근콘크리트용배수로관, 대명, M-700T, (부품)980×150×2000mm, 수로관뚜껑", "전지역", 113400, ), ] } val, _ = loose("무게수로관뚜껑-관급", "M-400T(640*2000*150),530kg", raw, 76600, monkeypatch) assert val == 76600 val, _ = loose("무게수로관뚜껑-관급", "M-700T(1000*2000*150),760kg", raw, 113400, monkeypatch) assert val == 113400 # 980 ↔ 1000 은 3% 안 def test_벤치플룸_다산_모델_글자가_달라도_치수_종별_지역으로_짝(monkeypatch): raw = { "MASCntrct_플룸": [ raw_row( "철근콘크리트벤치플룸관, 가, HCT-500A, 500×400×2000mm, 3종", "경북,대구(도서지역 제외)", 77400, ), raw_row( "철근콘크리트벤치플룸관, 나, 500A, 500×400×2000mm, 3종", "전북(도서지역 제외)", 75800, ), ] } val, _ = loose("벤치플륨관(3종)(관급)-대구경북", "400A(500*400)", raw, 77400, monkeypatch) assert val == 77400 # 전북 줄은 지역이 달라 안 봄 def test_다산_옛_값이_없는_줄은_느슨하게_안_채움(monkeypatch): monkeypatch.setattr(slow, "old_of", lambda key: None) raw = {"MASCntrct_H빔": [raw_row("H빔, 가, X, 100×100×6×8mm, SS275", "전지역", 1228830)]} (r,) = up.refill([item("z", "H 형강(관급)", "SS275, 248*124*5*8mm")], raw) assert r["새값"] is None