Files
Aislo/resources/tester/test_828_1_stc_composition.py
T
eomsangdonandClaude Opus 5.5 43e9455688 feat(828-1): 원파일 일위대가 구성 줄 → 일위대가 길 · 중기 구성 → 내 기계 씨앗 · 낙찰률 줄 2 칸 좁힘
- ㉯ STC 일위 = HP 호표(엑셀 일위대가표 길과 같은 꼴 · 「일위대가표」 시트) → 목록 · 만들기 · 고를 재료 판(구성 줄 · 「일위대가 N」)
- ㉰ 구성표 중기 → 기계구성(엑셀 중기사용료와 같은 값 · 달러 손료 = 천원) · 같은 파일 구성 대조 같은구성 → STmate 기계 찾기
- ㉱ 낙찰률 줄 2 칸(원단가) = 구성 셈 · 일위 머리만(엑셀 일위대가표와 같음) · 산근 · 자원 = 1 칸 그대로(엑셀 단가산출근거와 같음 · 812-1 짝 0)
- 별도계상 = 자재 이름 · 규격 「별도계상」 · 「별산」(엑셀 비고 「별산…」 짝 · 828-2 대조)
- MyMachine 엑셀 중기사용료 잡품 줄 = 잡재료 · 잡재료비 이름도(원파일과 같은 값)
- 시험 test_828_1_stc_composition 새로 · test_814_1 낡은 칸 고침(원파일 호표 0 → 45 · 격자 일위대가표 시트) · test_810_1 거울 대조 글 칸 앞 빈칸 걷음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:47:55 +09:00

285 lines
13 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) == 17 # 엑셀 비고 「별산…」 17 줄과 같음(분석 813 2 장)
# ── ④ 중기 구성 ──────────────────────────────────────────────────────────
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) # 구성 줄 재료가 판에 옴