Files
Aislo/resources/master_data/scripts/update_관급단가.py
T
eomsangdonandClaude Opus 5 8d95041a93 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
2026-09-21 23:37:13 +09:00

724 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""관급 단가 갱신 — 조달청 종합쇼핑몰 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()