feat(master_data): 관급 단가 받기 — 조달청 종합쇼핑몰 API 원본 보관 · update_관급단가.py 받기·짝짓기 시작

- 받기: 레미콘 · 아스팔트콘크리트 · 봉강(철근) · H빔 · 파형강관 · 콘크리트관 — MAS · 일반단가 · 3자단가 (gz 원본 + _meta.md)
- 짝짓기 도우미: 지명 묶음 · 강도 · 지역 고르기

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PAdA5ThqmtVSsbzjusk1cJ
This commit is contained in:
2026-09-20 16:33:03 +09:00
co-authored by Claude Sonnet 5
parent 02b9c8be92
commit 652ee94586
13 changed files with 157 additions and 0 deletions
@@ -0,0 +1,140 @@
"""관급 단가 갱신 — 조달청 종합쇼핑몰 API 에서 받아 자재품목(재료_시중물가) 「조달」 칸을 채움.
python update_관급단가.py fetch # 받기 → 원본 보관 (resources/knowledge/original/원가계산/자재단가/관급/<날짜>/)
python update_관급단가.py fill [날짜] # 원본 → 사본에서 조달 칸을 지우고 다시 채움 · 다산소프트 값과 맞대기
마스터 정본은 덮지 않음 — 사본(tmp/관급_사본.json)과 대조표(tmp/관급_대조.json)만 씀.
"""
import gzip
import json
import re
import sys
import urllib.parse
from datetime import date
from pathlib import Path
ROOT = Path(__file__).resolve().parents[3]
PIPE = ROOT / "resources/knowledge/original/_pipeline"
RAW = ROOT / "resources/knowledge/original/원가계산/자재단가/관급"
sys.path.insert(0, str(PIPE))
import collect_cost_sources as cc # noqa: E402 (키 읽기 · fetch)
BASE = "https://apis.data.go.kr/1230000/at/ShoppingMallPrdctInfoService/"
MAS, UNIT, THPTY = (
"getMASCntrctPrdctInfoList",
"getUcntrctPrdctInfoList",
"getThptyUcntrctPrdctInfoList",
)
# (오퍼레이션, 분류명) — 분류명은 조회 조건(inqryDiv=2 · prdctClsfcNoNm)
JOBS = [ # 분류명은 부분 글자 조회 — 「철근」은 암거블록까지 섞여 오므로 「봉강」(철근또는강철봉) · H형강은 「H빔」
(MAS, "레미콘"),
(THPTY, "레미콘"),
(MAS, "아스팔트콘크리트"),
(UNIT, "아스팔트콘크리트"),
(THPTY, "아스팔트콘크리트"),
(MAS, "봉강"),
(MAS, "H빔"),
(MAS, "파형강관"),
(THPTY, "파형강관"),
(MAS, "콘크리트관"),
(UNIT, "콘크리트관"),
]
# 보관하는 칸 — 나머지(첨부·원산지·이미지·긴 설명)는 버림(원본이 수백 MB)
KEEP = ("cntrctMthdNm cntrctCorpNm prdctSpecNm cntrctPrceAmt prdctUnit prdctSplyRgnNm splyJrsdctRgnNm "
"prdctDlvryCndtnNm vatAplDivNm prdctClsfcNoNm prdctClsfcNo dtilPrdctClsfcNo prdctIdntNo prdctMakrNm "
"fctryLocplc dscntAmt dscntBgnDate dscntEndDate cntrctBgnDate cntrctEndDate rgstDt chgDt").split()
def call(op: str, key: str, **kw) -> dict:
q = "&".join(f"{k}={urllib.parse.quote(str(v))}" for k, v in kw.items())
url = f"{BASE}{op}?serviceKey={key}&type=json&{q}"
return json.loads(cc.fetch(url, timeout=90).decode("utf-8", "replace"))["response"]
def fetch_all() -> Path:
key = cc.read_g2b_key()
out = RAW / date.today().isoformat()
out.mkdir(parents=True, exist_ok=True)
lines = ["# 관급 단가 원본 — 조달청 종합쇼핑몰 품목정보 서비스", "",
f"받은 날 {date.today().isoformat()} · 수집 `scripts/update_관급단가.py fetch` · 조건 = inqryDiv=2 · prdctClsfcNoNm=<분류명> · 쪽당 999", "",
"| 파일 | 오퍼레이션 | 분류명 | 줄 수 |", "|---|---|---|---|"]
for op, name in JOBS:
rows, page, total = [], 1, 1
while len(rows) < total:
r = call(op, key, inqryDiv=2, prdctClsfcNoNm=name, numOfRows=999, pageNo=page)
b = r["body"]
total = int(b.get("totalCount") or 0)
items = b.get("items") or []
if isinstance(items, dict):
items = items.get("item", [])
if not items:
break
rows += [{k: it.get(k) for k in KEEP} for it in items]
page += 1
f = f"{op.removeprefix('get').removesuffix('PrdctInfoList')}_{name}.json.gz"
(out / f).write_bytes(gzip.compress(json.dumps(rows, ensure_ascii=False).encode("utf-8")))
lines.append(f"| {f} | {op} | {name} | {len(rows):,} (totalCount {total:,}) |")
print(f, len(rows), total, flush=True)
(out / "_meta.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
return out
# ── 짝짓기 ──────────────────────────────────────────────────────────────
def load(day: str) -> dict[str, list[dict]]:
"""받아 둔 원본 → {파일이름(.json.gz 뺀): 줄들}"""
return {
f.name.removesuffix(".json.gz"): json.loads(gzip.decompress(f.read_bytes()))
for f in sorted((RAW / day).glob("*.json.gz"))
}
def latest() -> str:
return max(p.name for p in RAW.iterdir() if p.is_dir())
ALIAS = {"경기": "경기도", "경남": "경상남도", "경북": "경상북도", "충남": "충청남도", "충북": "충청북도",
"전남": "전라남도", "전북": "전라북도", "강원": "강원도", "제주": "제주특별자치도",
"서울": "서울특별시", "부산": "부산광역시", "대구": "대구광역시", "인천": "인천광역시",
"광주": "광주광역시", "대전": "대전광역시", "울산": "울산광역시", "세종": "세종특별자치시"}
WORDS = re.compile(r"[가-힣]+")
def places(text: str) -> frozenset:
"""지역 글 → 지명 낱말 묶음 — 약칭 풀고 「전지역」·「(협)」·조합 이름은 뺌."""
out = set()
text = re.sub(r"전지역\([^)]*\)", "전지역", text or "") # 「전지역(구 나열)」 = 전지역
for w in WORDS.findall(text):
if w in ("전지역", "지역", "", "사업협동조합", "레미콘", "레미콘공업") or w.endswith(("조합", "레미콘")):
continue
w = ALIAS.get(w, w)
if w.endswith(("광역시", "특별시", "특별자치시", "특별자치도")):
w = w[:2]
elif len(w) > 2 and w.endswith(("", "", "", "")) and w not in ("경기도", "강원도"):
w = w[:-1]
elif w in ("경기도", "강원도"):
w = w[:2]
out.add(w)
return frozenset(out)
def strength(text: str):
"""「25-18-080」·「25-18-80」 → (25, 18, 80) · 휨 강도 등은 글 그대로"""
m = re.search(r"(\d+)-(\d+)-(\d+)", text or "")
return tuple(int(x) for x in m.groups()) if m else None
def region_pick(cands: dict, want: frozenset):
"""cands = {지명묶음: 줄} — 같은 묶음 → 겹침 가장 큰 하나(동점이면 None)."""
if want in cands:
return cands[want], "지역 같음"
best = sorted(((len(want & k) / len(want | k), k) for k in cands if want & k), reverse=True)
if best and best[0][0] >= 0.5 and (len(best) == 1 or best[0][0] > best[1][0]):
return cands[best[0][1]], f"지역 비슷함 {best[0][0]:.2f}"
return None, "지역 못 찾음"
if __name__ == "__main__":
mode = sys.argv[1] if len(sys.argv) > 1 else "fetch"
if mode == "fetch":
fetch_all()