Files
Aislo/resources/master_data/scripts/build_재료_자재품목.py
T
eomsangdonandClaude Sonnet 5 c04ccc91f6 feat(master_data): 자재품목 분류 보강 — 미분류 1063 → 169 · 빌더가 분류를 부름
- 규칙 보강(기존 규칙 뒤에 붙임 · 이미 분류된 줄 변동 0)
- build_재료_자재품목.py 끝에서 분류 적용 — 다시 돌려도 두 칸 · 머리 비고 유지
- 이름 빈 24줄은 비고에 까닭만

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V1MKKZKpUHTPKb513FneU8
2026-09-20 21:51:06 +09:00

157 lines
7.7 KiB
Python

"""상용 적산 자료(ESTX 2026-07) 자재 → 재료_자재품목.json — 개발용 · 배포 전 판권 구매 뒤 갱신.
ESTX 자재 표 = StuffcDat(칸 뜻: 다산소프트/ESTX_값사전_자재노무기계.md).
열 한 벌 = 키 · 원문번호 · 구분 · 상세구분 · 이름 · 규격 · 단위 · 물가자료 · 유통물가 · 물가정보 · 거래가격 · 관급 · 출처 · 비고 · 면수.
Cost2..5 = 물가지 넷 · Page2..5 = 면수. TC(채택) · Cost6(견적가)는 마스터 아님 — 뺌.
품명에 「관급」 인 줄의 Cost1(가격정보) = 조달청 관급 단가 — 「관급」 열 · 기간은 비고 「조달 26.7」.
그 밖의 줄에 남은 Cost1 은 출처가 조달인지 물가지인지 원문이 안 가름 — 값 열에 넣지 않고 비고 「가격정보 ○○」 로만.
0 · 미저장 = 값 없음(null). 이름·규격은 원문 그대로(띄어쓰기·오자 포함). 코드 없는 빈 줄은 뺌.
ESTX 에 대·중분류 칸이 없어 구분·상세구분은 build_자재품목_분류.py 가 품명 글자 규칙으로 채움(빌더 끝에서 부름).
쓰기: ./venv/Scripts/python.exe resources/master_data/scripts/build_재료_자재품목.py
쓴 뒤 원본과 대조.
"""
import hashlib
import json
import math
import random
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
import master_keys as mk # noqa: E402
from _migrate_base import dump_master # noqa: E402
from compare_estx import ESTX, ROOT, SLOTS, Estx # noqa: E402
OUT = ROOT / "resources/master_data/재료_자재품목.json"
BASE = SLOTS[0] # Cost1 「가격정보」 — 관급이면 관급 열 · 아니면 비고
MARKET = SLOTS[1:5] # 물가지 넷 · 견적가(Cost6)는 마스터 아님
# 유류·전력 — 유가·전기요금은 재료_유가전력(유류는 값이 비고 글뿐 · 중유는 받지 않음 · 대장 「폐기」)
DROP = {
"M0003039885": "휘발유",
"M0003039886": "경유",
"M0004805582": "선박용경유",
"M0003039887": "중유",
"M0003039888": "등유",
"M0005250064": "전력",
"M0005250065": "전력(주택용저압)",
}
def num(v):
if not v:
return None
return int(v) if float(v).is_integer() else v
def note(base, page, guard: str | None) -> str | None:
"""비고 — 관급이면 「조달 <기간>」 · 남은 가격정보는 「가격정보 <값> · <면수>」."""
if guard is not None:
return f"조달 {str(guard).removeprefix('조달 ')}".strip()
if base is None:
return None
return " · ".join(x for x in (f"가격정보 {base:,}", page) if x)
def build(rows: list[dict]) -> list[dict]:
out = []
for r in sorted(rows, key=lambda r: r["Index"]):
code = r.get("Code")
if not code or code in DROP:
continue
base, page = num(r.get("Cost1")), r.get("Page1") or None
public = "관급" in r.get("Name", "") and base is not None
면수 = {slot: r.get(f"Page{i}") or None for i, slot in enumerate(MARKET, 2)}
out.append(
{
"열쇠": code,
"구분": None,
"상세구분": None,
"이름": r.get("Name", ""),
"규격": r.get("Size", ""),
"단위": r.get("Unite", ""),
**{slot: num(r.get(f"Cost{i}")) for i, slot in enumerate(MARKET, 2)},
"관급": base if public else None,
"출처": f"자재품목 {code}",
"비고": note(base, page, page if public else None),
"면수": 면수 if any(면수.values()) else None,
}
)
return out
def check(rows: list[dict]) -> None:
"""원본 대조 — 줄 수 · 값 열마다 합 · 관급·가격정보 가름 · 표본 쉰 줄."""
data = json.loads(OUT.read_text(encoding="utf-8"))
got = {r["열쇠"]: r for r in data["줄"]}
raw = {r["Code"]: r for r in rows if r.get("Code") and r["Code"] not in DROP}
assert len(got) == len(data["줄"]) == len(raw), (len(got), len(raw))
print(
f"줄 {len(got)} = 원본 코드 있는 줄 {len(raw)}"
f" (코드 없는 빈 줄 {sum(1 for r in rows if not r.get('Code'))} · 유류 {len(DROP)} 뺌)"
)
for i, slot in enumerate((*MARKET, "관급"), 2):
a = math.fsum(r.get(f"Cost{i}") or 0 for r in raw.values())
if slot == "관급": # Cost1 가운데 관급 줄만
a = math.fsum(r.get("Cost1") or 0 for r in raw.values() if "관급" in r.get("Name", ""))
b = math.fsum(r[slot] or 0 for r in got.values())
n = sum(1 for r in got.values() if r[slot] is not None)
assert abs(a - b) < 1e-3, (slot, a, b)
print(f" {slot}: 값 {n}줄 · 합 {b:,.1f} = 원본 {a:,.1f}")
left = [r for r in got.values() if str(r["비고"] or "").startswith("가격정보")]
print(
f" 비고에만 남긴 가격정보 {len(left)}줄 · 관급 기간 비고 {sum(1 for r in got.values() if str(r['비고'] or '').startswith('조달'))}줄"
)
random.seed(20260719)
for code in random.sample(sorted(raw), 50):
r, g = raw[code], got[code]
assert (g["이름"], g["규격"], g["단위"]) == (
r.get("Name", ""),
r.get("Size", ""),
r.get("Unite", ""),
)
public = "관급" in r.get("Name", "")
assert (g["관급"] or 0) == ((r.get("Cost1") or 0) if public else 0), code
for i, slot in enumerate(MARKET, 2):
assert (g[slot] or 0) == (r.get(f"Cost{i}") or 0), (code, slot)
assert (g["면수"] or {}).get(slot) == (r.get(f"Page{i}") or None), (code, slot)
print(" 표본 50줄 이름·규격·단위·값 5·면수 4 같음")
def main():
rows = Estx().table("StuffcDat")
head = {
"그룹": "재료",
"원문": "자재품목",
"판": "2026-07",
"원천": [
{
"경로": ESTX.relative_to(ROOT).as_posix(),
"해시": hashlib.sha256(ESTX.read_bytes()).hexdigest(),
"역할": "primary",
}
],
"비고": [
"다산소프트 EST 2026-07 자재 · 개발용 · 배포 전 판권 구매",
"값 열 다섯 = 물가지 넷(물가자료 · 유통물가 · 물가정보 · 거래가격) · 관급(조달) — 채택값·기본가 없음 · 쓰는 곳이 값을 고르고 출처를 밝힘",
"유류·전력은 뺌 — 유가·전기요금은 재료_유가전력(유류는 값이 비고 글뿐 · 중유는 받지 않음)",
"원문번호(다산소프트 품번) 칸 없음 — 대조는 _키대장.json 에만",
"관급 = 원문 「가격정보」 칸의 조달청 관급 단가(철근 · 레미콘 · 아스콘 · H형강 등) · 기간은 비고 「조달 26.7」",
"조달청에만 있는 품목 380 줄은 관급 열만 채움 — 출처 「자재품목 조달청 …」 · 원문번호는 _키대장.json 에만",
"원문 「가격정보」 칸에 시중 값이 남은 줄은 값 열에 넣지 않고 비고 「가격정보 ○○」 로만 둠 — 출처가 조달인지 물가지인지 원문이 안 가름",
"구분 · 상세구분 = 품명 글자 규칙으로 채움(scripts/build_자재품목_분류.py · 묶음 목록 ref/_묶음_자재품목.md) · 조달청 줄은 조달청 품명을 따름 · 못 가린 줄은 「미분류」",
"면수 = 물가지 값의 원문 면수 · 화면에는 접어 둠",
],
}
dump_master(OUT, head, build(rows))
check(rows)
import build_자재품목_분류 as cls # noqa: E402
print("구분·상세구분 채움", len(cls.apply()), "줄")
print("새 틀로 받음", mk.adopt()) # 열쇠 → 키
if __name__ == "__main__":
main()