- 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>
188 lines
8.6 KiB
Python
188 lines
8.6 KiB
Python
"""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
|