Files
Aislo/resources/tester/test_828_1_stc_composition.py
T
eomsangdonandClaude Opus 5.5 9212219960 fix(828-1): 원파일 일위 별도계상 = 경비 · 일식 「별산」 줄도 · 판 재료에서 율 자원(단위 %) 뺌 · HP 율 줄 밑 · 대상
- 828-2 엑셀 읽기기와 5 건 136 호표 706 줄 다름 0(코드 · 갈래 · 수량 · 율 · 밑줄 · 관급 · 별도계상) — 시험 test_828_1 에 대조 더함
- 영월 S11039 검증수수료 · 소광 W 깬돌(구입별산) = 별도계상 · 봉화 「잡재료비 재료비의 %」 = 판 재료 아님

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 21:06:22 +09:00

317 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
"""828-1 지킴 — STmate 원파일 구성표(BDQTY) 읽기 · 실무 .STC 일곱 · 엑셀 짝 다섯(읽기만 · 임시 storage).
① 거울 — `M02_San_Bdqty.decode` = `tools/stmate-test/bdqty.py` 믿을 칸 1:1(7 건)
② 스스로 검사 ① ~ ③ 실패 흉내(바이트 흩뜨림) → 일위 줄 없음 + 물러남 표지(오류 아님)
③ 일위 구성 줄 = 엑셀 일위대가표(코드 차례 · 수량 · 136 호표 706 줄) · 다시 셈 = 저장 단가(B 216 + X 176)
④ 중기 구성 = 엑셀 「중기사용료」(`M02_San_MyMachine._stm_sheet`) 118/118 · 내 기계 씨앗 꼴
⑤ 낙찰률 줄(NAGCD > 0) — 일위 머리 · 구성 셈 = 2 칸 · 산근 · 자원 = 1 칸(엑셀 짝) · 원설계 그대로
⑥ 일위대가 길 — 올림 → 호표 목록 · 「일위대가표」 시트 · 하나 만들기 금액 = 원본 · 재료 판에 구성 줄
"""
from __future__ import annotations
import io
import re
import struct
import sys
import warnings
import zipfile
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))
import helper_m02_hopyo_golden as golden # noqa: E402
from M02_MasterTemplete import M02_Doc_FromNative as fn # noqa: E402
from M02_MasterTemplete import M02_Hopyo_Build as hb # noqa: E402
from M02_MasterTemplete import M02_MasterTemplete_Store as store # noqa: E402
from M02_MasterTemplete import M02_San_Bdqty as bq # noqa: E402
from M02_MasterTemplete import M02_San_Ilwi as il # noqa: E402
from M02_MasterTemplete import M02_San_MatPick as mp # noqa: E402
from M02_MasterTemplete import M02_San_MyMachine as mm # noqa: E402
from M02_MasterTemplete import M02_San_Stc as S # noqa: E402
from M02_MasterTemplete import M02_Sangeun_FromHopyo as fh # noqa: E402
from resources.tester import test_810_1_stc as stc # noqa: E402
from resources.tester.test_m02_labor_ref import world # noqa: E402, F401 — 같은 임시 층
#: 엑셀 짝 → 같은 공사 STC(코드 같음 · 울진대흥 = 코드 새로 매김 → 이름 짝)
PAIR = {"봉화": "봉화", "동부청영월": "동부청영월", "울진대흥": "울진대흥", "영덕계류": "영덕계류", "울진소광": "울진소광"} # fmt: skip
@lru_cache(maxsize=8)
def parsed(tag: str) -> dict:
return S.read(stc.raw(tag))
def _members(data: bytes) -> dict[str, bytes]:
z = zipfile.ZipFile(io.BytesIO(data))
return {i.filename: z.read(i) for i in z.infolist()}
def _bdqty_name(m: dict) -> str:
return next(k for k in m if k.upper() == "BDQTY.DBF")
# ── ① 거울 ───────────────────────────────────────────────────────────────
@pytest.mark.parametrize("tag", list(stc.FILES))
def test_mirror_bdqty(tag: str) -> None:
import bdqty
m = _members(stc.raw(tag))
raw = m[_bdqty_name(m)]
theirs = [{k: r[k] for k in bq.TRUSTED if k in r} for r in bdqty.decode_bdqty(raw)]
assert bq.decode(raw) == theirs and len(theirs) > 300
# ── ② 스스로 검사 · 물러남 ───────────────────────────────────────────────
def _field(raw: bytes, name: str) -> tuple[int, int, int, int]:
"""(헤더 길이, 레코드 길이, 칸 자리, 칸 길이)."""
_, hlen, rlen = struct.unpack_from("<IHH", raw, 4)
o, off = 32, 1
while raw[o] != 0x0D:
if raw[o : o + 11].split(b"\0")[0].decode() == name:
return hlen, rlen, off, raw[o + 16]
off += raw[o + 16]
o += 32
raise KeyError(name)
def _scrambled(name: str) -> bytes:
"""봉화 STC — BDQTY 첫 레코드 그 칸 바이트를 흩뜨린 사본."""
data = stc.raw("봉화")
m = _members(data)
key = _bdqty_name(m)
raw = bytearray(m[key])
hlen, _, off, ln = _field(bytes(raw), name)
for j in range(off, off + ln):
raw[hlen + j] ^= 0x5A
m[key] = bytes(raw)
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as z:
for k, v in m.items():
z.writestr(k, v)
return buf.getvalue()
@pytest.mark.parametrize("name", ["CODE", "BDCODE", "DEVD1"])
def test_check_falls_back(name: str) -> None:
d = S.read(_scrambled(name))
why = [x for x in d["표지"] if x.startswith(S.FALLBACK)]
assert len(why) == 1 and S.UNREAD[0] in d["표지"]
assert all(not b["줄"] and S.UNREAD[0] in b["표지"] for b in d["일위"])
assert not d.get("기계구성") and len(d["산근"]) == 65 # 산근 길 그대로
assert {"CODE": "자식 코드", "BDCODE": "부모 코드", "DEVD1": "나누기"}[name] in why[0]
def test_check_rules() -> None:
ok = [{"BDCODE": "B00001", "CODE": c, "DEVD1": "F"} for c in ("M00001", "PR", "C1", "T2", "")]
assert bq.check(ok, {"M00001"}) is None
assert bq.check([{"BDCODE": "Q00001", "CODE": "", "DEVD1": ""}], set())
assert bq.check([{"BDCODE": "B00001", "CODE": "M9", "DEVD1": ""}], set())
assert bq.check([{"BDCODE": "B00001", "CODE": "", "DEVD1": "T"}], set())
# ── ③ 일위 구성 줄 = 엑셀 · 다시 셈 ─────────────────────────────────────────
def _meta(s: str) -> dict:
return dict(t.split("|", 1) for t in re.split(r"_x000[0-9A-Fa-f]_`", s or "") if "|" in t)
@lru_cache(maxsize=8)
def _xl(tag: str, sheet: str) -> list[list]:
import openpyxl
path = golden.FILES[tag]
if not path.exists():
pytest.skip(f"실무 엑셀 없음 {tag}")
with warnings.catch_warnings():
warnings.simplefilter("ignore")
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
return [list(r) + [None] * 30 for r in wb[sheet].iter_rows(values_only=True)]
def _xl_ilwi(tag: str) -> dict[str, dict]:
"""엑셀 일위대가표 → {호표 코드: {머리 (이름, 규격), 줄 [(코드, 수량, 이름)]}}(숨은 N 열 메타)."""
out: dict[str, dict] = {}
cur = None
for r in _xl(tag, "일위대가표"):
n = r[13]
if isinstance(n, str) and "BDCOD|" in n:
cur = out.setdefault(_meta(n)["BDCOD"], {"머리": None, "줄": []})
continue
if cur is None:
continue
if r[0] == "합 계":
cur = None
elif isinstance(n, str) and "COD|" in n:
cur["줄"].append((_meta(n).get("COD", "").strip(), r[2], str(r[0] or "")))
elif cur["머리"] is None:
cur["머리"] = (str(r[0] or "").strip(), str(r[1] or "").strip())
return out
def _flat(v) -> str:
return re.sub(r"\s+", "", str(v or ""))
def _same_lines(xl: list, mine: list, res: dict) -> bool:
if len(xl) != len(mine):
return False
for (code, qty, name), one in zip(xl, mine, strict=True):
if code != one["코드"] and (
code[:1] != one["코드"][:1] or _flat(name) != _flat(one["이름"])
):
return False
if code not in ("PR", "RR") and qty is not None and abs(float(qty) - one["수량"]) > 1e-6:
return False
return True
def test_lines_equal_excel_ilwi() -> None:
hops = lines = 0
for xt, st in PAIR.items():
d = parsed(st)
mine = {b["코드"]: b for b in d["일위"]}
by_name = {(_flat(b["이름"]), _flat(b["규격"])): b for b in d["일위"]}
for code, x in _xl_ilwi(xt).items():
b = mine.get(code)
if b is None or (_flat(b["이름"]), _flat(b["규격"])) != tuple(map(_flat, x["머리"])):
b = by_name[tuple(map(_flat, x["머리"]))]
assert _same_lines(x["줄"], b["줄"], d["자원"]), (xt, code)
hops += 1
lines += len(x["줄"])
assert (hops, lines) == (136, 706)
def test_recompute_equals_saved() -> None:
n_b = n_x = 0
for tag in stc.FILES:
d = parsed(tag)
assert not any(b["표지"] for b in d["일위"]), tag # 일위 다시 셈 = 저장(B00038 꼴 포함)
assert not [m for m in d["표지"] if il.MISMATCH in m], tag # 중기 다시 셈 = 저장
n_b += len(d["일위"])
n_x += sum(1 for c in d["자원"] if c[:1] == "X")
assert (n_b, n_x) == (216, 176)
b38 = next(b for b in parsed("소광변경1")["일위"] if b["코드"] == "B00038")
assert [ln["코드"] for ln in b38["줄"]][-2:] == ["C1", "PR"] and not b38["표지"]
def test_line_shape() -> None:
b = next(b for b in parsed("봉화")["일위"] if b["코드"] == "B00013")
rate = b["줄"][-1]
assert rate["갈래"] == "율" and rate["율"] == {"밑": "L", "대상": "M", "곱": False}
assert rate["이름"] == "노무비의 3%" and rate["금액"]["M"] == pytest.approx(409.3)
assert all(ln["밑줄"] for ln in b["줄"][:2]) and b["줄합"]["T"] == b["저장"]["T"]
kinds = {ln["갈래"] for t in stc.FILES for x in parsed(t)["일위"] for ln in x["줄"]}
assert kinds <= {"M", "L", "S", "X", "B", "D", "W", "율", "닫기", "구분", "글"}
apart = [ln for x in parsed("동부청영월")["일위"] for ln in x["줄"] if ln.get("별도계상")]
assert len(apart) == 18 # 엑셀 비고 「별산…」 줄과 같음(자재 17 + 경비 S11039 · 828-2)
def test_same_lines_as_excel_reader() -> None:
"""828-2 엑셀 읽기기(`M02_San_Xlsx_Ilwi` 숨은 칸) 일위 줄 = 원파일 줄(코드 · 갈래 · 수량 · 율 · 표시 · 136 · 706)."""
from M02_MasterTemplete import M02_San_Book as book
from M02_MasterTemplete import M02_San_Xlsx as sx
def key(x: dict) -> tuple:
r, code = x.get("율") or {}, x["코드"]
qty = None if x["갈래"] == "율" else round(float(x["수량"] or 0), 6)
flags = tuple(bool(x.get(k)) for k in ("밑줄", "관급", "별도계상"))
return code[:1] if code[:1] in "MLSXBDW" else code, x["갈래"], qty, r.get("밑"), r.get("대상"), flags # fmt: skip
hops = lines = 0
for xt, st in PAIR.items():
path = golden.FILES[xt]
if not path.exists():
pytest.skip(f"실무 엑셀 없음 {xt}")
xl = sx.read_sheets(book.sheets(path.name, path.read_bytes()))
nat = parsed(st)["일위"]
by_code = {b["코드"]: b for b in nat}
by_name = {(_flat(b["이름"]), _flat(b["규격"])): b for b in nat}
for b in xl["일위"]:
head = (_flat(b["이름"]), _flat(b["규격"]))
y = by_code.get(b["코드"])
if (
y is None or (_flat(y["이름"]), _flat(y["규격"])) != head
): # 울진대흥 = 코드 새로 매김
y = by_name[head]
assert [key(x) for x in b["줄"]] == [key(x) for x in y["줄"]], (xt, b["코드"])
hops, lines = hops + 1, lines + len(b["줄"])
assert (hops, lines) == (136, 706)
# ── ④ 중기 구성 ──────────────────────────────────────────────────────────
def test_machines_equal_excel() -> None:
n = m = 0
for xt, st in PAIR.items():
xl = mm._stm_sheet(_xl(xt, "중기사용료"))
mine = parsed(st)["기계구성"]
for a in {
id(m): m for m in xl.values()
}.values(): # 호표 하나 = 구성 하나(코드 · 이름 열쇠 둘)
b = mine.get(mm.key_of(a["이름"], a["규격"]))
assert a == b, (xt, a["이름"])
n += 1
rows = _xl(xt, "중기사용료")
heads = [rows[i + 1] for i, r in enumerate(rows) if re.fullmatch(r"제\d+호표", _flat(r[0]))]
assert all(mm.key_of(h[0], h[1]) in mine for h in heads), xt # 엑셀 읽기기가 못 읽는 머리도
m += len(heads)
assert (n, m) == (100, 118) # 엑셀 읽기기 100(울진대흥 머리 코드 없음 9 만) · 엑셀 호표 118
def test_my_machine_seed_and_peers() -> None:
d = parsed("봉화")
make = d["기계구성"]["X00018"]
assert make["손료"] == [[2799.0, 875000.0, False]] and make["주연료"] == 1.3
assert make["잡재료율"] == 20.0
line = {"자원": {"이름": make["이름"], "규격": make["규격"], "갈래": "기계"}}
body = mm.body(make, 0, line, "봉화.STC")
assert body and body["이름"] == make["이름"]
saved = fn.read("봉화.STC", stc.raw("봉화"))
assert saved["기계구성"]["X00018"] == make # 둘 꼴(올림 저장)에 감
assert any(p.get("같은구성") for p in saved["단가"].values())
# ── ⑤ 낙찰률 줄 2 칸 · 원설계 그대로 ─────────────────────────────────────────
def test_bid_rate_rows_slot_two() -> None:
d = parsed("영덕계류")
assert d["자원"]["D01416"]["저장"]["T"] == 2239 # 산근 · 자원 = 1 칸(엑셀 단가산출근거와 같음)
assert d["자원"]["D01416"]["원단가"]["T"] == 2547 # 2 칸 = 원단가(2239 = 2547 × 88 %)
b = next(b for b in d["일위"] if b["코드"] == "B01092")
assert b["저장"]["T"] == 62804 == b["줄합"]["T"] # 일위 머리 = 2 칸(엑셀 일위대가표 62,804)
for tag in ("봉화", "동부청영월", "울진대흥", "소광22"):
z, names = S._zip(stc.raw(tag))
rows = S.dbf(z.read(names["COSTN.DBF"]))
assert all(S._f(r.get("NAGCD")) == 0 for r in rows)
res = parsed(tag)["자원"]
assert all(res[r["CODE"]]["저장"]["T"] == S._f(r["TAMT1"]) for r in rows if r["CODE"])
assert not any("원단가" in v for v in res.values())
# ── ⑥ 일위대가 길 ─────────────────────────────────────────────────────────
def test_hopyo_route(world) -> None:
folder = world["personal"]
folder.mkdir(parents=True, exist_ok=True)
tag = "동부청영월"
with store.at(folder):
row = fh.upload_native(folder, stc.FILES[tag].name, stc.raw(tag), {"이름": "sub3임시"})
assert row["호표수"] == 23
grid = fn.grid(fn.read(stc.FILES[tag].name, stc.raw(tag)))
assert grid["보기"]["일위대가표"] == "s2" and grid["책"]["시트"][0]["이름"] == "일위대가표"
from M02_MasterTemplete import M02_Hopyo_View as hv
got = hv.listing(folder, row["키"], folder)
assert len(got["호표"]) == 23 and all(h["맞춤"][1] for h in got["호표"])
no = next(h["번"] for h in got["호표"] if h["번"] == 1984) # 측구보호용잡석채움 — 단산 둘
made = hb.build_all(folder, folder, row["키"], None, True, no)
assert [x["번"] for x in made["만듦"]] == [no] and made["같음"] == 1, made
assert len(made["단가산출"]) == 2
kinds = mp.census(folder, row["키"])
assert any(g.get("호표") for g in kinds) # 구성 줄 재료가 판에 옴