From e794e0490ccd0a6be49690c7f5970e0d910e2bf8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 8 Nov 2026 19:22:25 +0900 Subject: [PATCH] =?UTF-8?q?feat(810-1):=20=EA=B2=BD=EC=9F=81=EC=82=AC=20?= =?UTF-8?q?=EC=9B=90=ED=8C=8C=EC=9D=BC=20=ED=8C=8C=EC=84=9C=20=E2=80=94=20?= =?UTF-8?q?EST(.estx)=20=C2=B7=20STmate(.stc)=20=EA=B5=AC=EC=A1=B0=20?= =?UTF-8?q?=EC=A4=84=20=C2=B7=20node=20=C2=B7=20smt=5Fparser=20=EA=B1=B0?= =?UTF-8?q?=EC=9A=B8=20=EB=8C=80=EC=A1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- M02_MasterTemplete/M02_San_Estx.py | 416 ++++++++++++++++++ M02_MasterTemplete/M02_San_Stc.py | 206 +++++++++ .../raw/요구_1104/_목록_810-1_원파일대조.xlsx | Bin 0 -> 23733 bytes resources/master_data/_원파일파서_틀.md | 79 ++++ resources/tester/test_810_1_estx.py | 245 +++++++++++ resources/tester/test_810_1_stc.py | 187 ++++++++ 6 files changed, 1133 insertions(+) create mode 100644 M02_MasterTemplete/M02_San_Estx.py create mode 100644 M02_MasterTemplete/M02_San_Stc.py create mode 100644 docs/raw/요구_1104/_목록_810-1_원파일대조.xlsx create mode 100644 resources/master_data/_원파일파서_틀.md create mode 100644 resources/tester/test_810_1_estx.py create mode 100644 resources/tester/test_810_1_stc.py diff --git a/M02_MasterTemplete/M02_San_Estx.py b/M02_MasterTemplete/M02_San_Estx.py new file mode 100644 index 000000000..55ebd5f0f --- /dev/null +++ b/M02_MasterTemplete/M02_San_Estx.py @@ -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(" 494: + raise EstxError("BLOB 조각 길이 오류") + if block == start: + if struct.unpack_from(" 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(" list[dict[str, Any]]: + """`.dat` → 행 목록(값 없음 = None · BLOB = bytes · 모르는 형 = {binary, kind}).""" + if len(buf) < 512: + raise EstxError("테이블 헤더가 짧음") + stride, count = struct.unpack_from(" 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(" 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(" 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 diff --git a/M02_MasterTemplete/M02_San_Stc.py b/M02_MasterTemplete/M02_San_Stc.py new file mode 100644 index 000000000..14fff9c6e --- /dev/null +++ b/M02_MasterTemplete/M02_San_Stc.py @@ -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 = +) · 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 diff --git a/docs/raw/요구_1104/_목록_810-1_원파일대조.xlsx b/docs/raw/요구_1104/_목록_810-1_원파일대조.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..d3fe6d9daca410acc1afc6f5a57b17a2c35080eb GIT binary patch literal 23733 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 literal 0 HcmV?d00001 diff --git a/resources/master_data/_원파일파서_틀.md b/resources/master_data/_원파일파서_틀.md new file mode 100644 index 000000000..14872cc5c --- /dev/null +++ b/resources/master_data/_원파일파서_틀.md @@ -0,0 +1,79 @@ +# 경쟁사 원파일 파서 틀 (810-1) + +읽기만 · 서버 단독 · 칸에 있는 것만 옮김(추정 0) · 없으면 비움 + 표지. + +| 프로그램 | 파일 | 파서 | 거울(시험) | +| --- | --- | --- | --- | +| EST Plus | `.estx` = ZIP · DBISAM `.dat` + `.blb` | `M02_MasterTemplete/M02_San_Estx.py` | node `tools/est-test/import-estx.cjs` · `test_810_1_estx.py` | +| STmate | `.stc` = ZIP · COSTN.DBF · COSTN.SMT · BOQ11.DBF | `M02_MasterTemplete/M02_San_Stc.py` | `tools/stmate-test/smt_parser.py` · `test_810_1_stc.py` | + +## 1. 돌려주는 꼴 — `read(bytes)` + +| 칸 | 뜻 | +| --- | --- | +| 꼴 | `EST` · `STmate` | +| 산근 | 단가산출 블록 목록 | +| 일위 | 일위대가 블록 목록(STmate = 머리만 · 구성 못 읽음) | +| 자원 | 코드 → {종류 · 이름 · 규격 · 단위 · 저장 금액} (EST 열쇠 = `종류:코드`) | +| 내역 | STmate BOQ11 내역 줄 {번 · 이름 · 규격 · 단위 · 수량 · 코드 · 저장} · EST = None | +| 단가 · 옵션 | STmate 만 — 셈용 단가 {T L M S meta} · Start8x.Ini 키 = 값 그대로 | +| 표지 | 못 읽는 것 · 미확정 | + +블록 = {코드 · 이름 · 규격 · 단위 · 저장 {T L M S (R)} · 줄 [구조 줄] · 줄합 · 표지}. + +- 저장 = 원파일 제목 표 금액(EST CostTitleDat · HeadTitleDat TC NC GC JC SC · STmate COSTN TAMT1 LAMT1 MAMT1 SAMT1). +- 줄합 = 소수 그대로의 비목 합 — EST = 저장 결과 줄만(셈 없음) · STmate = `M02_San_Eval.run_stmate`. +- 비목 글자 = T 계 · L 노무 · M 재료 · S 경비 · R 실적(EST SC). + +## 2. 구조 줄 + +| 칸 | EST 산근(CostMainDat) | EST 일위(HeadMainDat) | STmate 산근(COSTN.SMT 한 줄) | +| --- | --- | --- | --- | +| 행 | 블록 안 차례(머리 뺌) | 같음(빈 줄 뺌) | 원문 줄 차례 | +| 글 · 보임 | LineData · 제어문자 뺀 글 | — | 원문 · strip | +| 품명 · 규격 · 단위 | `\x0e` · `\x13` · `\x15` 첫 조각 | Name · Size · Unite | 비움 | +| 수량 | — | GN | — | +| 단 | Level | — | 계 단 1~5 | +| 연결 · 연결뜻 | LinkIndex · `LINK` 뜻 | 같음 | 줄 종류(빈 · 글 · 계 · 대입 · 참조 · 율) | +| 비목 | SCheck 음수 -1 L · -2 M · -3 S · -4 R · -5 T / 율 · 소계 양수 1 M · 2 L · 3 S · 4 R | 율 줄 SCheck | 참조 · 율 꼬리 L · M · S | +| 자원 | {코드 · 종류 · 표 · 이름 · 규격 · 단위 · 저장} | 같음 | {코드 · 종류 · 비목 · 옮김 · 이름 · 규격 · 단위 · 저장} | +| 연결단가 | `\x03` 수 | — | 비움 | +| 변수 | `\x11` 수(변수 참조 값) | — | 대입 이름 | +| 줄참조 | `\x0c` 수 | — | 율 번지 · 줄 번지 | +| 결과 | `\x12` 수 | — | 비움(원문에 결과 없음) | +| 합계 | `\x1a` 있음 | — | 계 줄 | +| 자리 | Diget · DigetIndex | 같음 | 비움 | +| 표지 | 율 · 소계 · 합계 | 율 · 소계 | 율 · 합계 | +| 기계 | 기계 세 줄 묶음 = 잇단 같은 Code + 105 + 비목 줄 · 첫 줄 행 | — | 기계 코드(X · E) | +| 산근 | LinkIndex 104 코드 | 같음 | D 코드 | +| 산출 · 단가 | — | CalcIndex · TU NU GU JU SU | — | +| 저장 | 줄 TC NC GC JC SC | 같음 | 비움 | +| 풀이 | — | — | `M02_San_Stmate.parse_line` 나무 그대로 | + +## 3. 코드 · 연결 대응 + +| 종류 | EST LinkIndex · 표 | STmate 코드 머리 | +| --- | --- | --- | +| 자재 | 5 StuffcDat | M | +| 노임 | 6 LaborcDat | L | +| 표준(실적) | 21 SilJukDat | — | +| 견적 | 101 EstiMateDat | — | +| 일위 | 103 HeadTitleDat | B | +| 산근 | 104 CostTitleDat | D | +| 기계 | 105 MachTitleDat | X · E | +| 경비 | — | S | +| 소계 | 51 · 61 · 68 | `(=)` 계 | +| 율 | 52 · 58 · 62 | `&P..&` · `&R..&` | + +## 4. 블록 합 규칙(시험이 저장값 100 % 로 지킴) + +- EST 산근 — 합계 줄(`\x1a` · 율 62 밖)이 있으면 가장 바깥 단 비목 줄 결과 · 없으면 비목 줄 결과 합. +- EST 일위 — 줄 저장 금액 합(CalcIndex < 2) · 소계 68 · 율 52 줄에서 누계를 그 줄로 다시 시작. +- EST 끝전 — 버림 · 반올림 둘 중 하나(DigetIndex · 옵션 미해석). +- STmate — `M02_San_Eval.stmate_block` · 차수 1 단가 · 낙찰(UC · NAGCD) · 결과 조정(S_ADJ · SBMOK) · 끝전 갈래 = 옵션 SAN_TYP 1 비목버림 / 0 합버림_큰나머지(원파일 일곱 저장값 대조로 확인 · 이름 뜻 미확정). + +## 5. 못 읽는 것 + +- EST — 옵션 파일(datestoption.opt) · 원가 서식(wongadat) · 기계 구성(MachMainDat) 은 구조 줄로 안 옮김. +- STmate — BDQTY.DBF(일위대가 구성) · SYSINFO.DBF 난독화 · 차수 2~4 금액 칸 안 옮김 · 코드 머리 W 뜻 모름. +- 원파일 없는 것(북부청 2 · 2016 사방 · 수목) = 엑셀 길. diff --git a/resources/tester/test_810_1_estx.py b/resources/tester/test_810_1_estx.py new file mode 100644 index 000000000..a689ea281 --- /dev/null +++ b/resources/tester/test_810_1_estx.py @@ -0,0 +1,245 @@ +"""810-1 지킴 — EST 원파일 파서(`M02_San_Estx`) · 원파일 여섯(실무 셋 + 경쟁사 셋 · 읽기만). + +(a) 거울 — node `tools/est-test/import-estx.cjs` 결과와 표 이름 · 행 수 · 모든 칸 값 1:1(다름 0) +(b) 블록 줄합(저장 결과만 · 셈 없음) = 제목 표 저장 금액(TC NC GC JC SC) 100 % +(c) 거창 = 지금 엑셀 엔진(`M02_San_Book` · `M02_San_Est` · `M02_San_Eval`)과 대조 — 블록 수 · 금액 · + 줄마다 갈래(단가 · 참조 수) · 다른 곳 = 엑셀 추정이 틀린 곳(고치지 않음 · 수만 지킴) +원파일 · node 없으면 skip(다른 PC). +""" + +from __future__ import annotations + +import base64 +import json +import math +import re +import shutil +import subprocess +import sys +from functools import lru_cache +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from M02_MasterTemplete import M02_San_Estx as E # noqa: E402 + +K = ROOT / "resources" / "knowledge" / "original" +P = K / "경쟁사 프로그램" / "EST Plus 2" +FILES = { + "거창": K / "실무문서/01.산불거창(거창북상병곡)/01.내역서/00.25년_산불_거창(거창,북상,병곡,산8외) _단가변경.estx", # noqa: E501 + "장수": K / "실무문서/02.산불 장수/01.내역서/00.단가변경_26년_산불_무주(장수,장수,덕산,산54-1).estx", # noqa: E501 + "진안": K / "실무문서/03.산불진안/01.내역서/00.단가변경_26년_산불_무주(진안,백운,백암,산1-2).estx", # noqa: E501 + "임도용역": P / "example/00.내역_2024년 임도시설 실시설계용역(12.18).estx", # noqa: E501 + "통영": P / "example/01.내역서_통영섬숲복원사업(사량도) 실시설계(0830).estx", # noqa: E501 + "main": P / "다산소프트/2026-07-main.estx", # noqa: E501 +} # fmt: skip +#: 파일 → (산근 블록, 산근 줄, 일위 블록) +WANT = { + "거창": (61, 2469 - 61, 14), + "장수": (65, None, 3), + "진안": (74, None, 14), + "임도용역": (73, None, 3), + "통영": (7, None, 27), + "main": (1569, None, 1468), +} +NODE = r""" +const fs = require("fs"); +const { importEstx } = require(process.argv[1] + "/tools/est-test/import-estx.cjs"); +const r = importEstx(fs.readFileSync(process.argv[2])); +process.stdout.write(JSON.stringify(r.tables)); +""" + + +@lru_cache(maxsize=2) +def data(tag: str) -> bytes: + path = FILES[tag] + if not path.exists(): + pytest.skip(f"원파일 없음 {path.name}") + return path.read_bytes() + + +@lru_cache(maxsize=2) +def parsed(tag: str) -> dict: + return E.read(data(tag)) + + +def _norm(v): + if isinstance(v, bytes): + return {"binary": base64.b64encode(v).decode()} + if isinstance(v, dict) and isinstance(v.get("binary"), bytes): + return {"binary": base64.b64encode(v["binary"]).decode(), "kind": v["kind"]} + return v + + +@pytest.mark.parametrize("tag", list(FILES)) +def test_mirror_node(tag: str) -> None: + if not shutil.which("node"): + pytest.skip("node 없음") + raw = data(tag) + py = E.tables(raw) + out = subprocess.run( + ["node", "-e", NODE, str(ROOT), str(FILES[tag])], capture_output=True, check=True + ).stdout + nd = json.loads(out) + nd.pop("Wonga", None) # node 덧붙임(원가 서식 풀이) — 원 표 아님 + assert sorted(py) == sorted(nd) + diff, c1 = [], 0 + for name, rows in nd.items(): + assert len(py[name]) == len(rows), name + for i, (a, b) in enumerate(zip(py[name], rows, strict=True)): + raw_a = a.get("_originalTextBytes") or {} + raw_b = b.pop("_originalTextBytes", None) or {} + for k, v in raw_b.items(): # node 가 못 푼 글 = 원 바이트 대조(파이썬 cp949 는 풂) + mine = raw_a.get(k) or a[k].encode("cp949") + assert base64.b64encode(mine).decode() == v, (name, i, k) + a = {k: _norm(v) for k, v in a.items() if k != "_originalTextBytes" and k not in raw_b} + b = {k: v for k, v in b.items() if k not in raw_b} + if a != b: + keys = [k for k in b if a.get(k) != b[k]] + if all(_c1_same(a.get(k), b[k]) for k in keys): + c1 += 1 + continue + diff.append((name, i, keys)) + assert diff == [] + assert c1 == C1.get(tag, 0) + + +#: node TextDecoder(euc-kr) 가 CP949 확장 한글(「뀡」 등)을 C1 제어문자로 푼 행 수 — 원 바이트 같음 +C1 = {"main": 5} + + +def _c1_same(mine, theirs) -> bool: + """node 글의 C1 제어문자(0x80~0x9F) = 원 바이트 · 나머지 = CP949 → 파이썬 글 바이트와 같음.""" + if not (isinstance(mine, str) and isinstance(theirs, str)): + return False + raw = b"".join(c.encode("latin1") if "€" <= c <= "Ÿ" else c.encode("cp949") for c in theirs) + return raw == mine.encode("cp949") + + +def _same(got: float, want: float | None) -> bool: + w = want or 0 + return abs(math.floor(round(got, 6)) - w) < 0.5 or abs(round(got) - w) < 0.5 + + +@pytest.mark.parametrize("tag", list(FILES)) +def test_saved_amounts(tag: str) -> None: + d = parsed(tag) + n_cost, n_lines, n_head = WANT[tag] + assert (len(d["산근"]), len(d["일위"])) == (n_cost, n_head) + if n_lines is not None: + assert sum(len(b["줄"]) for b in d["산근"]) == n_lines + bad = [ + (key, b["코드"]) + for key in ("산근", "일위") + for b in d[key] + if not all(_same(b["줄합"][c], (b["저장"] or {}).get(c)) for c in "LMSR") + ] + assert bad == [] + assert all(not b["표지"] for key in ("산근", "일위") for b in d[key]) + + +def test_structure_marks() -> None: + """거창 첫 산근 — 기계 세 줄 · 연결 단가 · 변수 참조 · 합계 · 비목.""" + b = parsed("거창")["산근"][0] + assert (b["코드"], b["이름"], b["저장"]["T"]) == ("D0000000003", "측구터파기", 5323) + m = [ln for ln in b["줄"] if ln["기계"]] + assert [ln["비목"] for ln in m[:4]] == ["L", "M", "S", "T"] + assert len({ln["기계"] for ln in m[:4]}) == 1 + assert m[0]["자원"]["코드"] == "E0000000000" and m[0]["자원"]["종류"] == "기계" + assert (m[0]["연결단가"], m[0]["변수"], m[0]["결과"]) == ([59020], [23.39], [2523.3]) + labor = next(ln for ln in b["줄"] if ln["연결"] == 6) + assert (labor["자원"]["이름"], labor["품명"]) == ("보통인부", "보통인부") + assert [ln["비목"] for ln in b["줄"] if ln["합계"]] == ["L", "M", "S", "T"] + sub = [ln for d in [parsed("거창")] for b in d["산근"] for ln in b["줄"] if ln["산근"]] + assert sub and all(ln["자원"]["종류"] == "산근" for ln in sub) + + +# ── (c) 엑셀 엔진 대조 ───────────────────────────────────────────────── +def _key(s: str) -> str: + num = r"\d[\d,]*\.?\d*|\.\d+" + s = re.sub(num, lambda m: repr(float(m.group().replace(",", "").rstrip(".") or 0)), s) + return re.sub(r"\s", "", s) + + +def excel_diffs(tag: str = "거창") -> tuple[list[dict], dict]: + """엑셀 엔진 블록 · 줄 갈래 대 원파일 → (다른 곳 목록, 셈).""" + sys.path.insert(0, str(ROOT / "resources" / "tester")) + import helper_m02_hopyo_golden as golden + + from M02_MasterTemplete import M02_San_Book as book + from M02_MasterTemplete import M02_San_Est as est + from M02_MasterTemplete import M02_San_Eval as ev + + if not golden.FILES[tag].exists(): + pytest.skip("엑셀 없음") + xl = book.load(golden.FILES[tag]) + by_no = {b["번"]: b for b in xl["블록"]} + out, cnt = [], {"블록": 0, "금액같음": 0, "글같음": 0, "줄": 0, "갈래같음": 0} + for no, b in enumerate(parsed(tag)["산근"], 1): + x = by_no.get(no) + if x is None: + out.append({"블록": no, "코드": b["코드"], "항목": "엑셀에 블록 없음"}) + continue + cnt["블록"] += 1 + want = xl["총괄"].get(no) + lines = est.parse_block(x["글"], xl["단가"]) + chk = ev.est_check(lines, want) if want else {"금액": None} + saved = {c: b["저장"].get(k) or 0 for c, k in (("L", "L"), ("M", "M"), ("S", "S"))} + if chk["금액"] and all(abs(chk["금액"][c] - saved[c]) < 0.5 for c in "LMS"): + cnt["금액같음"] += 1 + else: + out.append( + { + "블록": no, + "코드": b["코드"], + "항목": "금액", + "엑셀": chk["금액"], + "원파일": saved, + } + ) + mine = [ln for ln in b["줄"] if ln["보임"].strip()] + if len(mine) == len(x["글"]) and all( + _key(a["보임"]) == _key(c) for a, c in zip(mine, x["글"]) + ): + cnt["글같음"] += 1 + for L, ln in zip(lines, mine): + if not (ln["연결단가"] or ln["변수"] or ln["줄참조"] or L.get("꼴") == "돈"): + continue + cnt["줄"] += 1 + roles = [r["갈래"] for r in (L.get("역할") or {}).values()] + hidden = len((L.get("숨은") or {}).get("줄") or []) # 값 줄 = 숨은 복사 · 합 + got = (roles.count("단가"), roles.count("앞줄") + hidden) + truth = (len(ln["연결단가"]), len(ln["변수"]) + len(ln["줄참조"])) + money = L.get("꼴") == "돈" or L.get("종류") == "값줄" + auto = ln["합계"] or ln["표지"] == "소계" # 자동 합 · 소계 = 엑셀 숨은 합과 같은 뜻 + ok = money and got[0] == truth[0] and (auto or got[1] == truth[1]) + cnt["갈래같음"] += ok + if not ok: + out.append( + { + "블록": no, + "코드": b["코드"], + "행": ln["행"], + "항목": "갈래", + "글": ln["보임"].strip(), + "엑셀": f"{L.get('꼴')} 단가 {got[0]} · 앞줄 {got[1]}", + "원파일": f"단가 {truth[0]} · 변수 {len(ln['변수'])} · " + f"줄참조 {len(ln['줄참조'])}", + } + ) + return out, cnt + + +def test_excel_geochang() -> None: + out, cnt = excel_diffs("거창") + assert (cnt["블록"], cnt["금액같음"], cnt["글같음"]) == (61, 61, 61) + assert [o for o in out if o["항목"] != "갈래"] == [] + assert (cnt["줄"], cnt["갈래같음"]) == EXCEL_LINES + + +#: 거창 (원파일 · 엑셀 돈 줄 수, 엑셀 갈래 = 원파일) — 다른 곳 = `_목록_810-1_원파일대조.xlsx` +EXCEL_LINES = (547, 282) diff --git a/resources/tester/test_810_1_stc.py b/resources/tester/test_810_1_stc.py new file mode 100644 index 000000000..41d8ad94e --- /dev/null +++ b/resources/tester/test_810_1_stc.py @@ -0,0 +1,187 @@ +"""810-1 지킴 — STmate 원파일 파서(`M02_San_Stc`) · 실무 .STC 일곱(읽기만). + +(a) 거울 — `tools/stmate-test/smt_parser.py`(parse_dbf_bytes · extract_costn_memos)와 + COSTN · BOQ11 행 · + 칸 · 산근 원문 1:1(다름 0) · 줄 풀이 = `M02_San_Stmate.parse_line` +(b) 블록 다시 셈(`M02_San_Eval.stmate_block` · 차수 1 단가 · 낙찰 · 결과 조정 · 끝전 갈래 = 옵션 + SAN_TYP 1 비목버림 / 0 합버림_큰나머지) = COSTN 저장 금액(TAMT1 · LAMT1 · MAMT1 · SAMT1) 100 % +(c) 엑셀 짝 다섯(`helper_m02_hopyo_golden`) = 지금 엑셀 엔진 블록 금액 · 숨은 G 열 원문 · + 목록표 단가와 + 대조 — 다른 곳 = 엑셀 추정이 틀린 곳(수만 지킴) · 엑셀에 안 찍힌 블록 수 기록 +원파일 없으면 skip(다른 PC). +""" + +from __future__ import annotations + +import sys +from functools import lru_cache +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +for p in (ROOT, ROOT / "tools" / "stmate-test", ROOT / "resources" / "tester"): + if str(p) not in sys.path: + sys.path.insert(0, str(p)) + +from M02_MasterTemplete import M02_San_Eval as ev # noqa: E402 +from M02_MasterTemplete import M02_San_Stc as S # noqa: E402 +from M02_MasterTemplete import M02_San_Stmate as stm # noqa: E402 + +K = ROOT / "resources" / "knowledge" / "original" / "실무문서" +SO = K / "25년 산불진화임도(기번8)(울진소광)" +FILES = { + "봉화": K / "04.산불 봉화/01. 내역서/13-2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km.STC", # noqa: E501 + "동부청영월": K / "05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.STC", # noqa: E501 + "울진대흥": K / "2024년 간선임도(기번3-울진.대흥)(공통)/1. 설계원본/01 내역자료/2024년 간선임도사업(기번3).STC", # noqa: E501 + "영덕계류": K / "20250527 2025년 계류보전사업(기번1-영덕.병곡.영리.산214-1)(변경)/01.내역자료/2025년 계류보전사업(기번1 영덕.병곡.영.산214)(변경).STC", # noqa: E501 + "울진소광": SO / "2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외).STC", # noqa: E501 + "소광22": SO / "1. 설계원본/03-내역/22-2025년 산불진화임도사업(울진 금강송 소광).STC", # noqa: E501 + "소광변경1": SO / "변경 1차분-2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외).STC", # noqa: E501 +} # fmt: skip +#: 파일 → (산근 블록, 일위 블록 · 구성 못 읽음, SAN_TYP) +WANT = { + "봉화": (65, 45, "1"), + "동부청영월": (69, 23, "0"), + "울진대흥": (46, 34, "1"), + "영덕계류": (35, 13, "1"), + "울진소광": (62, 37, "1"), + "소광22": (40, 25, "1"), + "소광변경1": (54, 39, "1"), +} +RULE = {"1": "비목버림", "0": "합버림_큰나머지"} + + +@lru_cache(maxsize=2) +def raw(tag: str) -> bytes: + path = FILES[tag] + if not path.exists(): + pytest.skip(f"원파일 없음 {path.name}") + return path.read_bytes() + + +@lru_cache(maxsize=2) +def parsed(tag: str) -> dict: + return S.read(raw(tag)) + + +def _members(tag: str) -> dict[str, bytes]: + z, names = S._zip(raw(tag)) + return {k: z.read(v) for k, v in names.items()} + + +@pytest.mark.parametrize("tag", list(FILES)) +def test_mirror_smt_parser(tag: str) -> None: + import smt_parser as sp + + m = _members(tag) + for name in ("COSTN.DBF", "BOQ11.DBF"): + _, theirs, _ = sp.parse_dbf_bytes(m[name]) + mine = S.dbf(m[name]) + assert len(mine) == len(theirs), name + for a, b in zip(mine, theirs, strict=True): + b = {k: v for k, v in b.items() if k != "_deleted"} + if "IFORM" in a: + assert a.pop("IFORM").hex() == b.pop("IFORM_HEX") + b.pop("IFORM") + assert a == b + memos = sp.extract_costn_memos(m["COSTN.DBF"], m["COSTN.SMT"]) + d = parsed(tag) + blocks = {b["코드"]: b for b in d["산근"]} + assert sorted(blocks) == sorted(memos) + for code, mm in memos.items(): + assert [ln["글"] for ln in blocks[code]["줄"]] == mm["text"].splitlines() + assert all(ln["풀이"] == stm.parse_line(ln["글"]) for ln in blocks[code]["줄"]) + + +@pytest.mark.parametrize("tag", list(FILES)) +def test_saved_amounts(tag: str) -> None: + d = parsed(tag) + n_cost, n_head, san = WANT[tag] + assert (len(d["산근"]), len(d["일위"]), d["옵션"]["SAN_TYP"]) == (n_cost, n_head, san) + assert all(b["표지"] == [S.UNREAD[0]] and not b["줄"] for b in d["일위"]) + bad = [] + for b in d["산근"]: + got = ev.stmate_block(b, [ln["풀이"] for ln in b["줄"]], d["단가"], RULE[san])["금액"] + if any(abs(got[c] - b["저장"][c]) >= 0.5 for c in "TLMS"): + bad.append((b["코드"], got, b["저장"])) + assert bad == [] + assert all(not b["표지"] for b in d["산근"]) + + +def test_structure_line() -> None: + """봉화 D00014 — 참조 줄 자원 · 비목 · 계 · 산근 블록 머리.""" + b = next(b for b in parsed("봉화")["산근"] if b["코드"] == "D00014") + assert (b["이름"], b["단위"], b["저장"]["T"]) == ("수평규준틀(변곡점)", "개소", 26144) + refs = [ln for ln in b["줄"] if ln["자원"]] + assert [ln["자원"]["코드"] for ln in refs] == ["M00080", "M00002", "M00001", "L00001", "L00007"] + assert refs[0]["자원"]["종류"] == "자재" and refs[3]["자원"]["종류"] == "노임" + assert [ln["단"] for ln in b["줄"] if ln["합계"]] == [1, 1, 2] + + +# ── (c) 엑셀 엔진 대조 ───────────────────────────────────────────────── +PAIRED = ("봉화", "동부청영월", "울진대흥", "영덕계류", "울진소광") + + +def excel_diffs(tag: str) -> tuple[list[dict], dict]: + """엑셀 엔진(숨은 G 열 · 목록표 단가) 대 원파일 → (다른 곳, 셈).""" + import helper_m02_hopyo_golden as golden + + from M02_MasterTemplete import M02_San_Book as book + + if not golden.FILES[tag].exists(): + pytest.skip("엑셀 없음") + xl = book.load(golden.FILES[tag]) + d = parsed(tag) + rule = RULE[d["옵션"]["SAN_TYP"]] + mine = {b["코드"]: b for b in d["산근"]} + out = [] + cnt = {"엑셀블록": len(xl["블록"]), "원파일블록": len(mine), "같이": 0, "금액같음": 0} + cnt |= {"글같음": 0, "단가": 0, "단가같음": 0} + for x in xl["블록"]: + b = mine.get(x["코드"]) + if b is None: + out.append({"코드": x["코드"], "항목": "원파일에 블록 없음"}) + continue + cnt["같이"] += 1 + res = ev.stmate_block(x, stm.parse_block(x["몸"]), xl["단가"], rule)["금액"] + if all(abs(res[c] - b["저장"][c]) < 0.5 for c in "TLMS"): + cnt["금액같음"] += 1 + else: + out.append({"코드": x["코드"], "항목": "금액", "엑셀": res, "원파일": b["저장"]}) + g = [ln["G"].strip() for ln in x["몸"] if ln["G"].strip()] + if g == [ln["글"].strip() for ln in b["줄"] if ln["글"].strip()]: + cnt["글같음"] += 1 + else: + out.append({"코드": x["코드"], "항목": "원문 글"}) + for code, p in xl["단가"].items(): + q = d["단가"].get(code) + cnt["단가"] += 1 + if q and all(abs((p[c] or 0) - q[c]) < 0.5 for c in "TLMS"): + cnt["단가같음"] += 1 + else: + out.append({"코드": code, "항목": "단가", "엑셀": p, "원파일": q}) + for code in sorted(set(mine) - {x["코드"] for x in xl["블록"]}): + out.append({"코드": code, "항목": "엑셀에 안 찍힌 블록"}) + return out, cnt + + +#: 엑셀 짝 → (엑셀 블록, 같이 있는 블록, 엑셀 단가 줄, 그중 같음, 엑셀에 안 찍힌 원파일 블록) +#: 울진대흥 = 엑셀(신설사업 판)과 원파일(설계원본 판)이 다른 판 — 코드 겹침 14 · 단가 40 빠짐 +EXCEL = { + "봉화": (65, 65, 203, 203, 0), + "동부청영월": (69, 69, 201, 201, 0), + "울진대흥": (47, 14, 153, 113, 32), + "영덕계류": (15, 15, 54, 54, 20), + "울진소광": (40, 40, 138, 138, 22), +} + + +@pytest.mark.parametrize("tag", PAIRED) +def test_excel_engine(tag: str) -> None: + out, cnt = excel_diffs(tag) + n_xl, both, n_price, same_price, extra = EXCEL[tag] + assert (cnt["엑셀블록"], cnt["같이"], cnt["단가"]) == (n_xl, both, n_price) + assert cnt["금액같음"] == cnt["같이"] and cnt["글같음"] == cnt["같이"] + assert cnt["단가같음"] == same_price + assert sum(o["항목"] == "엑셀에 안 찍힌 블록" for o in out) == extra