Files
Aislo/B09_Estimation/B09_Estimation_Estx.py
T
eomsangdonandClaude Opus 5.5 550c0adcce feat(B09 설계내역서): .estx(EST 원본) 바로 읽기 모듈 — 내역 · 호표 목록 · 노임 · 자재 · 기계 평문 표 (PLAN 707-5)
- B09_Estimation_Estx: ZIP 안 DBISAM 고정 레코드 표를 메모리에서 읽음 · 엑셀 읽기와 같은 꼴(707-1 내역 · 707-3 단가표)
- 구성 줄 → 호표 귀속 · 제N호표 번호 · 원가계산서 행 경계는 안 읽음(UNKNOWN · 데스크탑 REA 필요)
- 시험 test_707_5_estx: 거창 = 엑셀과 줄마다 같음 · 직접비 384,916,227 · 장수 · 진안 줄 수 · 합 기록

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:24:02 +09:00

245 lines
10 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.
"""B09 EST 원본(.estx) 바로 읽기(PLAN 707-5) — 메모리에서 풀어 평문 표만 · 저장 안 함.
.estx = 보통 ZIP · 표마다 DBISAM `.dat`(고정 레코드) · `.idx` · `.blb`
(지식DB `original/원가계산/다산소프트/ESTX_파일구조.md` · `ESTX_테이블사전.md`):
머리 0x29 레코드 수 · 0x2D 레코드 길이 · 0x2F 필드 수 ·
필드 설명자 = 0x200 + n×0x300(+0x02 이름 길이 · +0xA4 자료형 · +0xA9 길이 · +0xAC 자리) ·
데이터 = 512 + 필드 수×768 · 필드 = 존재 바이트 1 + 값(0 = 미저장 = None)
자료형 1 CP949 글 · 2 날짜 서수 · 4 int16 · 6 int32 · 7 double · 3 BLOB(안 읽음)
금액 넷(`REA 분석/ESTPlus2_REA_최종보고서_20261030.md:31`) = N 노무 · G 재료 · J 경비 · S 실적
(단가 NU · GU · JU · SU · 금액 NC · GC · JC · SC · 합 TU · TC · 수량 GN) — S 는 셋에 안 넣음.
밑줄 칸(`TC_` …) = 비교 스냅샷(방향 미확정) — 안 읽음.
돌려주는 꼴 = 엑셀 읽기와 같은 dict — 뒤에 고리만 이으면 같은 그릇:
`read_bill` = `B09_Estimation_Bill_Excel.read_bill`(707-1) — EstMainDat
묶음 = Level 있음(클수록 바깥) · 항목 = Level 없음 + 이름 · 이름 없음 = 빈 줄 ·
LinkIndex 54 · 55 · 59(순공사비 · 도급액 · 경계) = 합계 줄(안 읽음) ·
57 = 조달수수료(율 = GN · 줄로 안 실음) · 그 밖 % 줄 = 1 식(단가 = 금액) ·
제외 = 순공사비 뒤 · 이름 「관급」 맨 위 묶음 ·
호표 = LinkIndex 103 대가 · 104 산근 · 5 자재 · 6 노임 · 105 기계 + 코드
`read_prices` = `B09_Estimation_Import_Prices.read_prices`(707-3) — LaborcDat · StuffcDat ·
MachTitleDat(+ 자재 출처 6칸 `sources`)
`read_hopyo` = 호표 목록(HeadTitleDat 대가 · CostTitleDat 산근 — 코드 · 이름 · 단가 셋)
안 읽음(데스크탑 REA 필요 · 같은 공사 엑셀로 메움) = `UNKNOWN`.
"""
from __future__ import annotations
import io
import struct
import zipfile
from decimal import Decimal
from typing import Any
from B09_Estimation.B09_Estimation_Bill_Excel import BillExcelError, _plain, _unwrap
UNKNOWN = (
"일위대가 · 단가산출 구성 줄 → 호표 귀속(HeadMainDat · CostMainDat)",
"화면 「제N호표」 번호 자리",
"원가계산서 행 경계(WongaDat)",
)
#: 가격자료 6칸 이름 — `ProjectDataInfo.Ini` TitleName1..6 이 없을 때(`ESTX_데이터관계.md`)
SOURCES = ("가격정보", "물가자료", "유통물가", "물가정보", "거래가격", "견적가")
PARTS = (("N", "labor"), ("G", "material"), ("J", "expense"))
LINK = {103: "대가", 104: "산근", 5: "자재", 6: "노임", 105: "기계"}
TOTAL_LINKS = {54, 55, 59}
FEE_LINK = 57
_SIZE = {1: None, 2: 4, 4: 2, 6: 4, 7: 8}
class EstxError(BillExcelError):
"""못 읽는 .estx — 길이 400 으로 답함."""
def _zip(data: bytes) -> zipfile.ZipFile:
if data[:4] != b"PK\x03\x04":
raise EstxError(".estx 가 아님 — ZIP 머리 없음")
try:
book = zipfile.ZipFile(io.BytesIO(data))
except zipfile.BadZipFile as error:
raise EstxError("깨진 .estx") from error
if any(i.flag_bits & 1 for i in book.infolist()):
raise EstxError("암호 걸린 .estx — 읽을 수 없음")
return book
def table(book: zipfile.ZipFile, name: str, want: tuple[str, ...]) -> list[dict[str, Any]]:
"""DBISAM `{name}.dat` → 레코드 목록(파일 차례) · `want` 칸만."""
try:
d = book.read(f"{name}.dat")
except KeyError as error:
raise EstxError(f".estx 안에 {name} 표가 없음") from error
count, size, nf = struct.unpack_from("<IHH", d, 0x29)
cols = []
for n in range(nf):
at = 0x200 + n * 0x300
field = d[at + 3 : at + 3 + d[at + 2]].decode("ascii", "replace")
kind = d[at + 0xA4]
if field in want and kind in _SIZE:
width = struct.unpack_from("<H", d, at + 0xA9)[0]
cols.append((field, kind, width, struct.unpack_from("<I", d, at + 0xAC)[0]))
start = 512 + nf * 768
if start + count * size > len(d):
raise EstxError(f"{name} 표 길이가 머리와 다름")
out = []
for r in range(count):
base = start + r * size
row: dict[str, Any] = dict.fromkeys(want)
for field, kind, width, off in cols:
if not d[base + off]:
continue
at = base + off + 1
if kind == 1:
row[field] = d[at : at + width].split(b"\0")[0].decode("cp949", "replace").strip()
elif kind == 7:
row[field] = struct.unpack_from("<d", d, at)[0]
else:
row[field] = struct.unpack_from("<h" if kind == 4 else "<i", d, at)[0]
out.append(row)
return out
def _dec(value: Any) -> Decimal:
return Decimal(repr(value)) if isinstance(value, float) else Decimal(value or 0)
def _parts(row: dict[str, Any], tail: str) -> dict[str, Decimal]:
return {key: _dec(row.get(f"{p}{tail}")) for p, key in PARTS}
_BILL = ("Code", "Name", "Size", "Unite", "GN", "Level", "LinkIndex")
_BILL += tuple(f"{p}{t}" for p, _ in PARTS for t in "UC")
def read_bill(name: str, data: bytes) -> dict[str, Any]:
""".estx → 엑셀 `read_bill` 과 같은 꼴(`행` = EstMainDat 차례 1~)."""
rows = table(_zip(data), "EstMainDat", _BILL)
lines: list[dict[str, Any]] = []
stack: list[tuple[int, dict[str, Any]]] = [] # (Level, 묶음 줄)
skipped, fee, pct_lines, after_net = 0, None, 0, False
for at, row in enumerate(rows, 1):
item_name, link, level = row["Name"] or "", row["LinkIndex"], row["Level"]
if link in TOTAL_LINKS:
skipped += 1
after_net = after_net or link == 54
continue
if not item_name:
continue
group = level is not None
if group:
while stack and stack[-1][0] <= level:
stack.pop()
up = stack[-1][1] if stack else None
code = row["Code"] or ""
line = {
"행": at,
"묶음": group,
"위": up["행"] if up else None,
"공종": "",
"이름": item_name,
"규격": row["Size"] or "",
"단위": row["Unite"] or "",
"수량": None if group else _dec(row["GN"]),
"단가": _parts(row, "U"),
"금액": _parts(row, "C"),
"비고": "",
"호표": {"종류": LINK[link], "코드": code} if not group and link in LINK else None,
"제외": (up or {}).get("제외", False)
or (up is None and (after_net or "관급" in item_name)),
}
if group:
stack.append((level, line))
elif line["단위"] == "%":
if link == FEE_LINK:
fee = fee if fee is not None else line["수량"]
continue
pct_lines += 1
line.update({"단가": line["금액"], "수량": Decimal(1), "단위": "식"})
lines.append(line)
_unwrap(lines)
total = {"직접": Decimal(0), "제외": Decimal(0)}
for line in lines:
if not line["묶음"]:
total["제외" if line["제외"] else "직접"] += sum(line["금액"].values())
return _plain(
{
"꼴": "estx",
"시트": "EstMainDat",
"줄": lines,
"합계": total,
"조달수수료율": fee,
"합계줄": skipped,
"율줄": pct_lines,
}
)
def _item(row: dict[str, Any]) -> dict[str, Any]:
return {"name": row["Name"] or "", "spec": row["Size"] or "", "unit": row["Unite"] or ""}
def _titles(book: zipfile.ZipFile) -> tuple[str, ...]:
try:
text = book.read("ProjectDataInfo.Ini").decode("cp949", "replace")
except KeyError:
return SOURCES
got = dict(line.split("=", 1) for line in text.splitlines() if "=" in line)
return tuple(got.get(f"TitleName{n}", "").strip() or SOURCES[n - 1] for n in range(1, 7))
def read_prices(name: str, data: bytes) -> dict[str, Any]:
""".estx → 707-3 `read_prices` 꼴 {labor, material, machine, *_sheet} ·
자재 줄 + `sources` [{출처, 단가, 쪽}](값 있는 칸만)."""
book = _zip(data)
head = ("Code", "Name", "Size", "Unite", "TC")
cost = tuple(f"Cost{n}" for n in range(1, 7)) + tuple(f"Page{n}" for n in range(1, 7))
titles = _titles(book)
labor = [{**_item(r), "price": r["TC"]} for r in table(book, "LaborcDat", head) if r["Name"]]
material = []
for r in table(book, "StuffcDat", head + cost):
if not r["Name"] or r["Unite"] == "%":
continue
sources = [
{"출처": titles[n - 1], "단가": r[f"Cost{n}"], "쪽": r[f"Page{n}"] or ""}
for n in range(1, 7)
if r[f"Cost{n}"]
]
material.append({**_item(r), "price": r["TC"], "sources": sources})
machine = [
{**_item(r), **{key: r[f"{p}C"] or 0 for p, key in PARTS}}
for r in table(book, "MachTitleDat", head + tuple(f"{p}C" for p, _ in PARTS))
if r["Name"]
]
return _plain(
{
"labor": labor,
"labor_sheet": "LaborcDat",
"material": material,
"material_sheet": "StuffcDat",
"machine": machine,
"machine_sheet": "MachTitleDat",
}
)
def read_hopyo(name: str, data: bytes) -> dict[str, list[dict[str, Any]]]:
""".estx → {대가: HeadTitleDat, 산근: CostTitleDat} 목록
[{코드, 이름, 규격, 단위, 단가 {labor, material, expense}, 합계}] — 구성 줄은 `UNKNOWN`."""
book = _zip(data)
want = ("Code", "Name", "Size", "Unite", "TC") + tuple(f"{p}C" for p, _ in PARTS)
out = {}
for kind, name_ in (("대가", "HeadTitleDat"), ("산근", "CostTitleDat")):
out[kind] = [
{
"코드": r["Code"] or "",
"이름": r["Name"],
"규격": r["Size"] or "",
"단위": r["Unite"] or "",
"단가": _parts(r, "C"),
"합계": _dec(r["TC"]),
}
for r in table(book, name_, want)
if r["Name"]
]
return _plain(out)