"""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(" 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(" 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)