Files
Aislo/resources/master_data/scripts/compare_estx.py
T
eomsangdonandClaude Opus 5 11c9f562fc 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
2026-09-19 21:54:28 +09:00

522 lines
21 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.
"""상용 적산 자료(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()