- 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
724 lines
30 KiB
Python
724 lines
30 KiB
Python
"""관급 단가 갱신 — 조달청 종합쇼핑몰 API 에서 받아 자재품목(재료_자재품목) 「관급」 열을 채움.
|
||
|
||
다시 돌리는 법 (한 줄) — 받기 + 정본 관급 열 갱신 + 바뀐 줄 목록:
|
||
./venv/Scripts/python.exe resources/master_data/scripts/update_관급단가.py update
|
||
|
||
fetch 받기 → 원본 보관 (resources/knowledge/original/원가계산/자재단가/관급/<날짜>/ · gz + _meta.md)
|
||
test [날짜] 관급 열을 지우고 다시 채워 옛 값(다산소프트)과 맞댐(±5% 안 = 맞음) — 정본 안 덮음 · tmp/관급_대조.json
|
||
apply [날짜] 정본 관급 열(관급 · 비고)만 받은 값으로 바꿈 · tmp/관급_바뀐줄.json
|
||
"""
|
||
|
||
import gzip
|
||
import json
|
||
import re
|
||
import sys
|
||
import time
|
||
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)
|
||
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/"
|
||
MAS, UNIT, THPTY = (
|
||
"getMASCntrctPrdctInfoList",
|
||
"getUcntrctPrdctInfoList",
|
||
"getThptyUcntrctPrdctInfoList",
|
||
)
|
||
# (오퍼레이션, 분류명) — 분류명은 조회 조건(inqryDiv=2 · prdctClsfcNoNm)
|
||
JOBS = [ # 분류명은 부분 글자 조회 — 「철근」은 암거블록까지 섞여 오므로 「봉강」(철근또는강철봉) · H형강은 「H빔」
|
||
(MAS, "레미콘"),
|
||
(THPTY, "레미콘"),
|
||
(MAS, "아스팔트콘크리트"),
|
||
(UNIT, "아스팔트콘크리트"),
|
||
(THPTY, "아스팔트콘크리트"),
|
||
(MAS, "봉강"),
|
||
(MAS, "H빔"),
|
||
(MAS, "파형강관"),
|
||
(THPTY, "파형강관"),
|
||
(MAS, "콘크리트관"),
|
||
(UNIT, "콘크리트관"),
|
||
# 2차 — 다산소프트 「그 밖」 관급 · 품셈재료 중 조달청에 있는 것
|
||
(MAS, "시멘트"),
|
||
(MAS, "골재"),
|
||
(MAS, "조경석"),
|
||
(UNIT, "주철관"),
|
||
(MAS, "파형강관이음관"),
|
||
(THPTY, "파형강관이음관"),
|
||
(MAS, "배수로"),
|
||
(THPTY, "배수로"),
|
||
(MAS, "블록"),
|
||
(UNIT, "블록"),
|
||
(THPTY, "블록"),
|
||
(MAS, "돌망태"),
|
||
(THPTY, "돌망태"),
|
||
(MAS, "합성목재"),
|
||
(MAS, "볼라드"),
|
||
(MAS, "교량받침"),
|
||
(THPTY, "교량받침"),
|
||
(MAS, "용접철망"),
|
||
(THPTY, "섬유"),
|
||
(MAS, "폴리에틸렌"),
|
||
(THPTY, "폴리에틸렌"),
|
||
(MAS, "맨홀"),
|
||
(MAS, "매트"),
|
||
(THPTY, "매트"),
|
||
(MAS, "낙석"),
|
||
(THPTY, "그레이팅"),
|
||
(MAS, "벽돌"),
|
||
(THPTY, "시트"),
|
||
(MAS, "비료"),
|
||
(MAS, "압륜"),
|
||
(THPTY, "부직포"),
|
||
(MAS, "경질폴리염화비닐관"),
|
||
(THPTY, "경질폴리염화비닐관"),
|
||
(MAS, "플룸"), # 벤치플룸관 — 분류 「배수로」 조회에는 안 나옴
|
||
]
|
||
# 보관하는 칸 — 나머지(첨부·원산지·이미지·긴 설명)는 버림(원본이 수백 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}"
|
||
for tries in range(5): # 큰 분류는 시간 초과가 잦음 — 다섯 번까지
|
||
try:
|
||
return json.loads(cc.fetch(url, timeout=90).decode("utf-8", "replace"))["response"]
|
||
except Exception as e: # 오류 글에 받은 주소(키 포함)가 실려도 키는 가림
|
||
if tries == 4:
|
||
raise RuntimeError(f"{op} 호출 실패: {str(e).replace(key, '***')}") from None
|
||
time.sleep(5)
|
||
|
||
|
||
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:
|
||
f = f"{op.removeprefix('get').removesuffix('PrdctInfoList')}_{name}.json.gz"
|
||
if (out / f).exists(): # 같은 날 이미 받은 것은 건너뜀(이어받기)
|
||
n = len(json.loads(gzip.decompress((out / f).read_bytes())))
|
||
lines.append(f"| {f} | {op} | {name} | {n:,} |")
|
||
continue
|
||
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
|
||
(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
|
||
|
||
|
||
PROVINCE = frozenset(w for k, v in ALIAS.items() for w in places(k) | places(v))
|
||
|
||
|
||
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}"
|
||
# 다산 묶음이 조달청 시군 줄 여럿을 합친 것 — 묶음 안 시군마다 조달청 줄이 있을 때만(값이 갈리면 pick 이 보류)
|
||
towns = want - PROVINCE
|
||
if len(towns) > 1:
|
||
hit = [k for k in cands if k <= want and k - PROVINCE]
|
||
if towns <= set().union(*hit) if hit else False:
|
||
return [r for k in hit for r in cands[k]], f"지역 묶음 {len(hit)}줄"
|
||
# 다산 묶음의 시군이 조달청 한 줄의 공급 지역 안에 모두 들어 있음 — 그 줄들의 최저가(값이 갈리면 pick 이 최저)
|
||
sup = [k for k in cands if towns and towns <= k]
|
||
if sup:
|
||
return [r for k in sup for r in cands[k]], f"지역 포함 {len(sup)}줄"
|
||
# 묶음 시군 가운데 일부만 조달청 줄이 있음 — 그 시군 줄만(같은 도 · 묶음 밖 시군이 낀 줄은 뺌)
|
||
part = [k for k in cands if towns & k and k <= want | PROVINCE and k - PROVINCE <= want]
|
||
if len(towns) > 1 and part:
|
||
return [r for k in part for r in cands[k]], f"지역 일부 {len(part)}줄"
|
||
return None, "지역 못 찾음"
|
||
|
||
|
||
MASTER = ROOT / "resources/master_data/재료_자재품목.json"
|
||
TMP = ROOT / "tmp"
|
||
|
||
|
||
def newest(rows: list[dict]) -> dict:
|
||
return max(rows, key=lambda r: (r["cntrctBgnDate"] or "", r["rgstDt"] or ""))
|
||
|
||
|
||
def pick(rows: list[dict]):
|
||
"""같은 짝 후보가 여럿이면 값이 하나일 때 그 값 · 값이 갈리면 가장 낮은 값(제조사별 단가) — 사유를 함께 돌려줌."""
|
||
if not rows:
|
||
return None, "후보 없음"
|
||
vals = {int(r["cntrctPrceAmt"]) for r in rows}
|
||
if len(vals) == 1:
|
||
return newest(rows), "짝 하나" if len(rows) == 1 else f"같은 값 {len(rows)}건"
|
||
low = min(vals)
|
||
return newest(
|
||
[r for r in rows if int(r["cntrctPrceAmt"]) == low]
|
||
), f"제조사별 값 {len(vals)}가지 중 최저"
|
||
|
||
|
||
def _dia(t):
|
||
m = re.search(r"[ΦD](\d+)", t or "")
|
||
return int(m.group(1)) if m else None
|
||
|
||
|
||
def _thick(t):
|
||
m = re.search(r"[×xX*]t?(\d+(?:\.\d+)?)", t or "")
|
||
return float(m.group(1)) if m else None
|
||
|
||
|
||
def _size(t):
|
||
m = re.search(
|
||
r"(\d+(?:\.\d+)?)[×*x](\d+(?:\.\d+)?)[×*x](\d+(?:\.\d+)?)[×*x](\d+(?:\.\d+)?)", t or ""
|
||
)
|
||
return tuple(float(x) for x in m.groups()) if m else None
|
||
|
||
|
||
CORR_WORDS = ("PE양면피복", "PE내면피복", "아연도금", "유공관", "접속티", "커플링밴드", "직관")
|
||
CORR_EXTRA = ("내부평활", "플랜지", "세라믹", "복합", "PE+PP", "PE/PP", "내식")
|
||
|
||
|
||
class Matcher:
|
||
"""자재품목 줄 → 조달청 줄. 짝이 서면 (줄, 방식), 못 서면 (None, 까닭)."""
|
||
|
||
def __init__(self, raw: dict):
|
||
self.raw = raw
|
||
self.rmc = {}
|
||
for r in raw.get("MASCntrct_레미콘", []):
|
||
if r["prdctMakrNm"] == "조합공통품목":
|
||
self.rmc.setdefault(strength(r["prdctSpecNm"]), {}).setdefault(
|
||
places(r["prdctSplyRgnNm"]), []
|
||
).append(r)
|
||
self.asc = {}
|
||
for f in (
|
||
"MASCntrct_아스팔트콘크리트",
|
||
"Ucntrct_아스팔트콘크리트",
|
||
"ThptyUcntrct_아스팔트콘크리트",
|
||
):
|
||
for r in raw.get(f, []):
|
||
if "조합공통품목" in r["prdctSpecNm"]:
|
||
k = (
|
||
r["prdctSpecNm"].startswith("순환"),
|
||
re.sub(r"\s+", "", r["prdctSpecNm"].split("조합공통품목,")[1]),
|
||
)
|
||
self.asc.setdefault(k, {}).setdefault(places(r["prdctSplyRgnNm"]), []).append(r)
|
||
self.bar = raw.get("MASCntrct_봉강", [])
|
||
self.hbeam = raw.get("MASCntrct_H빔", [])
|
||
self.corr = raw.get("MASCntrct_파형강관", []) + raw.get("ThptyUcntrct_파형강관", [])
|
||
self.hume = raw.get("MASCntrct_콘크리트관", []) + raw.get("Ucntrct_콘크리트관", [])
|
||
|
||
@staticmethod
|
||
def _region(table: dict, name: str):
|
||
want = places(name.split(")-", 1)[1] if ")-" in name else name)
|
||
cands, why = region_pick(table, want)
|
||
if cands is None: # 구분 없이 붙은 시군(「진주사천남해…」)은 두 글자씩 끊어 다시
|
||
cut = frozenset(
|
||
q
|
||
for w in want
|
||
for q in ([w[i : i + 2] for i in range(0, len(w), 2)] if len(w) >= 6 else [w])
|
||
)
|
||
if cut != want:
|
||
cands, why = region_pick(table, cut)
|
||
if cands is None or (
|
||
why.startswith(("지역 묶음", "지역 포함", "지역 일부")) and "제외" in name
|
||
): # 「~ 제외」 묶음은 짝짓지 않음
|
||
return None, why if cands is None else "지역 못 찾음"
|
||
corp = re.findall(
|
||
r"([가-힣]+)레미콘\(협\)", name
|
||
) # 이름에 조합이 붙은 줄(「제주…레미콘(협)」)은 그 조합 줄만
|
||
if corp and len(cands) > 1:
|
||
cands = [r for r in cands if r["cntrctCorpNm"].startswith(corp[0])] or cands
|
||
return cands, why
|
||
|
||
def find(self, row: dict):
|
||
r, why = self._find(row)
|
||
if r is None:
|
||
why = misc.absent(row["이름"], why)
|
||
return r, why
|
||
|
||
def _find(self, row: dict):
|
||
n, spec = row["이름"], row["규격"]
|
||
if n.startswith("레미콘(관급)-"):
|
||
k = strength(spec)
|
||
if k not in self.rmc:
|
||
return None, "강도 없음(원문 규격이 조달청에 없음)"
|
||
c, why = self._region(self.rmc[k], n)
|
||
if c is None:
|
||
return None, why
|
||
r, how = pick(c)
|
||
return r, f"{why} · {how}"
|
||
if re.match(r"(순환)?아스콘\(관급\)-", n):
|
||
k = (
|
||
n.startswith("순환"),
|
||
re.sub(
|
||
r"\s+",
|
||
"",
|
||
re.sub(
|
||
r"^\s*[A-Z]\d-[HW]-[^,]*,",
|
||
"",
|
||
re.split(r"조합공통품목(?:\([^)]*\))?,", spec)[-1],
|
||
),
|
||
), # 「R3-H-BB2」 · 「A3-W-BB2」 = 앞의 관리 번호를 떼고 「BB-2, …」
|
||
)
|
||
if k not in self.asc:
|
||
return None, "규격 없음(원문 규격이 조달청에 없음)"
|
||
c, why = self._region(self.asc[k], n)
|
||
if c is None:
|
||
return None, why
|
||
r, how = pick(c)
|
||
return r, f"{why} · {how}"
|
||
m = re.match(r"이형철근\((SD\d+)(,제주)?\)-관급", n)
|
||
if m:
|
||
d = re.search(r"HD=(\d+)", spec)
|
||
zone = "제주" if m.group(2) else "전지역(제주제외)"
|
||
cond = (
|
||
"생산공장상차도"
|
||
if "생산공장" in spec
|
||
else ("하치장상차도" if "하치장" in spec else None)
|
||
)
|
||
c = (
|
||
[
|
||
r
|
||
for r in self.bar
|
||
if f"{m.group(1)}, D{d.group(1)}" in r["prdctSpecNm"]
|
||
and r["prdctSplyRgnNm"].startswith(zone)
|
||
and (cond is None or r["prdctDlvryCndtnNm"].replace(" ", "") == cond)
|
||
]
|
||
if d
|
||
else []
|
||
)
|
||
return pick(c)
|
||
if n.startswith("H 형강(관급)"):
|
||
sz = _size(spec)
|
||
g = re.match(r"(SS\d+|SM\d+|SHN\d+)", spec)
|
||
c = [
|
||
r
|
||
for r in self.hbeam
|
||
if sz
|
||
and _size(r["prdctSpecNm"]) == sz
|
||
and g
|
||
and g.group(1) in r["prdctSpecNm"]
|
||
and r["prdctSplyRgnNm"].startswith("전지역")
|
||
]
|
||
return pick(c)
|
||
if re.match(r"파형강관\(관급\)", n):
|
||
dia, th = _dia(spec), _thick(spec)
|
||
rs = re.search(r"(\d)RS", spec)
|
||
if not (dia and th and rs):
|
||
return None, "후보 없음"
|
||
coat = [w for w in ("PE양면피복", "PE내면피복") if w in spec]
|
||
c = [ # 먼저 「(nRS)」 꼴 — 이미 채운 값이 흔들리지 않게 · PE 피복 줄은 피복 글자가 맞아야
|
||
r
|
||
for r in self.corr
|
||
if _dia(r["prdctSpecNm"]) == dia
|
||
and re.search(rf"t{th:g}mm", r["prdctSpecNm"])
|
||
and f"({rs.group(1)}RS)" in r["prdctSpecNm"]
|
||
and all(re.search(rf"(?<!복합)(?<!\+)(?<!/){w}", r["prdctSpecNm"]) for w in coat)
|
||
]
|
||
if c:
|
||
return pick(c)
|
||
kinds = [w for w in CORR_WORDS if w in spec] # 없을 때만 — 종류·피복 글자가 맞는 줄
|
||
for r in self.corr:
|
||
t = r["prdctSpecNm"]
|
||
if (
|
||
_dia(t) == dia
|
||
and re.search(rf"t{re.escape(f'{th:g}')}(\.0)?mm", t)
|
||
and re.search(rf"(?<!\d){rs.group(1)}RS", t)
|
||
and all(re.search(rf"(?<!복합)(?<!\+)(?<!/){w}", t) for w in kinds)
|
||
and all(w in spec or w not in t for w in CORR_EXTRA) # 다산에 없는 덧붙임은 뺌
|
||
):
|
||
c.append(r)
|
||
return pick(c)
|
||
if re.match(r"흄\s*관", n):
|
||
dia = _dia(spec)
|
||
c = [
|
||
r
|
||
for r in self.hume
|
||
if dia and _dia(r["prdctSpecNm"]) == dia and "보통관" in r["prdctSpecNm"]
|
||
]
|
||
return pick(c)
|
||
return misc.find(row, self.raw, places, pick, PROVINCE)
|
||
|
||
|
||
FAMILY = (
|
||
("레미콘", r"^레미콘\(관급\)"),
|
||
("아스콘", r"^(순환)?아스콘\(관급\)"),
|
||
("철근", r"^이형철근"),
|
||
("H형강", r"^H 형강"),
|
||
("파형강관", r"^파형강관\(관급\)"),
|
||
("흄관", r"^흄\s*관"),
|
||
)
|
||
|
||
|
||
def family(name: str) -> str:
|
||
return next((k for k, pat in FAMILY if re.match(pat, name)), misc.label(name) or "그 밖")
|
||
|
||
|
||
def procured(book: dict | None = None) -> dict[str, str]:
|
||
"""{자재품목 키: 조달청 원문번호} — 줄에 원문번호 칸이 없어 _키대장.json 으로 가름."""
|
||
book = book or mk.load_book()
|
||
return {
|
||
k: v["원문번호"]
|
||
for k, v in book["키"].items()
|
||
if v["파일"] == "재료_자재품목.json" and str(v["원문번호"]).startswith("조달청 ")
|
||
}
|
||
|
||
|
||
def info(r: dict) -> dict:
|
||
return {
|
||
"방식": r["cntrctMthdNm"],
|
||
"품명": r["prdctSpecNm"],
|
||
"지역": r["prdctSplyRgnNm"],
|
||
"계약시작": r["cntrctBgnDate"],
|
||
"부가세": r["vatAplDivNm"],
|
||
"인도": r["prdctDlvryCndtnNm"],
|
||
"단위": r["prdctUnit"],
|
||
"제조사": r["cntrctCorpNm"] if r["prdctMakrNm"] == "조합공통품목" else r["prdctMakrNm"],
|
||
}
|
||
|
||
|
||
def refill(rows: list[dict], raw: dict) -> list[dict]:
|
||
"""조달 값(관급 열)이 있는 줄마다 조달청 값으로 다시 채운 결과."""
|
||
m, out, mine = Matcher(raw), [], procured()
|
||
for x in rows:
|
||
if x["키"] in mine: # 조달청에서 새로 들인 줄은 아래 `add_rows` 몫
|
||
continue
|
||
if x["관급"] is None and not (x["비고"] or "").startswith(
|
||
"관급:"
|
||
): # 지난번에 못 채운 줄도 다시 시도
|
||
continue
|
||
r, why = m.find(x)
|
||
if r is None: # 못 채운 줄은 규격 글자를 느슨하게 다시 봄 — 다산 옛 값 ±5% 안일 때만 채움
|
||
r, why = slow.find(x, m, why, pick, places, PROVINCE)
|
||
out.append(
|
||
{
|
||
"키": x["키"],
|
||
"이름": x["이름"],
|
||
"규격": x["규격"],
|
||
"옛값": x["관급"],
|
||
"옛기간": x["비고"],
|
||
"갈래": family(x["이름"]),
|
||
"새값": int(r["cntrctPrceAmt"]) if r else None,
|
||
"까닭": why,
|
||
"조달청": info(r) if r else None,
|
||
}
|
||
)
|
||
return out
|
||
|
||
|
||
def test(day: str | None = None) -> list[dict]:
|
||
day = day or latest()
|
||
res = refill(json.loads(MASTER.read_text(encoding="utf-8"))["줄"], load(day))
|
||
TMP.mkdir(exist_ok=True)
|
||
(TMP / "관급_대조.json").write_text(
|
||
json.dumps(res, ensure_ascii=False, indent=1), encoding="utf-8"
|
||
)
|
||
tab: dict = {}
|
||
for r in res:
|
||
t = tab.setdefault(
|
||
r["갈래"],
|
||
{"전체": 0, "짝": 0, "맞음": 0, "다름": 0, "최저채움": 0, "못채움": 0, "까닭": {}},
|
||
)
|
||
t["전체"] += 1
|
||
if r["새값"] is None:
|
||
t["못채움"] += 1
|
||
t["까닭"][r["까닭"]] = t["까닭"].get(r["까닭"], 0) + 1
|
||
else:
|
||
t["짝"] += 1
|
||
ok = (
|
||
r["옛값"] and abs(r["새값"] / r["옛값"] - 1) <= 0.05
|
||
) # 받는 시점 차이는 ±5% 안이면 맞음
|
||
t["맞음" if ok else "다름"] += 1
|
||
t["최저채움"] += "최저" in r["까닭"]
|
||
for k, v in tab.items():
|
||
print(
|
||
k,
|
||
{a: b for a, b in v.items() if a != "까닭"},
|
||
dict(sorted(v["까닭"].items(), key=lambda kv: -kv[1])[:4]),
|
||
)
|
||
return res
|
||
|
||
|
||
METHOD = {
|
||
"다수 공급자 계약": "다수공급자계약",
|
||
"일반단가계약": "일반단가계약",
|
||
"3자단가계약": "3자단가계약",
|
||
}
|
||
|
||
|
||
def decide(x: dict, r: dict, day: str) -> tuple[int | None, str]:
|
||
"""(새 관급 값, 새 비고) — 제조사별로 값이 갈리면 같은 규격의 가장 낮은 값 · 비고에 그 제조사."""
|
||
if r["새값"] is None:
|
||
return None, f"관급: 조달청 값 못 채움 — {r['까닭']} ({day})"
|
||
c = r["조달청"]
|
||
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['부가세']}{tail}"
|
||
)
|
||
|
||
|
||
def add_rows(lines: list[str], data: dict, raw: dict, day: str, changed: list) -> list[str]:
|
||
"""조달청에만 있는 품목 — 자재품목에 새 줄(관급만 · 물가지 네 열 null · 같은 품명·규격은 최저가). 이미 든 줄은 값·비고만 갈음."""
|
||
book, new = mk.load_book(), []
|
||
mine = procured(book)
|
||
have = {mine[x["키"]]: x for x in data["줄"] if x["키"] in mine}
|
||
for num, (name, spec, rows) in misc.groups(raw).items():
|
||
r, how = pick(rows)
|
||
val, note = decide(
|
||
None, {"새값": int(r["cntrctPrceAmt"]), "까닭": how, "조달청": info(r)}, day
|
||
)
|
||
if num in have:
|
||
for i, line in enumerate(lines):
|
||
if f'"키": "{have[num]["키"]}"' in line:
|
||
line = re.sub(
|
||
r'"관급": (?:null|-?[\d.]+)', lambda _: f'"관급": {val}', line, count=1
|
||
)
|
||
lines[i] = re.sub(
|
||
r'"비고": (?:null|"(?:[^"\\]|\\.)*")',
|
||
lambda _: f'"비고": {json.dumps(note, ensure_ascii=False)}',
|
||
line,
|
||
count=1,
|
||
)
|
||
break
|
||
continue
|
||
key = mk.issue(book, "MT", num, "재료_자재품목.json")
|
||
row = {
|
||
"키": key, "구분": None, "상세구분": None, "이름": name, "규격": spec,
|
||
"단위": r["prdctUnit"], "물가자료": None, "유통물가": None, "물가정보": None,
|
||
"거래가격": None, "관급": val, "출처": f"자재품목 조달청 {r['cntrctMthdNm']}", "비고": note, "면수": None,
|
||
} # fmt: skip
|
||
new.append(
|
||
" { "
|
||
+ ", ".join(f'"{k}": {json.dumps(v, ensure_ascii=False)}' for k, v in row.items())
|
||
+ " }"
|
||
)
|
||
changed.append(
|
||
{
|
||
"키": key,
|
||
"이름": name,
|
||
"규격": spec,
|
||
"갈래": "새 줄",
|
||
"옛값": None,
|
||
"새값": val,
|
||
"비고": note,
|
||
}
|
||
)
|
||
if new:
|
||
end = max(i for i, line in enumerate(lines) if line.strip() == "]")
|
||
lines[end - 1] += ","
|
||
lines[end:end] = [ln + "," for ln in new[:-1]] + new[-1:]
|
||
mk.save_book(mk.finish(book))
|
||
return lines
|
||
|
||
|
||
def add_boost(lines: list[str], data: dict, changed: list) -> list[str]:
|
||
"""보강 후보 중 새 줄로 더하는 품명(boost.NEW) — 이미 든 줄은 값·비고만 갈음."""
|
||
book = mk.load_book()
|
||
at = {x["키"]: x for x in data["줄"]}
|
||
dasan = {(x["이름"], x["규격"] or "") for x in data["줄"] if "조달청" not in x["출처"]}
|
||
pos = {
|
||
m.group(1): i for i, ln in enumerate(lines) if (m := re.match(r'\s*\{ "키": "([^"]+)"', ln))
|
||
}
|
||
new = []
|
||
for row in boost.new_rows(book, mk.issue, dasan):
|
||
if row["키"] in at:
|
||
i = pos[row["키"]]
|
||
lines[i] = re.sub(
|
||
r'"관급": (?:null|-?[\d.]+)', lambda _: f'"관급": {row["관급"]}', lines[i], count=1
|
||
)
|
||
lines[i] = re.sub(
|
||
r'"비고": (?:null|"(?:[^"\\]|\\.)*")',
|
||
lambda _: f'"비고": {json.dumps(row["비고"], ensure_ascii=False)}',
|
||
lines[i],
|
||
count=1,
|
||
)
|
||
continue
|
||
new.append(
|
||
" { "
|
||
+ ", ".join(f'"{k}": {json.dumps(v, ensure_ascii=False)}' for k, v in row.items())
|
||
+ " }"
|
||
)
|
||
changed.append({"키": row["키"], "이름": row["이름"], "규격": row["규격"], "갈래": "보강 새 줄", "옛값": None, "새값": row["관급"], "비고": row["비고"]}) # fmt: skip
|
||
if new:
|
||
end = max(i for i, line in enumerate(lines) if line.strip() == "]")
|
||
lines[end - 1] += ","
|
||
lines[end:end] = [ln + "," for ln in new[:-1]] + new[-1:]
|
||
mk.save_book(mk.finish(book))
|
||
return lines
|
||
|
||
|
||
def apply(day: str | None = None) -> None:
|
||
"""정본 관급 열 · 비고만 바꿈 — 다산소프트 값은 하나도 안 남김(못 채운 줄 = null)."""
|
||
day = day or latest()
|
||
text = MASTER.read_bytes().decode("utf-8")
|
||
crlf = "\r\n" in text # 작업 폴더 줄바꿈(CRLF)을 그대로 지킴
|
||
text = text.replace("\r\n", "\n")
|
||
data = json.loads(text)
|
||
found = {r["키"]: r for r in refill(data["줄"], load(day))}
|
||
changed, edits = [], {}
|
||
for x in data["줄"]:
|
||
r = found.get(x["키"])
|
||
if r is None:
|
||
continue
|
||
val, note = decide(x, r, day)
|
||
changed.append(
|
||
{
|
||
"키": x["키"],
|
||
"이름": x["이름"],
|
||
"규격": x["규격"],
|
||
"갈래": r["갈래"],
|
||
"옛값": x["관급"],
|
||
"새값": val,
|
||
"비고": note,
|
||
}
|
||
)
|
||
edits[x["키"]] = (val, note)
|
||
now = {x["키"]: edits[x["키"]][0] if x["키"] in edits else x["관급"] for x in data["줄"]}
|
||
for k, (val, note) in boost.fills(data["줄"], now, places, PROVINCE).items():
|
||
x = next(y for y in data["줄"] if y["키"] == k)
|
||
changed[:] = [c for c in changed if c["키"] != k] # 앞서 못 채움으로 적힌 줄을 갈음
|
||
changed.append({"키": k, "이름": x["이름"], "규격": x["규격"], "갈래": "보강 채움", "옛값": x["관급"], "새값": val, "비고": note}) # fmt: skip
|
||
edits[k] = (val, note)
|
||
# 글 자리에서 「관급」·「비고」 두 칸만 갈음 — 나머지 서식·칸은 그대로
|
||
lines = text.split("\n")
|
||
put = lambda v: json.dumps(v, ensure_ascii=False) # noqa: E731
|
||
for i, line in enumerate(lines):
|
||
m = re.match(r'\s*\{ "키": "([^"]+)"', line)
|
||
if not m or m.group(1) not in edits:
|
||
continue
|
||
val, note = edits.pop(m.group(1))
|
||
line, n1 = re.subn(
|
||
r'"관급": (?:null|-?[\d.]+)', lambda _: f'"관급": {put(val)}', line, count=1
|
||
)
|
||
line, n2 = re.subn(
|
||
r'"비고": (?:null|"(?:[^"\\]|\\.)*")', lambda _: f'"비고": {put(note)}', line, count=1
|
||
)
|
||
assert n1 == n2 == 1, f"{m.group(1)} 줄 서식이 달라 멈춤"
|
||
lines[i] = line
|
||
assert not edits, f"정본에서 못 찾은 줄 {len(edits)}"
|
||
lines = add_rows(lines, data, load(day), day, changed)
|
||
lines = add_boost(lines, data, changed)
|
||
out = "\n".join(lines)
|
||
MASTER.write_bytes((out.replace("\n", "\r\n") if crlf else out).encode("utf-8"))
|
||
TMP.mkdir(exist_ok=True)
|
||
(TMP / "관급_바뀐줄.json").write_text(
|
||
json.dumps(changed, ensure_ascii=False, indent=1), encoding="utf-8"
|
||
)
|
||
ok = sum(c["새값"] is not None for c in changed)
|
||
print(
|
||
f"관급 열 {len(changed)} 줄 중 채움 {ok} · null {len(changed) - ok} — 목록 tmp/관급_바뀐줄.json"
|
||
)
|
||
|
||
|
||
def update() -> None:
|
||
day = fetch_all().name
|
||
apply(day)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
mode = sys.argv[1] if len(sys.argv) > 1 else "fetch"
|
||
if mode == "fetch":
|
||
fetch_all()
|
||
elif mode == "test":
|
||
test(*sys.argv[2:3])
|
||
elif mode == "apply":
|
||
apply(*sys.argv[2:3])
|
||
elif mode == "update":
|
||
update()
|