feat(810-1): 경쟁사 원파일 파서 — EST(.estx) · STmate(.stc) 구조 줄 · node · smt_parser 거울 대조
- M02_San_Estx: DBISAM 표 · BLOB 읽기(import-estx.cjs 거울) · 산근 · 일위대가 블록 → 구조 줄(LinkIndex 뜻 · SCheck 비목 · LineData 표지 · 기계 세 줄 · 산근 속 산근 · 자리) · 줄합 = 저장 결과만 - M02_San_Stc: COSTN.DBF · COSTN.SMT · BOQ11.DBF 읽기(smt_parser 거울) · 줄 풀이 = M02_San_Stmate 재사용 · BDQTY 난독화 표지 · Start8x.Ini 옵션 그대로 - 시험 test_810_1_estx · test_810_1_stc 34 통과 — 거울 다름 0(estx 6 · stc 7) · 블록 금액 = 저장값 100 %(EST 3,378 · STC 371) · 엑셀 엔진 대조(거창 줄 갈래 547 중 265 = 엑셀이 작업량 Q 를 상수로 봄) - 꼴 문서 resources/master_data/_원파일파서_틀.md · 대조 목록 docs/raw/요구_1104/_목록_810-1_원파일대조.xlsx Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,416 @@
|
||||
"""경쟁사 EST 원파일(.estx) 파서(PLAN 810-1 · 요구 705 · 706 · 읽기만 · 서버 단독).
|
||||
|
||||
거울 = `tools/est-test/import-estx.cjs`(unzip · blob · table —
|
||||
시험 `test_810_1_estx.py` 가 node 결과와
|
||||
표 · 줄 · 칸 1:1 대조) · 칸 뜻 = `resources/master_data/_원파일파서_틀.md` ·
|
||||
지식DB `original/경쟁사 프로그램/EST Plus 2/다산소프트/ESTX_파일구조.md` · `ESTX_테이블사전.md`.
|
||||
|
||||
.estx = ZIP · 표마다 DBISAM `.dat`(머리 512 · 필드 설명자 768 × n · 고정 레코드) +
|
||||
`.blb`(512 블록 체인).
|
||||
확정 칸만 옮김(추정 0) — Code · LinkIndex(뜻 `LINK`) · SCheck(비목 · 율 대상) ·
|
||||
Diget/DigetIndex(자리) ·
|
||||
LineData 제어문자(`MARK`) · 저장 금액(TC NC GC JC SC). 없는 것 = 비움 + 표지.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import re
|
||||
import struct
|
||||
import zipfile
|
||||
from typing import Any
|
||||
|
||||
MAGIC = bytes.fromhex("068abe8e592364cb403d71d2e3bc64d0")
|
||||
LIMIT = 256 * 1024 * 1024
|
||||
#: 파일 이름(소문자) → 표 이름(node `importEstx` 와 같음)
|
||||
CANON = {
|
||||
n.lower(): n
|
||||
for n in (
|
||||
"StuffcDat LaborcDat SilJukDat EstiMateDat MachGijunDat MachMainDat MachTitleDat "
|
||||
"CostMainDat CostTitleDat HeadMainDat HeadTitleDat EstMainDat MulMainDat MulTitleDat "
|
||||
"CadMainDat CadTitleDat"
|
||||
).split()
|
||||
}
|
||||
#: LinkIndex 뜻(`native-engine.js:4-12` · `native-rows.js`)
|
||||
LINK = {
|
||||
5: "자재",
|
||||
6: "노임",
|
||||
21: "표준",
|
||||
101: "견적",
|
||||
103: "일위",
|
||||
104: "산근",
|
||||
105: "기계",
|
||||
51: "소계",
|
||||
61: "소계",
|
||||
68: "소계",
|
||||
52: "율",
|
||||
58: "율",
|
||||
62: "율",
|
||||
}
|
||||
#: 자원 줄 LinkIndex → 단가 표
|
||||
SOURCE = {
|
||||
5: "StuffcDat",
|
||||
6: "LaborcDat",
|
||||
21: "SilJukDat",
|
||||
101: "EstiMateDat",
|
||||
103: "HeadTitleDat",
|
||||
104: "CostTitleDat",
|
||||
105: "MachTitleDat",
|
||||
}
|
||||
#: 금액 칸 → 비목 글자(T 계 · L 노무 · M 재료 · S 경비 · R 실적)
|
||||
AMT = {"TC": "T", "NC": "L", "GC": "M", "JC": "S", "SC": "R"}
|
||||
UNIT_AMT = {"TU": "T", "NU": "L", "GU": "M", "JU": "S", "SU": "R"}
|
||||
#: 산근 줄 SCheck 음수 = 그 줄이 낸 비목 · -5 = 계
|
||||
SC_LINE = {-1: "L", -2: "M", -3: "S", -4: "R", -5: "T"}
|
||||
#: 율 · 소계 줄 SCheck 양수 = 대상 비목(`native-rows.js` target)
|
||||
SC_RATE = {1: "M", 2: "L", 3: "S", 4: "R"}
|
||||
#: LineData 제어문자(`native-text.js` names)
|
||||
MARK = {
|
||||
"\x0e": "품명",
|
||||
"\x13": "규격",
|
||||
"\x15": "단위",
|
||||
"\x03": "단가",
|
||||
"\x11": "변수",
|
||||
"\x0c": "줄참조",
|
||||
"\x12": "결과",
|
||||
"\x1a": "합계",
|
||||
"\x06": "구간",
|
||||
"\x07": "구간합",
|
||||
}
|
||||
_NUM = r"([-\d.,]+)"
|
||||
_CTRL = re.compile(r"[\x00-\x1f]")
|
||||
|
||||
|
||||
class EstxError(ValueError):
|
||||
"""못 읽는 .estx."""
|
||||
|
||||
|
||||
# ── DBISAM 표 읽기(거울 = import-estx.cjs blob · table) ─────────────────────
|
||||
def blob(f: bytes | None, start: int, ident: int) -> bytes:
|
||||
"""`.blb` 512 블록 체인 → 바이트(첫 블록 선언 길이까지)."""
|
||||
if not f or start == 0:
|
||||
return b""
|
||||
parts, seen, block, previous, expected, got = [], set(), start, 0, 0, 0
|
||||
while block:
|
||||
at = block * 512
|
||||
if block in seen or at + 512 > len(f):
|
||||
raise EstxError("BLOB 체인 범위/순환 오류")
|
||||
seen.add(block)
|
||||
if struct.unpack_from("<I", f, at)[0] != previous:
|
||||
raise EstxError("BLOB 역연결 오류")
|
||||
length = struct.unpack_from("<H", f, at + 8)[0]
|
||||
if length > 494:
|
||||
raise EstxError("BLOB 조각 길이 오류")
|
||||
if block == start:
|
||||
if struct.unpack_from("<I", f, at + 10)[0] != ident:
|
||||
raise EstxError("BLOB 식별자 오류")
|
||||
expected = struct.unpack_from("<I", f, at + 14)[0]
|
||||
if expected > LIMIT:
|
||||
raise EstxError("BLOB 크기 한도 초과")
|
||||
take = min(length, expected - got)
|
||||
parts.append(f[at + 18 : at + 18 + take])
|
||||
got += take
|
||||
if got == expected:
|
||||
break
|
||||
previous, block = block, struct.unpack_from("<I", f, at + 4)[0]
|
||||
out = b"".join(parts)
|
||||
if len(out) != expected:
|
||||
raise EstxError(f"BLOB 총 길이 오류: 블록 {start}")
|
||||
return out
|
||||
|
||||
|
||||
def table(buf: bytes, blb: bytes | None, warn: set[str]) -> list[dict[str, Any]]:
|
||||
"""`.dat` → 행 목록(값 없음 = None · BLOB = bytes · 모르는 형 = {binary, kind})."""
|
||||
if len(buf) < 512:
|
||||
raise EstxError("테이블 헤더가 짧음")
|
||||
stride, count = struct.unpack_from("<HH", buf, 45)
|
||||
start = 512 + 768 * count
|
||||
if not stride or count > 1024 or start > len(buf) or (len(buf) - start) % stride:
|
||||
raise EstxError("DBISAM 레이아웃과 다른 파일")
|
||||
fields = []
|
||||
for i in range(count):
|
||||
at = 512 + 768 * i
|
||||
name = buf[at + 3 : at + 3 + buf[at + 2]].decode("ascii", "replace")
|
||||
kind, width = buf[at + 164], struct.unpack_from("<H", buf, at + 169)[0]
|
||||
payload = struct.unpack_from("<I", buf, at + 172)[0]
|
||||
if not re.fullmatch(r"[A-Za-z_]\w*", name) or payload + width + 1 > stride:
|
||||
raise EstxError("필드 설명자 오류")
|
||||
fields.append((name, kind, width, payload))
|
||||
rows = []
|
||||
for off in range(start, len(buf), stride):
|
||||
row: dict[str, Any] = {}
|
||||
for name, kind, width, payload in fields:
|
||||
flag = buf[off + payload]
|
||||
raw = buf[off + payload + 1 : off + payload + 1 + width]
|
||||
if not flag:
|
||||
row[name] = None
|
||||
continue
|
||||
if flag != 1:
|
||||
raise EstxError(f"행 존재 플래그 {flag}")
|
||||
if kind == 1:
|
||||
b = raw.split(b"\0", 1)[0]
|
||||
try:
|
||||
row[name] = b.decode("cp949")
|
||||
except UnicodeDecodeError:
|
||||
row[name] = b.decode("cp949", "replace")
|
||||
row.setdefault("_originalTextBytes", {})[name] = (
|
||||
b # 원 바이트 보존(node 와 같음)
|
||||
)
|
||||
warn.add(f"CP949 범위 밖 문자열: {name}")
|
||||
elif kind == 7 and width == 8:
|
||||
row[name] = struct.unpack("<d", raw)[0]
|
||||
elif kind in (2, 6) and width == 4:
|
||||
row[name] = struct.unpack("<i", raw)[0]
|
||||
elif kind == 4 and width == 2:
|
||||
row[name] = struct.unpack("<h", raw)[0]
|
||||
elif kind == 3 and width == 8:
|
||||
row[name] = blob(blb, *struct.unpack("<II", raw))
|
||||
else:
|
||||
row[name] = {"binary": raw, "kind": kind}
|
||||
warn.add(f"미해석 필드형: {kind}/{width}")
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def tables(data: bytes, warn: set[str] | None = None) -> dict[str, list[dict[str, Any]]]:
|
||||
""".estx 바이트 → {표 이름: 행 목록}(DBISAM 표지가 있는 `.dat` 만 · node 와 같은 이름)."""
|
||||
warn = set() if warn is None else warn
|
||||
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")
|
||||
files = {i.filename.lower(): i.filename for i in book.infolist()}
|
||||
out = {}
|
||||
for low, real in files.items():
|
||||
if not low.endswith(".dat"):
|
||||
continue
|
||||
d = book.read(real)
|
||||
if d[8:24] != MAGIC:
|
||||
continue
|
||||
blb_name = files.get(low[:-4] + ".blb")
|
||||
try:
|
||||
rows = table(d, book.read(blb_name) if blb_name else None, warn)
|
||||
except (EstxError, struct.error) as error:
|
||||
raise EstxError(f"{low}: {error}") from error
|
||||
out[CANON.get(low[:-4], low[:-4])] = rows
|
||||
if "EstMainDat" not in out:
|
||||
raise EstxError("내역 테이블이 없는 ESTX")
|
||||
return out
|
||||
|
||||
|
||||
# ── 구조 줄 ──────────────────────────────────────────────────────────
|
||||
def _num(s: str) -> float:
|
||||
return float(s.replace(",", ""))
|
||||
|
||||
|
||||
def _amounts(row: dict[str, Any], names: dict[str, str]) -> dict[str, float] | None:
|
||||
got = {k: row.get(f) for f, k in names.items() if row.get(f) is not None}
|
||||
return got or None
|
||||
|
||||
|
||||
def marks(text: str) -> dict[str, Any]:
|
||||
"""LineData → 표지별 조각(품명 · 규격 · 단위 = 첫 조각 글 · 나머지 = 수 목록)."""
|
||||
out: dict[str, Any] = {}
|
||||
for ch, key in MARK.items():
|
||||
if ch not in text:
|
||||
continue
|
||||
if key in ("품명", "규격", "단위"):
|
||||
m = re.search(f"{ch}([^{ch}]*){ch}", text)
|
||||
out[key] = m.group(1) if m else None
|
||||
elif key in ("단가", "변수", "줄참조", "결과"):
|
||||
out[key] = [_num(v) for v in re.findall(f"{ch}{_NUM}{ch}", text)]
|
||||
else:
|
||||
out[key] = True
|
||||
return out
|
||||
|
||||
|
||||
def resources(t: dict[str, list[dict[str, Any]]]) -> dict[tuple[int, str], dict[str, Any]]:
|
||||
"""(LinkIndex, 코드) → 자원 머리(표 · 이름 · 규격 · 단위 · 저장 금액)."""
|
||||
out = {}
|
||||
for link, name in SOURCE.items():
|
||||
for r in t.get(name, []):
|
||||
if r.get("Code"):
|
||||
out[(link, r["Code"])] = {
|
||||
"표": name,
|
||||
"이름": r.get("Name"),
|
||||
"규격": r.get("Size"),
|
||||
"단위": r.get("Unite"),
|
||||
"저장": _amounts(r, AMT),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def _resource(row: dict[str, Any], res: dict, tags: list[str]) -> dict[str, Any] | None:
|
||||
link, code = row.get("LinkIndex"), row.get("Code")
|
||||
if not code or link not in SOURCE:
|
||||
return None
|
||||
head = res.get((link, code))
|
||||
if head is None:
|
||||
tags.append(f"자원 못 찾음 {LINK[link]} {code}")
|
||||
return {"코드": code, "종류": LINK[link], **(head or {"표": SOURCE[link]})}
|
||||
|
||||
|
||||
def _sc(row: dict[str, Any]) -> str | None:
|
||||
sc = row.get("SCheck") or 0
|
||||
if sc < 0:
|
||||
return SC_LINE.get(sc)
|
||||
return SC_RATE.get(sc) if LINK.get(row.get("LinkIndex") or 0) in ("율", "소계") else None
|
||||
|
||||
|
||||
def _tag(link: int | None, ld: dict[str, Any]) -> str | None:
|
||||
kind = LINK.get(link or 0)
|
||||
if kind in ("율", "소계"):
|
||||
return kind
|
||||
return "합계" if ld.get("합계") else None
|
||||
|
||||
|
||||
def _groups(rows: list[dict[str, Any]], title: dict[str, dict]) -> list[tuple[dict, list]]:
|
||||
"""Index > 0 줄이 블록 머리(node `groups`) → [(제목, 몸 줄)]."""
|
||||
out: list[tuple[dict, list]] = []
|
||||
for r in rows:
|
||||
if (r.get("Index") or 0) > 0:
|
||||
out.append((title.get(r.get("Code")) or {"Code": r.get("Code")}, []))
|
||||
elif out:
|
||||
out[-1][1].append(r)
|
||||
return out
|
||||
|
||||
|
||||
def _head(t: dict[str, Any], tags: list[str]) -> dict[str, Any]:
|
||||
if "Name" not in t:
|
||||
tags.append("제목 표에 코드 없음")
|
||||
return {
|
||||
"코드": t.get("Code"),
|
||||
"이름": t.get("Name"),
|
||||
"규격": t.get("Size"),
|
||||
"단위": t.get("Unite"),
|
||||
"저장": _amounts(t, AMT),
|
||||
}
|
||||
|
||||
|
||||
def cost_line(at: int, r: dict[str, Any], res: dict, tags: list[str]) -> dict[str, Any]:
|
||||
"""CostMainDat 한 줄 → 구조 줄."""
|
||||
text = r.get("LineData") or ""
|
||||
ld = marks(text)
|
||||
link = r.get("LinkIndex")
|
||||
return {
|
||||
"행": at,
|
||||
"글": text,
|
||||
"보임": _CTRL.sub("", text),
|
||||
"품명": ld.get("품명"),
|
||||
"규격": ld.get("규격"),
|
||||
"단위": ld.get("단위"),
|
||||
"단": r.get("Level"),
|
||||
"연결": link,
|
||||
"연결뜻": LINK.get(link or 0),
|
||||
"비목": _sc(r),
|
||||
"자원": _resource(r, res, tags),
|
||||
"연결단가": ld.get("단가", []),
|
||||
"변수": ld.get("변수", []),
|
||||
"줄참조": ld.get("줄참조", []),
|
||||
"결과": ld.get("결과", []),
|
||||
"합계": bool(ld.get("합계")),
|
||||
"자리": {"자리수": r.get("Diget"), "방법": r.get("DigetIndex")},
|
||||
"표지": _tag(link, ld),
|
||||
"기계": None,
|
||||
"산근": r.get("Code") if link == 104 else None,
|
||||
"저장": _amounts(r, AMT),
|
||||
}
|
||||
|
||||
|
||||
def _machine(lines: list[dict[str, Any]]) -> None:
|
||||
"""기계 세 줄 묶음 = 잇단 같은 Code + LinkIndex 105 + 비목 줄(SCheck -1~-5) · 첫 줄 행."""
|
||||
first = None
|
||||
for i, ln in enumerate(lines):
|
||||
prev = lines[i - 1] if i else None
|
||||
if ln["연결"] == 105 and ln["비목"] in SC_LINE.values():
|
||||
code = (ln["자원"] or {}).get("코드")
|
||||
same = prev and prev["기계"] and (prev["자원"] or {}).get("코드") == code
|
||||
first = first if same else ln["행"]
|
||||
ln["기계"] = first
|
||||
|
||||
|
||||
def cost_sum(lines: list[dict[str, Any]]) -> dict[str, float]:
|
||||
"""줄 → 블록 비목 합(셈 없이 저장 결과만) — 합계 줄(\\x1a · 율 62 밖)이 있으면 가장 바깥 단의
|
||||
비목 줄 결과 · 없으면 비목 줄(-1~-4) 결과 합(소수 그대로)."""
|
||||
auto = [ln for ln in lines if ln["합계"] and ln["연결"] != 62]
|
||||
out = dict.fromkeys("LMSR", 0.0)
|
||||
if auto:
|
||||
low = min(ln["단"] or 0 for ln in auto)
|
||||
for ln in auto:
|
||||
if (ln["단"] or 0) == low and ln["비목"] in out:
|
||||
out[ln["비목"]] = _shown(ln)
|
||||
return out
|
||||
for ln in lines:
|
||||
if ln["비목"] in out and (ln["연결"] or 0) not in (58, 62, 52):
|
||||
out[ln["비목"]] += ln["결과"][-1] if ln["결과"] else (ln["저장"] or {}).get("T") or 0
|
||||
return out
|
||||
|
||||
|
||||
def _shown(ln: dict[str, Any]) -> float:
|
||||
if ln["결과"]:
|
||||
return ln["결과"][-1]
|
||||
tail = ln["보임"].split(":", 1)[1].strip() if ":" in ln["보임"] else ""
|
||||
return _num(tail) if re.fullmatch(r"-?[\d,]+(?:\.\d+)?", tail) else 0.0
|
||||
|
||||
|
||||
def head_line(at: int, r: dict[str, Any], res: dict, tags: list[str]) -> dict[str, Any]:
|
||||
"""HeadMainDat(일위대가) 한 줄 → 구조 줄(수량 · 단가 · 금액 칸)."""
|
||||
link = r.get("LinkIndex")
|
||||
return {
|
||||
"행": at,
|
||||
"품명": r.get("Name"),
|
||||
"규격": r.get("Size"),
|
||||
"단위": r.get("Unite"),
|
||||
"수량": r.get("GN"),
|
||||
"연결": link,
|
||||
"연결뜻": LINK.get(link or 0),
|
||||
"비목": SC_RATE.get(r.get("SCheck") or 0) if LINK.get(link or 0) == "율" else None,
|
||||
"자원": _resource(r, res, tags),
|
||||
"산출": r.get("CalcIndex"),
|
||||
"자리": {"자리수": r.get("Diget"), "방법": r.get("DigetIndex")},
|
||||
"표지": LINK.get(link or 0) if LINK.get(link or 0) in ("율", "소계") else None,
|
||||
"산근": r.get("Code") if link == 104 else None,
|
||||
"단가": _amounts(r, UNIT_AMT),
|
||||
"저장": _amounts(r, AMT),
|
||||
}
|
||||
|
||||
|
||||
def head_sum(lines: list[dict[str, Any]]) -> dict[str, float]:
|
||||
"""일위대가 줄 → 비목 합(저장 금액 · CalcIndex < 2) — 소계 68 · 율 52 줄에서 누계를 그 줄로
|
||||
다시 시작(node `EstRows.head`)."""
|
||||
body: list[dict[str, Any]] = []
|
||||
for ln in lines:
|
||||
body = [ln] if ln["연결"] in (52, 68) else [*body, ln]
|
||||
keep = [ln for ln in body if (ln["산출"] or 0) < 2]
|
||||
return {c: sum((ln["저장"] or {}).get(c) or 0 for ln in keep) for c in "LMSR"}
|
||||
|
||||
|
||||
def read(data: bytes) -> dict[str, Any]:
|
||||
""".estx → {꼴, 산근, 일위, 자원, 표지}(블록 = 머리 + 구조 줄 + 줄합)."""
|
||||
warn: set[str] = set()
|
||||
t = tables(data, warn)
|
||||
res = resources(t)
|
||||
out: dict[str, Any] = {"꼴": "EST", "산근": [], "일위": [], "내역": None}
|
||||
for key, main, title, line in (
|
||||
("산근", "CostMainDat", "CostTitleDat", cost_line),
|
||||
("일위", "HeadMainDat", "HeadTitleDat", head_line),
|
||||
):
|
||||
titles = {r.get("Code"): r for r in t.get(title, [])}
|
||||
for head, body in _groups(t.get(main, []), titles):
|
||||
tags: list[str] = []
|
||||
blk = _head(head, tags)
|
||||
keep = [r for r in body if key == "산근" or r.get("Name") or r.get("Code")]
|
||||
lines = [line(i, r, res, tags) for i, r in enumerate(keep, 1)]
|
||||
if key == "산근":
|
||||
_machine(lines)
|
||||
blk["줄합"] = cost_sum(lines)
|
||||
else:
|
||||
blk["줄합"] = head_sum(lines)
|
||||
out[key].append({**blk, "줄": lines, "표지": tags})
|
||||
out["자원"] = {f"{LINK[k[0]]}:{k[1]}": v for k, v in res.items()}
|
||||
out["표지"] = sorted(warn) + ["옵션(datestoption.opt) · 원가 서식(wongadat) 안 읽음"]
|
||||
return out
|
||||
@@ -0,0 +1,206 @@
|
||||
"""경쟁사 STmate 원파일(.stc) 파서(PLAN 810-1 · 요구 705 · 706 · 읽기만 · 서버 단독).
|
||||
|
||||
거울 = `tools/stmate-test/smt_parser.py`(`parse_dbf_bytes` · `extract_costn_memos` — 시험
|
||||
`test_810_1_stc.py` 가 행 · 칸 · 원문 1:1 대조) · 글 문법 = `M02_San_Stmate.parse_line` 재사용 ·
|
||||
칸 뜻 = `resources/master_data/_원파일파서_틀.md`.
|
||||
|
||||
.stc = ZIP · COSTN.DBF(자원 · 산근 머리 · 저장 금액) · COSTN.SMT(SIxMemo · 산근 원문 · IFORM =
|
||||
<HII 꼬리표 · 길이 · 블록 번호>) · BOQ11.DBF(내역).
|
||||
BDQTY.DBF(일위대가 구성) · SYSINFO = 난독화 → 표지.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import re
|
||||
import struct
|
||||
import zipfile
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_San_Eval as ev
|
||||
from M02_MasterTemplete import M02_San_Stmate as stm
|
||||
|
||||
#: 코드 머리 글자 → 종류
|
||||
KIND = {"L": "노임", "M": "자재", "S": "경비", "E": "기계", "X": "기계", "D": "산근", "B": "일위"}
|
||||
#: 금액 칸(차수 1) → 비목 글자
|
||||
AMT = {"TAMT1": "T", "LAMT1": "L", "MAMT1": "M", "SAMT1": "S"}
|
||||
BOQ_AMT = {"T_AMT1": "T", "L_AMT1": "L", "M_AMT1": "M", "S_AMT1": "S"}
|
||||
#: 엑셀 숨은 J 열 메타 이름 ← COSTN 칸(`M02_San_Eval.stmate_block` 이 읽음)
|
||||
META = {"UNT": "UNIT", "UC": "UC", "NAG": "NAGCD", "SBM": "SBMOK", "ADJ": "S_ADJ"}
|
||||
UNREAD = ("BDQTY.DBF 난독화 — 일위대가 구성 줄 못 읽음", "SYSINFO.DBF 난독화")
|
||||
|
||||
|
||||
class StcError(ValueError):
|
||||
"""못 읽는 .stc."""
|
||||
|
||||
|
||||
def dbf(b: bytes) -> list[dict[str, Any]]:
|
||||
"""dBASE → 지워지지 않은 행(글 = CP949 strip · IFORM = 바이트 그대로 · `_recno` 파일 차례)."""
|
||||
if len(b) < 32:
|
||||
raise StcError("DBF 헤더 길이 부족")
|
||||
nrec, hlen, rlen = struct.unpack_from("<IHH", b, 4)
|
||||
fields, off = [], 32
|
||||
while off < len(b) and b[off] != 0x0D:
|
||||
name = b[off : off + 11].split(b"\0", 1)[0].decode("cp949", "replace").strip()
|
||||
fields.append((name, b[off + 16]))
|
||||
off += 32
|
||||
rows = []
|
||||
for i in range(nrec):
|
||||
pos = hlen + i * rlen
|
||||
if pos + rlen > len(b):
|
||||
break
|
||||
rec = b[pos : pos + rlen]
|
||||
if rec[:1] == b"*":
|
||||
continue
|
||||
row: dict[str, Any] = {"_recno": i}
|
||||
cur = 1
|
||||
for name, width in fields:
|
||||
raw = rec[cur : cur + width]
|
||||
cur += width
|
||||
row[name] = raw if name == "IFORM" else raw.decode("cp949", "replace").strip()
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def memo(iform: bytes, smt: bytes) -> str | None:
|
||||
"""IFORM(<HII 꼬리표 · 길이 · 블록) → COSTN.SMT 원문 · 없음 = None."""
|
||||
if not smt or smt[8:15] != b"SIxMemo" or len(iform) < 10:
|
||||
return None
|
||||
if not any(c not in (0, 0x20) for c in iform):
|
||||
return None
|
||||
_, length, block = struct.unpack("<HII", iform[:10])
|
||||
start = block * (struct.unpack("<I", smt[4:8])[0] or 64)
|
||||
if length <= 0 or start >= len(smt):
|
||||
return None
|
||||
return smt[start : start + length].rstrip(b"\0").decode("cp949", "replace")
|
||||
|
||||
|
||||
def _f(s: str | None) -> float:
|
||||
try:
|
||||
return float(s or 0)
|
||||
except ValueError:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _amt(row: dict[str, Any], names: dict[str, str]) -> dict[str, float]:
|
||||
return {k: _f(row.get(f)) for f, k in names.items()}
|
||||
|
||||
|
||||
def _zip(data: bytes) -> tuple[zipfile.ZipFile, dict[str, str]]:
|
||||
if data[:4] != b"PK\x03\x04":
|
||||
raise StcError(".stc 가 아님 — ZIP 머리 없음")
|
||||
try:
|
||||
z = zipfile.ZipFile(io.BytesIO(data))
|
||||
except zipfile.BadZipFile as error:
|
||||
raise StcError("깨진 .stc") from error
|
||||
names = {i.filename.upper(): i.filename for i in z.infolist()}
|
||||
if "COSTN.DBF" not in names:
|
||||
raise StcError(".stc 안에 COSTN.DBF 없음")
|
||||
return z, names
|
||||
|
||||
|
||||
def stc_line(at: int, raw: str, res: dict[str, dict], tags: list[str]) -> dict[str, Any]:
|
||||
"""원문 한 줄 → 구조 줄(풀이 = `M02_San_Stmate.parse_line` 나무 그대로)."""
|
||||
node = stm.parse_line(raw)
|
||||
kind = node["종류"]
|
||||
src = node.get("자원")
|
||||
res_line = None
|
||||
if src:
|
||||
code = src["코드"]
|
||||
head = res.get(code)
|
||||
if head is None:
|
||||
tags.append(f"자원 못 찾음 {code}")
|
||||
res_line = {
|
||||
"코드": code,
|
||||
"종류": KIND.get(code[:1]),
|
||||
"비목": src["비목"],
|
||||
"옮김": src["옮김"],
|
||||
**{k: (head or {}).get(k) for k in ("이름", "규격", "단위", "저장")},
|
||||
}
|
||||
rate = node.get("율")
|
||||
names = [p["이름"] for p in node.get("대입", []) if p["이름"]]
|
||||
code = res_line["코드"] if res_line else ""
|
||||
return {
|
||||
"행": at,
|
||||
"글": raw,
|
||||
"보임": raw.strip(),
|
||||
"품명": None,
|
||||
"규격": None,
|
||||
"단위": None,
|
||||
"단": node.get("단"),
|
||||
"연결": kind,
|
||||
"연결뜻": kind,
|
||||
"비목": (src or rate or {}).get("비목"),
|
||||
"자원": res_line,
|
||||
"연결단가": [],
|
||||
"변수": names,
|
||||
"줄참조": (rate or {}).get("번지", []) + ([node["번지"]] if node.get("번지") else []),
|
||||
"결과": [],
|
||||
"합계": kind == "계",
|
||||
"자리": {"자리수": None, "방법": None},
|
||||
"표지": "율" if kind == "율" else "합계" if kind == "계" else None,
|
||||
"기계": code if KIND.get(code[:1]) == "기계" else None,
|
||||
"산근": code if code[:1] == "D" else None,
|
||||
"저장": None,
|
||||
"풀이": node,
|
||||
}
|
||||
|
||||
|
||||
def read(data: bytes) -> dict[str, Any]:
|
||||
""".stc → {꼴, 산근, 일위, 자원, 내역, 단가(셈용), 표지}."""
|
||||
z, names = _zip(data)
|
||||
costn = dbf(z.read(names["COSTN.DBF"]))
|
||||
smt = z.read(names["COSTN.SMT"]) if "COSTN.SMT" in names else b""
|
||||
res, prices = {}, {}
|
||||
for r in costn:
|
||||
code = r.get("CODE") or ""
|
||||
if not code:
|
||||
continue
|
||||
saved = _amt(r, AMT)
|
||||
res[code] = {
|
||||
"종류": KIND.get(code[:1]),
|
||||
"이름": r.get("NAME"),
|
||||
"규격": r.get("SIZE"),
|
||||
"단위": r.get("UNIT"),
|
||||
"저장": saved,
|
||||
}
|
||||
prices[code] = {**saved, "meta": {k: r.get(f) for k, f in META.items()}}
|
||||
out: dict[str, Any] = {"꼴": "STmate", "산근": [], "일위": [], "자원": res, "단가": prices}
|
||||
for r in costn:
|
||||
code, text = r.get("CODE") or "", memo(r.get("IFORM") or b"", smt)
|
||||
if not code or (text is None and code[:1] != "B"):
|
||||
continue
|
||||
tags: list[str] = []
|
||||
head = {k: res[code][k] for k in ("이름", "규격", "단위", "저장")}
|
||||
if text is None:
|
||||
out["일위"].append({"코드": code, **head, "줄": [], "줄합": None, "표지": [UNREAD[0]]})
|
||||
continue
|
||||
lines = [stc_line(i, ln, res, tags) for i, ln in enumerate(text.splitlines(), 1)]
|
||||
_, tot = ev.run_stmate([ln["풀이"] for ln in lines], prices)
|
||||
blk = {"코드": code, **head, "줄": lines, "줄합": tot, "표지": tags}
|
||||
out["일위" if code[:1] == "B" else "산근"].append(blk)
|
||||
out["내역"] = None
|
||||
if "BOQ11.DBF" in names:
|
||||
out["내역"] = [
|
||||
{
|
||||
"번": r.get("ITNUM"),
|
||||
"이름": r.get("NAME1"),
|
||||
"규격": r.get("SIZE1"),
|
||||
"단위": r.get("UNIT1"),
|
||||
"수량": _f(r.get("QTY1")),
|
||||
"코드": r.get("BDCODE1"),
|
||||
"저장": _amt(r, BOQ_AMT),
|
||||
}
|
||||
for r in dbf(z.read(names["BOQ11.DBF"]))
|
||||
]
|
||||
ini = z.read(names["START8X.INI"]).decode("cp949", "replace") if "START8X.INI" in names else ""
|
||||
out["옵션"] = dict(re.findall(r"^\s*(\w+)\s*=[ \t]*(.*?)\s*$", ini, re.M | re.A))
|
||||
|
||||
odd = sorted({c[:1] for c, v in res.items() if v["종류"] is None})
|
||||
out["표지"] = [
|
||||
*UNREAD,
|
||||
"금액 = 차수 1 칸(TAMT1 · LAMT1 · MAMT1 · SAMT1)",
|
||||
"블록 끝전 갈래 = 옵션 SAN_TYP 후보(뜻 미확정)",
|
||||
*(f"모르는 코드 머리 {c}" for c in odd),
|
||||
]
|
||||
return out
|
||||
Reference in New Issue
Block a user