diff --git a/resources/knowledge/original/원가계산/자재단가/관급/2026-09-20/MASCntrct_H빔.json.gz b/resources/knowledge/original/원가계산/자재단가/관급/2026-09-20/MASCntrct_H빔.json.gz new file mode 100644 index 00000000..a6b1b38b Binary files /dev/null and b/resources/knowledge/original/원가계산/자재단가/관급/2026-09-20/MASCntrct_H빔.json.gz differ diff --git a/resources/knowledge/original/원가계산/자재단가/관급/2026-09-20/MASCntrct_레미콘.json.gz b/resources/knowledge/original/원가계산/자재단가/관급/2026-09-20/MASCntrct_레미콘.json.gz new file mode 100644 index 00000000..8f28b984 Binary files /dev/null and b/resources/knowledge/original/원가계산/자재단가/관급/2026-09-20/MASCntrct_레미콘.json.gz differ diff --git a/resources/knowledge/original/원가계산/자재단가/관급/2026-09-20/MASCntrct_봉강.json.gz b/resources/knowledge/original/원가계산/자재단가/관급/2026-09-20/MASCntrct_봉강.json.gz new file mode 100644 index 00000000..a220c224 Binary files /dev/null and b/resources/knowledge/original/원가계산/자재단가/관급/2026-09-20/MASCntrct_봉강.json.gz differ diff --git a/resources/knowledge/original/원가계산/자재단가/관급/2026-09-20/MASCntrct_아스팔트콘크리트.json.gz b/resources/knowledge/original/원가계산/자재단가/관급/2026-09-20/MASCntrct_아스팔트콘크리트.json.gz new file mode 100644 index 00000000..fa371da2 Binary files /dev/null and b/resources/knowledge/original/원가계산/자재단가/관급/2026-09-20/MASCntrct_아스팔트콘크리트.json.gz differ diff --git a/resources/knowledge/original/원가계산/자재단가/관급/2026-09-20/MASCntrct_콘크리트관.json.gz b/resources/knowledge/original/원가계산/자재단가/관급/2026-09-20/MASCntrct_콘크리트관.json.gz new file mode 100644 index 00000000..d7b2eeb0 Binary files /dev/null and b/resources/knowledge/original/원가계산/자재단가/관급/2026-09-20/MASCntrct_콘크리트관.json.gz differ diff --git a/resources/knowledge/original/원가계산/자재단가/관급/2026-09-20/MASCntrct_파형강관.json.gz b/resources/knowledge/original/원가계산/자재단가/관급/2026-09-20/MASCntrct_파형강관.json.gz new file mode 100644 index 00000000..c0ac8786 Binary files /dev/null and b/resources/knowledge/original/원가계산/자재단가/관급/2026-09-20/MASCntrct_파형강관.json.gz differ diff --git a/resources/knowledge/original/원가계산/자재단가/관급/2026-09-20/ThptyUcntrct_레미콘.json.gz b/resources/knowledge/original/원가계산/자재단가/관급/2026-09-20/ThptyUcntrct_레미콘.json.gz new file mode 100644 index 00000000..404f1063 Binary files /dev/null and b/resources/knowledge/original/원가계산/자재단가/관급/2026-09-20/ThptyUcntrct_레미콘.json.gz differ diff --git a/resources/knowledge/original/원가계산/자재단가/관급/2026-09-20/ThptyUcntrct_아스팔트콘크리트.json.gz b/resources/knowledge/original/원가계산/자재단가/관급/2026-09-20/ThptyUcntrct_아스팔트콘크리트.json.gz new file mode 100644 index 00000000..b46fe288 Binary files /dev/null and b/resources/knowledge/original/원가계산/자재단가/관급/2026-09-20/ThptyUcntrct_아스팔트콘크리트.json.gz differ diff --git a/resources/knowledge/original/원가계산/자재단가/관급/2026-09-20/ThptyUcntrct_파형강관.json.gz b/resources/knowledge/original/원가계산/자재단가/관급/2026-09-20/ThptyUcntrct_파형강관.json.gz new file mode 100644 index 00000000..59166557 Binary files /dev/null and b/resources/knowledge/original/원가계산/자재단가/관급/2026-09-20/ThptyUcntrct_파형강관.json.gz differ diff --git a/resources/knowledge/original/원가계산/자재단가/관급/2026-09-20/Ucntrct_아스팔트콘크리트.json.gz b/resources/knowledge/original/원가계산/자재단가/관급/2026-09-20/Ucntrct_아스팔트콘크리트.json.gz new file mode 100644 index 00000000..7b228272 Binary files /dev/null and b/resources/knowledge/original/원가계산/자재단가/관급/2026-09-20/Ucntrct_아스팔트콘크리트.json.gz differ diff --git a/resources/knowledge/original/원가계산/자재단가/관급/2026-09-20/Ucntrct_콘크리트관.json.gz b/resources/knowledge/original/원가계산/자재단가/관급/2026-09-20/Ucntrct_콘크리트관.json.gz new file mode 100644 index 00000000..57f92299 Binary files /dev/null and b/resources/knowledge/original/원가계산/자재단가/관급/2026-09-20/Ucntrct_콘크리트관.json.gz differ diff --git a/resources/knowledge/original/원가계산/자재단가/관급/2026-09-20/_meta.md b/resources/knowledge/original/원가계산/자재단가/관급/2026-09-20/_meta.md new file mode 100644 index 00000000..c7ee53ed --- /dev/null +++ b/resources/knowledge/original/원가계산/자재단가/관급/2026-09-20/_meta.md @@ -0,0 +1,17 @@ +# 관급 단가 원본 — 조달청 종합쇼핑몰 품목정보 서비스 + +받은 날 2026-09-20 · 수집 `scripts/update_관급단가.py fetch` · 조건 = inqryDiv=2 · prdctClsfcNoNm=<분류명> · 쪽당 999 + +| 파일 | 오퍼레이션 | 분류명 | 줄 수 | +|---|---|---|---| +| MASCntrct_레미콘.json.gz | getMASCntrctPrdctInfoList | 레미콘 | 26,384 (totalCount 26,384) | +| ThptyUcntrct_레미콘.json.gz | getThptyUcntrctPrdctInfoList | 레미콘 | 128 (totalCount 128) | +| MASCntrct_아스팔트콘크리트.json.gz | getMASCntrctPrdctInfoList | 아스팔트콘크리트 | 17,949 (totalCount 17,949) | +| Ucntrct_아스팔트콘크리트.json.gz | getUcntrctPrdctInfoList | 아스팔트콘크리트 | 17 (totalCount 17) | +| ThptyUcntrct_아스팔트콘크리트.json.gz | getThptyUcntrctPrdctInfoList | 아스팔트콘크리트 | 13 (totalCount 13) | +| MASCntrct_봉강.json.gz | getMASCntrctPrdctInfoList | 봉강 | 1,039 (totalCount 1,039) | +| MASCntrct_H빔.json.gz | getMASCntrctPrdctInfoList | H빔 | 181 (totalCount 181) | +| MASCntrct_파형강관.json.gz | getMASCntrctPrdctInfoList | 파형강관 | 6,970 (totalCount 6,970) | +| ThptyUcntrct_파형강관.json.gz | getThptyUcntrctPrdctInfoList | 파형강관 | 1,573 (totalCount 1,573) | +| MASCntrct_콘크리트관.json.gz | getMASCntrctPrdctInfoList | 콘크리트관 | 504 (totalCount 504) | +| Ucntrct_콘크리트관.json.gz | getUcntrctPrdctInfoList | 콘크리트관 | 10 (totalCount 10) | diff --git a/resources/master_data/scripts/update_관급단가.py b/resources/master_data/scripts/update_관급단가.py new file mode 100644 index 00000000..4cba0320 --- /dev/null +++ b/resources/master_data/scripts/update_관급단가.py @@ -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()