- 828-2 엑셀 읽기기와 5 건 136 호표 706 줄 다름 0(코드 · 갈래 · 수량 · 율 · 밑줄 · 관급 · 별도계상) — 시험 test_828_1 에 대조 더함 - 영월 S11039 검증수수료 · 소광 W 깬돌(구입별산) = 별도계상 · 봉화 「잡재료비 재료비의 %」 = 판 재료 아님 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
317 lines
15 KiB
Python
317 lines
15 KiB
Python
# 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) # 구성 줄 재료가 판에 옴
|