Files
Aislo/resources/tester/test_masterdata_procure.py
T
eomsangdonandClaude Opus 5 8d95041a93 feat(master_data): 관급 못 채움 320 → 108 — 느슨한 두 번째 짝짓기 · 다산 옛 값 ±5% 안일 때만 채움
- update_관급단가_느슨.py 신설 — 엄격한 짝이 안 선 줄을 갈래 안에서 규격 글자를 느슨하게 맞춰 다시 봄
  (아스콘 · H형강 · HI-VP · PE · VR관 · 돌망태 · 무게수로관 뚜껑 · 벤치플룸 · 유공관 · 이음관)
- 아스콘 관리번호 A3-W 떼기 · 못 채운 까닭 비고를 지금 말로 다듬음
- 자재품목 관급 열 212줄 채움(다른 열·줄 그대로) · 시험 · 설계 문서

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V1MKKZKpUHTPKb513FneU8
2026-09-21 23:37:13 +09:00

485 lines
21 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/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