- 다산에 같은 품명(그레이팅 · 맨홀뚜껑 · 보행매트 · 합성목재 · 용접철망 · 갈매기표지판 · 고무링 · 혼합골재 등): 같은 치수 후보의 최저가를 관급 열에 채움 · 안 맞으면 비움 - 다산에 없는 품명(관 · 안전시설 · 종자·비료·식생 · 골재·경계석 · 매트 · 목재): 새 줄 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
311 lines
13 KiB
Python
311 lines
13 KiB
Python
"""관급 단가 짝짓기 — 작은 견본 응답으로 레미콘 지역·규격 · 아스콘 · 값 갈림 보류 · 지역 불일치 미채움 (`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)
|