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:
2026-11-08 19:22:25 +09:00
co-authored by Claude Opus 5.5
parent e330025459
commit e794e0490c
6 changed files with 1133 additions and 0 deletions
+416
View File
@@ -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
+206
View File
@@ -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
@@ -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 사방 · 수목) = 엑셀 길.
+245
View File
@@ -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)
+187
View File
@@ -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