Files
Aislo/resources/tester/test_masterdata_procure.py
T
eomsangdonandClaude Sonnet 5 565833bcb8 feat(master_data): 관급 보강 후보 합침 — 다산 같은 품명 줄에 조달청 최저가 · 새 줄 6,239
- 다산에 같은 품명(그레이팅 · 맨홀뚜껑 · 보행매트 · 합성목재 · 용접철망 · 갈매기표지판 · 고무링 · 혼합골재 등): 같은 치수 후보의 최저가를 관급 열에 채움 · 안 맞으면 비움
- 다산에 없는 품명(관 · 안전시설 · 종자·비료·식생 · 골재·경계석 · 매트 · 목재): 새 줄 6,239 · 자재품목 24,353 → 30,592
- update_관급단가_합침.py 신설 · apply 가 부름 · 다시 돌려도 같은 결과
- 설계 문서에 합친 결과 · 시험 추가

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PAdA5ThqmtVSsbzjusk1cJ
2026-09-20 20:59:27 +09:00

311 lines
13 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
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "master_data/scripts"))
import update_관급단가 as up # 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"}
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", "VR관(관급)", "D=300mm"))
assert val is None and "VR관 품명 없음" 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)