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
This commit is contained in:
2026-09-21 23:37:13 +09:00
co-authored by Claude Opus 5
parent 08ac0f7614
commit 8d95041a93
6 changed files with 911 additions and 334 deletions
@@ -24,6 +24,7 @@ sys.path.insert(0, str(PIPE))
import collect_cost_sources as cc # noqa: E402 (키 읽기 · fetch)
import master_keys as mk # noqa: E402 (새 줄 키)
import update_관급단가_기타 as misc # noqa: E402 (「그 밖」 갈래 짝짓기)
import update_관급단가_느슨 as slow # noqa: E402 (엄격한 짝이 안 선 줄 · 느슨한 두 번째 짝)
import update_관급단가_합침 as boost # noqa: E402 (보강 후보 합치기)
BASE = "https://apis.data.go.kr/1230000/at/ShoppingMallPrdctInfoService/"
@@ -346,11 +347,11 @@ class Matcher:
r"\s+",
"",
re.sub(
r"^\s*[A-Z]\d-H-[^,]*,",
r"^\s*[A-Z]\d-[HW]-[^,]*,",
"",
re.split(r"조합공통품목(?:\([^)]*\))?,", spec)[-1],
),
), # 「R3-H-BB2, BB-2, …」 = 앞의 순환 관리 번호를 떼고 「BB-2, …」
), # 「R3-H-BB2」 · 「A3-W-BB2」 = 앞의 관리 번호를 떼고 「BB-2, …」
)
if k not in self.asc:
return None, "규격 없음(원문 규격이 조달청에 없음)"
@@ -480,6 +481,8 @@ def refill(rows: list[dict], raw: dict) -> list[dict]:
): # 지난번에 못 채운 줄도 다시 시도
continue
r, why = m.find(x)
if r is None: # 못 채운 줄은 규격 글자를 느슨하게 다시 봄 — 다산 옛 값 ±5% 안일 때만 채움
r, why = slow.find(x, m, why, pick, places, PROVINCE)
out.append(
{
"": x[""],
@@ -541,10 +544,12 @@ def decide(x: dict, r: dict, day: str) -> tuple[int | None, str]:
if r["새값"] is None:
return None, f"관급: 조달청 값 못 채움 — {r['까닭']} ({day})"
c = r["조달청"]
who = f" {c['제조사']} 최저" if "최저" in r["까닭"] else ""
loose = r["까닭"].startswith("느슨") # 느슨한 짝 — 제조사와 까닭을 함께 적음
who = f" {c['제조사']}" if loose else (f" {c['제조사']} 최저" if "최저" in r["까닭"] else "")
who += " · 묶음 시군 일부만 조달청에 있음" if "지역 일부" in r["까닭"] else ""
tail = f" · {r['까닭']}" if loose else ""
return r["새값"], (
f"관급: 조달청 {METHOD.get(c['방식'], c['방식'])}{who} {day} · 계약 {c['계약시작']}~ · {c['지역']} · {c['인도']} · {c['부가세']}"
f"관급: 조달청 {METHOD.get(c['방식'], c['방식'])}{who} {day} · 계약 {c['계약시작']}~ · {c['지역']} · {c['인도']} · {c['부가세']}{tail}"
)
@@ -42,23 +42,35 @@ FAMILIES = (
# 조달청에 그 품목·규격이 없거나 다산 규격으로는 물건을 가릴 수 없는 것 — 「관급」 못 채움의 까닭 글
ABSENT = (
(
r"^(벤치플륨관뚜껑|무게수로관뚜껑)",
"짝 못 지음 — 조달청 뚜껑은 (부품) 치수 표기가 달라 다산 규격과 안 맞음",
r"^벤치플륨관뚜껑",
"짝 못 지음 — 조달청 뚜껑은 (부품) 표기라 다산 규격과 안 맞음",
),
(r"^HI-VP", "조달청 종합쇼핑몰에 없음 — 삼중벽 품명 없음(단층 HIVG 만 있음)"),
(r"^KP 접합부속", "조달청 종합쇼핑몰에 없음 — 접합부속 세트 품명 없음"),
(
r"^소형고압블록",
"조달청 소형고압블록은 제조사 몇 줄뿐이라 다산의 색 · 지역 묶음 줄이 없음",
),
(
r"^생태(옹벽|어소)블록",
"조달청에 그 품명이 없음 — 호안 · 옹벽블록은 모양 · 단위(㎡)가 달라 안 맞음",
),
(r"^합성목재\(본우드\)", "조달청 본우드 줄과 규격(치수 · 형)이 안 맞음"),
(r"^콘크리트보강섬유\(슈퍼셀\)", "조달청에 셀룰로오스 보강섬유 품명이 없음"),
(r"^KP 접합부속", "조달청에 접합부속 세트 품명이 없음(고무링 · 압륜 부품만 따로 있음)"),
(
r"^(PE직관\(수도용\)|PE이중벽)",
"짝 못 지음 — 조달청은 압력·강성 등급별 값인데 다산 규격에 등급 없음",
),
(r"^VR관", "조달청 종합쇼핑몰에 없음 — VR관 품명 없음"),
(
r"^(순환)?아스콘.*제외",
"짝 못 지음 — 다산 묶음이 「~구 제외」 꼴이라 조달청 공급 지역과 안 맞음",
),
(r"^측구수로관", "짝 못 지음 — 조달청은 길이·형별 표기(가로×세로×길이)"),
)
GONE = ("후보 없음", "강도 없음(원문 규격이 조달청에 없음)", "규격 없음(원문 규격이 조달청에 없음)")
GONE = {
"후보 없음": "조달청에 이 품목 줄이 없음(후보 없음)",
"강도 없음(원문 규격이 조달청에 없음)": "조달청에 이 강도 줄이 없음(강도 없음)",
"규격 없음(원문 규격이 조달청에 없음)": "조달청에 같은 품명은 있으나 이 규격 줄이 없음",
}
def absent(name: str, why: str) -> str:
@@ -67,8 +79,8 @@ def absent(name: str, why: str) -> str:
if re.search(pat, name):
return text
if why == "지역 못 찾음":
return "조달청 종합쇼핑몰에 그 지역 묶음의 줄 없음지역 못 찾음"
return f"조달청 종합쇼핑몰에 없음 — {why}" if why in GONE else why
return "조달청에 그 지역 묶음의 줄 없음(지역 못 찾음)"
return GONE.get(why, why)
SKIP_WORDS = {"관급", "포장품", "", "각종"}
@@ -0,0 +1,366 @@
"""관급 단가 — 느슨한 두 번째 짝짓기(`update_관급단가.py` 가 엄격한 짝이 안 서면 부름).
엄격한 짝짓기가 못 세운 줄만 다시 봄 — 같은 갈래 안에서 규격 글자를 느슨하게 맞춰 후보를 찾고,
**값이 잡히면 다산소프트 옛 값과 ±5% 안일 때만** 채움(밖이면 안 채움 · 까닭에 조달청 값).
· 후보가 같은 규격 여럿이면 최저가(제조사 비고) — 최저가가 ±5% 밖이고 후보가 등급·형이 달라
글자로 못 가릴 때만 다산 값에 가장 가까운 줄(비고에 「느슨」 으로 밝힘)
· 다산소프트 옛 값 = 원문(estx) Cost1 · 키 → 원문번호는 `_키대장.json` — 자재품목 정본엔 이미 없음
갈래: H형강 · 아스콘 · 돌망태·개비온 · 관(HI-VP · PE 이중벽 · PE 수도용 · VR관 · 유공관)
· 무게수로관 뚜껑 · 벤치플룸 · 파형강관 이음관.
"""
import re
from functools import lru_cache
PCT = 0.05 # 다산 옛 값과 이 안이면 「맞음」
SPREAD = 0.01 # 후보 묶음마다 값이 이 안이면 같은 값으로 봄
def within(new, old) -> bool:
return bool(old) and abs(new / old - 1) <= PCT
@lru_cache(maxsize=1)
def _dasan() -> dict[str, int]:
"""{자재품목 키: 다산소프트 관급 값} — 원문 estx 에서 읽음(못 채운 줄을 다시 볼 때만 부름)."""
import compare_estx as ce # noqa: PLC0415 — 무거운 불러오기는 필요할 때만
import master_keys as mk # noqa: PLC0415
book = mk.load_book()[""]
cost = {r["Code"]: r.get("Cost1") for r in ce.Estx().table("StuffcDat") if r.get("Code")}
return {
k: int(cost[v["원문번호"]])
for k, v in book.items()
if v["파일"] == "재료_자재품목.json" and cost.get(v["원문번호"])
}
def old_of(key: str) -> int | None:
return _dasan().get(key)
def _price(r: dict) -> int:
return int(r["cntrctPrceAmt"])
def choose(cands: list[dict], old, pick, why: str = ""):
"""(줄, 까닭) — 최저가가 ±5% 안이면 그 줄 · 밖이면 가장 가까운 줄(±5% 안) · 아니면 못 채움."""
if not cands:
return None, "후보 없음"
prices = sorted({_price(r) for r in cands})
low, high = prices[0], prices[-1]
if within(low, old):
r, how = pick(cands)
return r, f"느슨 · {why}{how}"
near = min(cands, key=lambda r: (abs(_price(r) - old), _price(r)))
if within(_price(near), old):
return near, (
f"느슨 · {why}값이 {low:,}~{high:,} 로 갈려 최저가는 다산과 5% 넘게 다름"
" — 다산 값에 가장 가까운 줄"
)
return None, f"규격은 맞으나 조달청 값({low:,}~{high:,})이 다산 값과 5% 넘게 다름"
# ── H형강 — 치수가 조달청에 없어도 강종별 톤당 단가가 모든 치수에서 같음 ────────────────
def hbeam(row: dict, m, old, pick):
g = re.match(r"(SS\d+|SM\d+|SHN\d+)", row["규격"])
rows = [
r
for r in m.hbeam
if g and g.group(1) in r["prdctSpecNm"] and r["prdctSplyRgnNm"].startswith("전지역")
]
if not rows:
return None, "조달청에 이 강종의 H형강 줄이 없음"
prices = {_price(r) for r in rows}
makers = {r["cntrctCorpNm"] for r in rows}
if len(prices) > len(makers): # 제조사마다 값이 하나뿐이어야 치수와 무관한 값
return None, "조달청은 치수마다 톤당 단가가 달라 치수 없이는 못 정함"
got = [r for r in rows if _price(r) == min(prices)]
return choose(
got, old, pick, "치수는 조달청에 없으나 이 강종은 모든 치수의 톤당 단가가 같음 · "
)
def _out(text: str) -> set[str]:
"""「(강북,노원,도봉구 제외)」 — 묶음에서 뺀 시·군·구 낱말(끝 글자 구·시·군은 뗌)."""
return {
w[:-1] if len(w) > 2 and w[-1] in "구시군" else w
for grp in re.findall(r"\(([^()]*?)제외\)", text or "")
for w in re.findall(r"[가-힣]+", grp)
}
# ── 아스콘 — 묶음 이름의 「~구 제외」 · 줄임 글을 도 이름으로 맞춤 ────────────────────
def asphalt(row: dict, m, old, pick, places, province):
name = row["이름"]
key = (
name.startswith("순환"),
re.sub(
r"\s+",
"",
re.sub(
r"^\s*[A-Z]\d-[HW]-[^,]*,",
"",
re.split(r"조합공통품목(?:\([^)]*\))?,", row["규격"])[-1],
),
),
)
table = m.asc.get(key)
if not table:
return None, "이 배합(가열 · 중온 · 등급)의 아스콘 줄이 조달청에 없음"
prov = places(name.split(")-", 1)[1] if ")-" in name else name) & province
got = [r for reg, rows in table.items() if prov and prov <= reg for r in rows]
if not got:
return None, f"조달청에 그 지역의 {key[1].split(',')[1]} 아스콘 줄이 없음(일부 지역만 있음)"
out = _out(name)
if out: # 「~구 제외」 묶음 — 조달청 묶음의 제외 목록이 다산이 뺀 곳을 포함하는 줄
near = [r for r in got if out <= _out(r["prdctSplyRgnNm"])]
if near:
return choose(near, old, pick, "다산이 뺀 구가 조달청 묶음의 제외 목록에 듦 · ")
prices = sorted({_price(r) for r in got})
if prices[-1] / prices[0] - 1 > SPREAD:
return (
None,
f"그 도의 조달청 묶음마다 값이 갈리고({prices[0]:,}~{prices[-1]:,})"
" 제외 목록도 안 맞음",
)
return choose(got, old, pick, "묶음의 「제외」 · 줄임 글을 도 이름으로 맞춤 · ")
# ── 돌망태 · 개비온 — 치수 표기(m · cm) 달라도 mm 로 맞춤 ─────────────────────────────
def _dims(spec: str):
"""규격의 치수 셋(또는 둘) → mm 후보 글 목록."""
m = re.match(r"\s*([\d.]+)L×([\d.]+)W×([\d.]+)H", spec)
if m: # 1.5L×1.0W×1.0H (m)
return [[str(round(float(x) * 1000)) for x in m.groups()]]
m = re.search(r"\((\d+)×(\d+)\)", spec) # #8(45×45) (cm)
if m:
return [[str(int(x) * 10) for x in m.groups()]]
return []
def gabion(row: dict, raw: dict, old, pick):
n, spec = row["이름"], row["규격"]
dims = _dims(spec)
if not dims:
return None, "규격 치수를 못 읽음"
d = dims[0]
form = re.match(r"(사각|육각|원형|타원형|이불형)", n)
part = re.search(r"\((뚜껑|몸통)\)", n)
wire = re.search(r"\(([\d.]+)mm", n)
rows = [r for f in ("MASCntrct_돌망태", "ThptyUcntrct_돌망태") for r in raw.get(f, [])]
pat = "×".join(d)
got = []
for r in rows:
s = re.sub(r"\s+", "", r["prdctSpecNm"])
if not re.search(rf"(?<!\d){pat}(?!\d)", s) or re.search(r"매트리스|식생", s):
continue
if form and not (form.group(1) in s or (form.group(1) + "") in s):
continue
if part and part.group(1) not in s:
continue
if wire and f"Φ{wire.group(1)}" not in s:
continue
if not part and "-" in s.split(",")[-1] and re.search(r"뚜껑|몸통", s):
continue
got.append(r)
return choose(got, old, pick, "치수 표기(m · cm)를 mm 로 맞춤 · ")
# ── 관 — 압력 · 강성 등급이 규격에 없어 같은 지름 후보를 함께 봄 ─────────────────────
def _per(r: dict, meters: int) -> dict:
"""본(길이 m) 값을 다산 단위(m)로 — 줄 사본에 값을 바꿔 넣음."""
return dict(r, cntrctPrceAmt=str(round(_price(r) / meters)), prdctUnit="m")
def pipe(row: dict, raw: dict, old, pick):
n, spec = row["이름"], row["규격"]
pvc = raw.get("MASCntrct_경질폴리염화비닐관", []) + raw.get(
"ThptyUcntrct_경질폴리염화비닐관", []
)
pe = raw.get("MASCntrct_폴리에틸렌", []) + raw.get("ThptyUcntrct_폴리에틸렌", [])
if n.startswith("HI-VP"):
m = re.match(r"D(\d+)mm×(\d)m", spec)
kind = "편수칼라" if "편수칼라" in n else "직관"
if not m:
return None, "규격 지름을 못 읽음"
got = [
r
for r in pvc
if "HIVP" in r["prdctSpecNm"]
and "삼중" in r["prdctSpecNm"]
and kind in r["prdctSpecNm"]
and f"Φ{m.group(1)}mm×{m.group(2)}m" in r["prdctSpecNm"]
]
return choose(got, old, pick, "압력등급 · 제조사가 달라 같은 지름 후보를 함께 봄 · ")
if n.startswith("PE이중벽"):
m = re.match(r"Φ(\d+)m/m", spec)
if not m:
return None, "규격 지름을 못 읽음"
got = []
for r in pe:
s = r["prdctSpecNm"]
k = re.search(rf"Φ{m.group(1)}mm×(\d+)m", s)
if s.startswith("일반용폴리에틸렌관") and "이중벽관" in s and k:
got.append(_per(r, int(k.group(1))))
return choose(
got, old, pick, "본 값을 m 당으로 나눔 · 강성등급이 달라 같은 지름 후보를 함께 봄 · "
)
if n.startswith("PE직관(수도용)"):
kp = re.search(r"신관(\d+)×(\d)m", spec) # 「75×6m (신관90×6m) KP식」 — 6m 한 본 값
if kp:
got = [
r
for r in pe
if r["prdctSpecNm"].startswith("수도용폴리에틸렌관")
and f"Φ{kp.group(1)}mm×{kp.group(2)}m" in r["prdctSpecNm"]
]
return choose(got, old, pick, "신관 지름 · 본 길이로 맞춤 · ")
m = re.search(r"신관(\d+)mm\)$", spec) # 「D16mm (신관20mm)」 — 조달청은 신관 지름
if not m:
return None, "규격에 신관 지름이 없음"
got = [
r
for r in pe
if r["prdctSpecNm"].startswith("수도용폴리에틸렌관")
and re.search(rf"Φ{m.group(1)}mm", r["prdctSpecNm"])
and r["prdctUnit"].lower() == "m"
]
return choose(
got, old, pick, "신관 지름으로 맞춤 · 등급이 달라 같은 지름 후보를 함께 봄 · "
)
if n.startswith("VR관"): # VR = 진동및전압철근콘크리트관 — 조달청 「콘크리트관」 분류 안
m = re.match(r"D=(\d+)mm", spec)
if not m:
return None, "규격 지름을 못 읽음"
hume = raw.get("MASCntrct_콘크리트관", []) + raw.get("Ucntrct_콘크리트관", [])
got = [
r
for r in hume
if "진동및전압" in r["prdctSpecNm"]
and "보통관" in r["prdctSpecNm"]
and f"Φ{m.group(1)}×" in r["prdctSpecNm"]
]
return choose(got, old, pick, "VR관 = 진동및전압철근콘크리트관 · ")
if n.startswith("파형강관(관급)"): # 유공관 — 다산 규격에 RS 가 없어 엄격한 짝이 안 섬
dia = re.search(r"Φ(\d+)", spec)
th = re.search(r"t(\d+(?:\.\d+)?)mm", spec)
if not (dia and th and "유공관" in spec):
return None, ""
kinds = [w for w in ("아연도금", "세라믹", "PE") if w in spec]
got = [
r
for r in raw.get("MASCntrct_파형강관", []) + raw.get("ThptyUcntrct_파형강관", [])
if r["prdctSpecNm"].startswith("파형강관,")
and f"Φ{dia.group(1)}×" in r["prdctSpecNm"]
and re.search(rf"t{re.escape(th.group(1))}(\.0)?mm", r["prdctSpecNm"])
and "유공관" in r["prdctSpecNm"]
and all(w in r["prdctSpecNm"] for w in kinds)
]
return choose(got, old, pick, "다산 규격에 없는 RS 표기를 뺌 · ")
return None, ""
# ── 무게수로관 뚜껑 · 벤치플룸 · 파형강관 이음관 — 조달청 표기(모델 · 치수 순서 · 피복)를 맞춤 ──
def lid(row: dict, raw: dict, old, pick):
m = re.match(r"(M-\w+)\((\d+)\*(\d+)\*(\d+)\)", row["규격"])
if not m:
return None, "규격 모델 · 치수를 못 읽음"
want = sorted(
int(x) for x in m.groups()[1:]
) # 다산 가로*길이*높이 — 조달청은 가로×높이×길이 순
got = []
for f in ("MASCntrct_배수로", "ThptyUcntrct_배수로"):
for r in raw.get(f, []):
s = r["prdctSpecNm"]
if "수로관뚜껑" not in s or not re.search(rf"(?<![\w-]){re.escape(m.group(1))},", s):
continue
d = re.search(r"(\d+)×(\d+)×(\d+)mm", s)
have = sorted(int(x) for x in d.groups()) if d else []
if have and all(
abs(a / b - 1) <= 0.03 for a, b in zip(have, want)
): # 모델 같고 치수 3% 안
got.append(r)
return choose(
got, old, pick, "조달청은 (부품) 표기 · 치수 순서가 달라 모델과 치수(3% 안)로 맞춤 · "
)
def flume(row: dict, raw: dict, old, pick, places, province):
m = re.search(r"\((\d+)\*(\d+)\)", row["규격"])
jong = re.search(r"\((\d)종\)", row["이름"])
if not (m and jong and ")-" in row["이름"]):
return None, "규격 치수 · 종별 · 지역을 못 읽음"
tail = row["이름"].split(")-", 1)[1]
want = places(tail)
cut = frozenset(y for x in want for i in range(0, len(x), 2) for y in places(x[i : i + 2]))
want = cut if want and not want & province and cut <= province else want
got = []
for r in raw.get("MASCntrct_플룸", []):
s = r["prdctSpecNm"]
if (
"(부품)" in s
or f"{m.group(1)}×{m.group(2)}×2000mm" not in s
or f"{jong.group(1)}" not in s
):
continue
reg = r["prdctSplyRgnNm"]
if reg.startswith("전지역") or (want and want <= places(reg)):
got.append(r)
return choose(got, old, pick, "다산 모델 글자(400A · 500C …)가 달라 치수와 종별로 맞춤 · ")
def joint(row: dict, raw: dict, old, pick):
spec = row["규격"]
kind = re.match(r"(커플링밴드|접속티)", spec)
dia = re.search(r"Φ(\d+)mm", spec)
coat = re.search(r"(양면|내면|단면)피복", spec)
if not (kind and dia and coat):
return None, "규격 종류 · 지름 · 피복을 못 읽음"
got = [
r
for f in ("MASCntrct_파형강관이음관", "ThptyUcntrct_파형강관이음관", "MASCntrct_파형강관")
for r in raw.get(f, [])
if r["prdctSpecNm"].startswith("파형강관이음관")
and kind.group(1) in r["prdctSpecNm"]
and f"Φ{dia.group(1)}mm" in r["prdctSpecNm"]
and coat.group(0) in r["prdctSpecNm"]
and not re.search(r"세라믹|아연도금", r["prdctSpecNm"])
]
return choose(got, old, pick, "조달청은 「PE」 를 빼고 적기도 함 · (부품) 표기 포함 · ")
FAMILIES = (
("H형강", r"^H 형강\(관급\)"),
("아스콘", r"^(순환)?아스콘\(관급\)-"),
("돌망태", r"(돌망태|개비온)"),
("", r"^(HI-VP|PE이중벽|PE직관\(수도용\)|VR관|파형강관\(관급\))"),
("뚜껑", r"^무게수로관뚜껑"),
("벤치플룸", r"^벤치플륨관\(\d종\)\(관급\)-"),
("이음관", r"^파형강관이음관\(관급\)"),
)
def find(row: dict, m, why: str, pick, places, province, old=None):
"""(조달청 줄, 까닭) — 엄격한 짝이 안 선 줄(`why`)을 다시 봄 · 갈래 밖이면 (None, why)."""
fam = next((k for k, pat in FAMILIES if re.search(pat, row["이름"])), None)
if fam is None:
return None, why
old = old if old is not None else old_of(row[""])
if not old:
return None, why
if fam == "H형강":
r, how = hbeam(row, m, old, pick)
elif fam == "아스콘":
r, how = asphalt(row, m, old, pick, places, province)
elif fam == "돌망태":
r, how = gabion(row, m.raw, old, pick)
elif fam == "뚜껑":
r, how = lid(row, m.raw, old, pick)
elif fam == "벤치플룸":
r, how = flume(row, m.raw, old, pick, places, province)
elif fam == "이음관":
r, how = joint(row, m.raw, old, pick)
else:
r, how = pipe(row, m.raw, old, pick)
return (r, how) if r is not None or how else (None, why)