"""경쟁사 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 = ) · 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(" 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(= 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