feat(master_data): 상용 적산 자료(ESTX 2026-07) 대 마스터 비교 — 읽기 전용 스크립트 · 결과 문서

- scripts/compare_estx.py — ESTX 고정 레코드 읽기 · 노임 · 자재 · 기계경비 · 일위대가 비교(마스터 안 고침)
- ref/_비교_estx.md — 노임 건설 117/131 같음 · 자재 나라장터 대비 +20% · 기계 조종원 계수 차이(÷8 대 0.2083) · 일위대가 28 짝 중 14 같음

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj
This commit is contained in:
2026-09-19 21:54:28 +09:00
co-authored by Claude Opus 5
parent 3aac66d13d
commit 11c9f562fc
2 changed files with 646 additions and 0 deletions
@@ -0,0 +1,521 @@
"""상용 적산 자료(ESTX 2026-07) 와 마스터 비교 — 읽기 전용 · 결과는 화면에만.
ESTX 구조: resources/knowledge/original/원가계산/다산소프트/ESTX_파일구조.md · ESTX_테이블사전.md.
비목: ESTX NC=노무비 · GC=재료비 · JC=경비. 기계 JC=손료 · GC=연료+잡품 · NC=조종원.
쓰기: ./venv/Scripts/python.exe resources/master_data/scripts/compare_estx.py [노임|자재|기계|일위대가]
"""
import copy
import re
import statistics
import struct
import sys
import zipfile
from decimal import Decimal
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
import check_master as cm # noqa: E402
import master_formula as mf # noqa: E402
ROOT = Path(__file__).resolve().parents[3]
ESTX = ROOT / "resources/knowledge/original/원가계산/다산소프트/2026-07-main.estx"
# ── ESTX 읽기 ─────────────────────────────────────────────────────────
class Estx:
def __init__(self, path=ESTX):
self.zip = zipfile.ZipFile(path)
self.cache = {}
def table(self, name: str) -> list[dict]:
"""고정 레코드 표 — 필드 설명자대로 풂 · 미저장 필드는 뺌."""
if name in self.cache:
return self.cache[name]
b = self.zip.read(name + ".dat")
count, size, nfield = struct.unpack_from("<IHH", b, 0x29)
fields = []
for i in range(nfield):
o = 0x200 + i * 0x300
fields.append(
(
b[o + 3 : o + 3 + b[o + 2]].decode("ascii"),
b[o + 0xA4],
struct.unpack_from("<H", b, o + 0xA9)[0],
struct.unpack_from("<I", b, o + 0xAC)[0],
)
)
start = 512 + nfield * 768
rows = []
for r in range(count):
base = start + r * size
row = {}
for fname, kind, length, off in fields:
if not b[base + off]:
continue
p = base + off + 1
if kind == 1:
row[fname] = b[p : p + length].split(b"\0")[0].decode("cp949", "replace")
elif kind in (2, 6):
row[fname] = struct.unpack_from("<i", b, p)[0]
elif kind == 4:
row[fname] = struct.unpack_from("<h", b, p)[0]
elif kind == 7:
row[fname] = struct.unpack_from("<d", b, p)[0]
rows.append(row)
self.cache[name] = rows
return rows
def details(self, main: str) -> dict[str, list[dict]]:
"""제목 코드 → 상세 줄(HeadMain · MachMain) — TitleCode 줄 뒤 이름 있는 줄."""
out, cur = {}, None
for r in self.table(main):
if r.get("TitleCode"):
cur = r["TitleCode"]
out[cur] = []
elif cur and r.get("Name"):
out[cur].append(r)
return out
# ── 공통 ──────────────────────────────────────────────────────────────
def norm(s) -> str:
s = str(s or "").lower()
for a, b in (
("×", "*"),
("x", "*"),
("", "mm"),
("φ", "ø"),
("Φ", "ø"),
("Ø", "ø"),
("", "m3"),
):
s = s.replace(a, b)
return "".join(ch for ch in s if not ch.isspace() and ch not in "")
def rate(a, b) -> float:
"""a 에 대한 b 의 차이율 % — (b-a)/a."""
return (float(b) - float(a)) / float(a) * 100 if a else float("inf")
def spread(rates: list[float]) -> str:
if not rates:
return "-"
ab = [abs(x) for x in rates]
within = lambda t: sum(x <= t for x in ab) # noqa: E731
return (
f"{len(rates)}건 · 같음(±0.5%) {within(0.5)} · ±5% {within(5)} · ±20% {within(20)} · "
f"중앙값 {statistics.median(rates):+.1f}%"
)
def load_master():
files = cm.load()
return files, mf.Master(files)
# ── (1) 노임 ──────────────────────────────────────────────────────────
def labor(estx: Estx, files: dict):
rows = estx.table("LaborcDat")
heads = [r["Name"] for r in rows if r.get("Name") and not r.get("Unite")]
print("## 노임 — ESTX 머리줄", heads)
ours = {}
for fname, tag in (("인력_건설노임.json", "건설"), ("인력_제조노임.json", "제조")):
for r in files[fname][""]:
ours.setdefault((tag, norm(r["이름"])), r)
theirs = {}
for r in rows:
if r.get("Unite") != "":
continue
name = r["Name"]
tag = "제조" if name.endswith("(제조)") else "건설"
key = (tag, norm(name.removesuffix("(제조)")))
theirs.setdefault(key, r)
for tag in ("건설", "제조"):
mine = {k for k in ours if k[0] == tag}
est = {k for k in theirs if k[0] == tag}
both = mine & est
same = [
k
for k in both
if ours[k][""] is not None and float(ours[k][""]) == theirs[k].get("TC")
]
diff = [k for k in both if k not in same]
print(
f"### {tag} — 우리 {len(mine)} · ESTX {len(est)} · 짝 {len(both)} · 같음 {len(same)} · 다름 {len(diff)}"
)
print(
" 다름:", spread([rate(ours[k][""], theirs[k]["TC"]) for k in diff if ours[k][""]])
)
for k in sorted(diff)[:15]:
print(" ", k[1], ours[k][""], "", theirs[k].get("TC"))
print(" 우리만:", sorted(k[1] for k in mine - est)[:40])
print(" ESTX만:", sorted(k[1] for k in est - mine)[:40])
# ── (2) 자재 ──────────────────────────────────────────────────────────
SLOTS = ["가격정보", "물가자료", "유통물가", "물가정보", "거래가격", "견적가"]
def base_name(name) -> str:
"""품명 뿌리 — 「, 서울」 · 「(관급)」 · 「-사급」 같은 지역·조달 꼬리를 뗌."""
s = re.sub(r"\(.*?\)", "", str(name or "").split(",")[0])
return norm(re.sub(r"-(사급|관급)$", "", s.strip()))
def bare_spec(name, spec) -> str:
n, s = norm(name), norm(spec)
s = s[len(n) :] if s.startswith(n) else s
return "".join(ch for ch in s if ch not in "-()[]")
def spec_match(nara_spec: str, estx_spec: str) -> bool:
"""규격 같음 — 같거나, ESTX 규격(3자 이상)이 나라장터 규격 끝과 같음(앞 수식어 「아연도」 등)."""
return nara_spec == estx_spec or (len(estx_spec) >= 3 and nara_spec.endswith(estx_spec))
def material_pairs(estx: Estx, nara: list[dict]):
by_name = {}
for r in estx.table("StuffcDat"):
if r.get("Name") and r.get("TC"):
by_name.setdefault(base_name(r["Name"]), []).append(r)
pairs = []
for n in nara:
cands = by_name.get(base_name(n["이름"])) if n.get("") else None
if not cands:
continue
s = bare_spec(n["이름"], n["규격"])
hit = next((e for e in cands if spec_match(s, bare_spec(e["Name"], e.get("Size")))), None)
if hit:
pairs.append((n, hit))
return by_name, pairs
def material(estx: Estx, files: dict):
nara = files["재료_나라장터자재.json"][""]
by_name, pairs = material_pairs(estx, nara)
rows = sum(by_name.values(), [])
names = {base_name(n["이름"]) for n in nara}
print(
f"## 자재 — 나라장터 {len(nara)}(품명 {len(names)}) · ESTX {len(rows)} · 품명 같음 {len(names & set(by_name))} · 품명·규격 짝 {len(pairs)}"
)
print(" TC 대 나라장터:", spread([rate(n[""], e["TC"]) for n, e in pairs]))
for i, slot in enumerate(SLOTS, 1):
got = [rate(n[""], e[f"Cost{i}"]) for n, e in pairs if e.get(f"Cost{i}")]
print(f" 슬롯{i} {slot}:", spread(got))
closest = {}
for n, e in pairs:
got = {
s: abs(rate(n[""], e[f"Cost{i}"]))
for i, s in enumerate(SLOTS, 1)
if e.get(f"Cost{i}")
}
best = min(got, key=got.get)
closest[best] = closest.get(best, 0) + 1
print(" 가장 가까운 슬롯:", closest)
for n, e in pairs[:8]:
print(
"",
n["이름"],
"|",
n["규격"],
n[""],
"",
e.get("TC"),
{s: e.get(f"Cost{i}") for i, s in enumerate(SLOTS, 1) if e.get(f"Cost{i}")},
)
have, near, none = [], [], []
for r in files["재료_자체.json"][""]:
if r.get("") is not None:
continue
mine = {base_name(r["이름"]), base_name(r["열쇠"])}
hits = [e for m in mine for e in by_name.get(m, [])]
if hits:
have.append((r, hits))
continue
tail = [
e for k, v in by_name.items() for m in mine if len(m) >= 2 and k.endswith(m) for e in v
]
(near if tail else none).append((r, tail))
print(
f"### 재료_자체 빈 값 {len(have) + len(near) + len(none)} — ESTX 품명 같음 {len(have)} · 품명 끝 같음 {len(near)} · 없음 {len(none)}"
)
for r, hits in near:
print(f" 비슷 {r['열쇠']} | {sorted({e['Name'] for e in hits})[:4]}")
for r, hits in have:
s = bare_spec(r["이름"], r.get("규격"))
same = [e for e in hits if s and spec_match(s, bare_spec(e["Name"], e.get("Size")))]
h = (same or hits)[0]
print(
f" 있음 {r['열쇠']} [{r.get('단위')}] | ESTX {len(hits)}줄 규격같음 {len(same)} | 예 {h['Name']} {h.get('Size')} {h.get('Unite')} {h['TC']:.0f}"
)
print(" 없음", [r["열쇠"] for r, _ in none])
# ── (3) 기계경비 ──────────────────────────────────────────────────────
MACH_WORDS = (
("불도우저", "불도저"),
("굴삭기", "굴착기"),
("(타이어형)", "(타이어)"),
("브레이카", "브레이커"),
("로울러", "롤러"),
("크로울러", "크롤러"),
("진동자주식", "진동 자주식"),
)
FIRST = ("0201-0020", "0201-0040", "0201-0070", "0101-", "0602-", "1306-", "1305-", "0230-")
LOGICS = [
"공통 8 기계 시간당 경비",
"공통 8 기계 시간당 경비(연료 없음)",
"공통 8 기계 시간당 경비(조종원 없음)",
"공통 8 기계 시간당 손료",
]
def mach_key(name, size) -> str:
n = str(name or "")
for a, b in MACH_WORDS:
n = n.replace(a, b)
size = norm(size).replace("", "~").replace("ton", "").replace("", "")
return norm(n) + "|" + size
def machine_cost(master: mf.Master, mid: str, region="전국평균"):
"""로직으로 계산 — 고르기 밖 기계는 메모리에서만 고르기에 더함."""
for key in LOGICS:
row = master.logic("건설품셈:" + key)
spec = next(i for i in row["입력"] if i["이름"] == "기계")
added = mid not in spec["고르기"]
if added:
spec["고르기"].append(mid)
try:
given = {"기계": mid, "지역": region}
given = {k: v for k, v in given.items() if any(i["이름"] == k for i in row["입력"])}
return key, mf.run(master, "건설품셈:" + key, given)
except (mf.FormulaError, ArithmeticError, KeyError, TypeError):
continue
finally:
if added:
spec["고르기"].remove(mid)
return None, None
def machine(estx: Estx, files: dict, master: mf.Master):
est = {}
for r in estx.table("MachTitleDat"):
if r.get("Unite") == "hr" and r.get("TC"):
est.setdefault(mach_key(r.get("Name"), r.get("Size")), r)
ours = files["기계_건설품셈.json"][""]
pairs = [(m, est[k]) for m in ours if (k := mach_key(m["이름"], m["규격"])) in est]
print(f"## 기계 — 우리 {len(ours)} · ESTX 시간당 {len(est)} · 기종·규격 짝 {len(pairs)}")
out = []
for m, e in pairs:
key, res = machine_cost(master, m["열쇠"])
if not res:
continue
out.append((m, e, key, res))
print(f" 로직 계산됨 {len(out)}")
first = [x for x in out if x[0]["열쇠"].startswith(FIRST)]
for label, group in (("임도 기계", first), ("전체", out)):
print(f"### {label}")
print(" 계:", spread([rate(e["TC"], res[""]) for m, e, k, res in group]))
for part, col in (
("손료(경비)", "JC"),
("연료·잡품(재료비)", "GC"),
("조종원(노무비)", "NC"),
):
item = {
"손료(경비)": "경비",
"연료·잡품(재료비)": "재료비",
"조종원(노무비)": "노무비",
}[part]
print(
f" {part}:",
spread([rate(e[col], res[item]) for m, e, k, res in group if e.get(col)]),
)
print("### 손료 ±0.5% 밖 · 연료 ±5%")
for m, e, k, res in out:
if abs(rate(e["JC"], res["경비"])) > 0.5 or (
e.get("GC") and abs(rate(e["GC"], res["재료비"])) > 5
):
print(
f" {m['열쇠']} {m['이름']} {m['규격']} {k} | ESTX 재 {e.get('GC', 0):.0f}{e['JC']:.0f} | 우리 재 {res['재료비']:.0f}{res['경비']:.0f}"
)
for m, e, k, res in first:
print(
f" {m['열쇠']} {m['이름']} {m['규격']} | ESTX {e['TC']:.0f} (노 {e.get('NC', 0):.0f}{e.get('GC', 0):.0f}{e.get('JC', 0):.0f})"
f" | 우리 {res['']:.0f} (노 {res['노무비']:.0f}{res['재료비']:.0f}{res['경비']:.0f}) {rate(e['TC'], res['']):+.1f}%"
)
return out
# ── (4) 일위대가 ──────────────────────────────────────────────────────
# (ESTX 제목 코드, 로직 열쇠, 입력[, 단위 환산 배수 — 우리 값 × 배수 = ESTX 단위])
REGION = {"지역": "전국평균", "보정작업": "해당 없음"}
PAIRS = [
(
"U0000001217",
"공통 3-2-1 굴착(인력/토사)",
{"토질": "보통토사", "넓은구역가산": 0, "용수감": 0, "잔토처리": "없음"},
),
(
"U0000001218",
"공통 3-2-1 굴착(인력/토사)",
{"토질": "경질토사", "넓은구역가산": 0, "용수감": 0, "잔토처리": "없음"},
),
("U0000001665", "공통 3-9-1 벌목", {"나무높이": 4, "집재거리": 100, **REGION}, "0.1"),
("U0000001666", "공통 3-9-1 벌목", {"나무높이": 6, "집재거리": 100, **REGION}, "0.1"),
("U0000001667", "공통 3-9-1 벌목", {"나무높이": 10, "집재거리": 100, **REGION}, "0.1"),
("U0000001260", "공통 4-1-1 잔디붙임", {"시공법": "평떼"}),
("U0000001261", "공통 4-1-1 잔디붙임", {"시공법": "줄떼"}),
(
"U0000002799",
"공통 4-3-4 식재(나무높이)",
{"구분": "인력시공", "나무높이": "2.0이하", "지주목": "세움", **REGION},
),
(
"U0000002802",
"공통 4-3-4 식재(나무높이)",
{"구분": "인력시공", "나무높이": "3.0이하", "지주목": "세움", **REGION},
),
(
"U0000002693",
"공통 4-3-2 굴취(나무높이)",
{"구분": "인력시공", "나무높이": "2.0이하", "야생감": 0, "": "분 있음", **REGION},
),
(
"U0000003316",
"공통 4-3-2 굴취(나무높이)",
{"구분": "기계시공", "나무높이": "2.0이하", "야생감": 0, "": "분 있음", **REGION},
),
("U0000002794", "공통 4-2-2 식재(단식)", {"나무높이": "0.3미만"}),
("U0000002633", "공통 4-2-3 식재(군식)", {"나무높이": "1.2~1.5이하"}),
("U0000002661", "공통 4-1-4 초화류 식재", {"등급": "보통", "특수화단감": 0}),
(
"U0000001171",
"공통 6-1-1 레디믹스트콘크리트 타설",
{
"타설방법": "인력운반타설",
"구조물": "무근구조물",
"소량감률": 0,
"굴착기": "0201-0070",
"지역": "전국평균",
},
),
(
"U0000001172",
"공통 6-1-1 레디믹스트콘크리트 타설",
{
"타설방법": "인력운반타설",
"구조물": "철근구조물",
"소량감률": 0,
"굴착기": "0201-0070",
"지역": "전국평균",
},
),
("U0000003127", "공통 6-2-2 철근 현장가공", {"Type": "Type-"}),
(
"U0000001068",
"공통 6-3-1 합판거푸집",
{
"유형": "복잡",
"사용횟수": "3회",
"합판": "20142452",
"각재": "20141038",
"높이감률": 0,
"지붕슬래브": "아님",
"폼타이": "아님",
"폼타이규격": "12.7㎜",
"측압": "3t/㎡",
"구멍땜": 0,
},
),
("U0000001101", "공통 2-7-1 강관비계 설치 및 해체", {"높이": "10m이하", "보호망": "시공"}),
(
"U0000001988",
"유지관리 1-2-12 예초",
{"연간시공횟수": "연3회 이상", "경사도": "25˚ 미만", "그물막": "필요 없음"},
),
("U0000001958", "공통 3-7-3 천연섬유사면보호공 설치", {}),
("U0000001668", "공통 6-1-6 콘크리트 치핑", {}),
("U0000001703", "공통 5-3-4 말뚝두부정리(콘크리트)", {"규격": "ø400", "지역": "전국평균"}),
("U0000001694", "공통 5-3-3 말뚝두부정리(강관)", {"규격": "ø400", "지역": "전국평균"}),
("U0000003394", "토목 6-2-1 타이튼 접합 및 부설 인력", {"관경": 80, "시공조건": "시공조건 A"}),
("U0000077626", "토목 6-3-1 부설 인력", {"관경": 80, "시공조건": "시공조건 A"}),
(
"U0000003527",
"토목 6-8-1 주철제 게이트 제수밸브 부설 및 접합 인력",
{"관경": 50, "시공조건": "시공조건 A"},
),
("U0000001289", "건축 11-2-6 녹막이 페인트칠", {"시공면": "", "비계층수": 0}),
]
def with_estx_materials(master: mf.Master, estx: Estx, code: str):
"""ESTX 일위대가의 자재 단가를 품명으로 우리 빈 재료(값 없음)에만 임시로 넣은 사본 — 파일에 안 씀."""
temp = copy.copy(master)
temp.index = {k: dict(v) for k, v in master.index.items()}
items = [
d
for d in estx.details("HeadMainDat").get(code, [])
if str(d.get("Code", "")).startswith("M")
]
put = []
for book in (("재료", "자체"), ("재료", "나라장터자재")):
rows = temp.index.get(book, {})
for key, row in list(rows.items()):
if row.get("") is not None:
continue
hit = next(
(d for d in items if base_name(d["Name"]) == base_name(row.get("이름"))), None
)
if hit:
rows[key] = {**row, "": Decimal(str(hit["TU"]))}
put.append(f"{key}={hit['TU']:.0f}")
return temp, put
def unit_price(estx: Estx, master: mf.Master):
heads = {r["Code"]: r for r in estx.table("HeadTitleDat")}
print("## 일위대가")
rates = []
for code, key, given, *scale in PAIRS:
h = heads[code]
k = Decimal(scale[0]) if scale else Decimal(1)
temp, put = with_estx_materials(master, estx, code)
row = temp.logic("건설품셈:" + key)
given = {k: v for k, v in given.items() if any(i["이름"] == k for i in row.get("입력", []))}
given = {k: Decimal(str(v)) if isinstance(v, (int, float)) else v for k, v in given.items()}
try:
res = mf.run(temp, "건설품셈:" + key, given)
res = {c: res[c] * k for c in ("", *mf.COST_ITEMS)}
except (mf.FormulaError, ArithmeticError, KeyError, TypeError) as e:
print(f" {code} {h['Name']} {h.get('Size')} | {key} | 멈춤: {str(e)[:120]}")
continue
r = rate(h["TC"], res[""])
rates.append(r)
print(
f" {code} {h['Name']} {h.get('Size')} {h.get('Unite')} | ESTX {h['TC']:.0f} (노 {h.get('NC', 0):.0f}{h.get('GC', 0):.0f}{h.get('JC', 0):.0f})"
f" | {key} {row.get('결과단위')} | 우리 {res['']:.0f} (노 {res['노무비']:.0f}{res['재료비']:.0f}{res['경비']:.0f}) {r:+.1f}%"
+ (f" | 임시 재료 {put}" if put else "")
)
print(" 계:", spread(rates))
def main():
only = sys.argv[1:]
estx = Estx()
files, master = load_master()
if not only or "노임" in only:
labor(estx, files)
if not only or "자재" in only:
material(estx, files)
if not only or "기계" in only:
machine(estx, files, master)
if not only or "일위대가" in only:
unit_price(estx, master)
if __name__ == "__main__":
main()