Merge commit '43e945568' into sub_laptop_1

# Conflicts:
#	M02_MasterTemplete/M02_MasterTemplete_Hopyo_MatPick.ts
#	M02_MasterTemplete/M02_San_MatPick.py
This commit is contained in:
2026-11-13 20:49:48 +09:00
10 changed files with 426 additions and 49 deletions
+4 -4
View File
@@ -3,8 +3,8 @@
(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` 계 · 결과 조정 · 끝전 갈래 = 옵션
SAN_TYP 1 비목버림 / 0 합버림_큰나머지) = COSTN 저장 금액(차수 1 칸 · 낙찰률 줄 = 2 칸 · 828-1) 100 %
(b) 블록 다시 셈(`M02_San_Eval.stmate_block` · 차수 1 단가 · 낙찰 · 결과 조정 · 끝전 갈래 = 옵션
SAN_TYP 1 비목버림 / 0 합버림_큰나머지) = COSTN 저장 금액(TAMT1 · LAMT1 · MAMT1 · SAMT1) 100 %
(c) 엑셀 짝 대조는 812-1 엑셀 읽기기 골든(`test_812_1_xlsx_golden` · 옛 엑셀 엔진 걷음)
원파일 없으면 skip(다른 PC).
"""
@@ -100,8 +100,8 @@ def test_saved_amounts(tag: str) -> None:
assert (len(d["산근"]), len(d["일위"]), d["옵션"]["SAN_TYP"]) == (n_cost, n_head, san)
assert all(b["줄"] and not b["표지"] for b in d["일위"]) # 828-1 구성표 읽음
bad = []
for b in d["산근"]: # 저장 = 계약 전 계(낙찰률 줄 = 2 칸 원단가 · 828-1 ㉱)
got = ev.stmate_block(b, [ln["풀이"] for ln in b["줄"]], d["단가"], RULE[san])["계"]
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 == []
@@ -71,11 +71,12 @@ def test_one_file_and_list_sheet(world) -> None:
folder = world["personal"]
with store.at(folder):
got = fh.upload_native(folder, stc.name, stc.read_bytes(), t.WHO)
assert got["원파일"] and got["호표수"] == 0 and "붙음" not in got
assert got["원파일"] and got["호표수"] == 45 and "붙음" not in got # 828-1 구성표 일위대가
grid = hs.grid(folder, got["키"])
blocks = fn.numbers(t.native("STC", "봉화"))
assert grid["보기"] == {"단가산출표": "s1"} and len(grid["단산"]) == len(blocks)
head = grid["책"]["시트"][0]["칸"]
assert grid["보기"] == {"단가산출표": "s1", "일위대가표": "s2"}
assert len(grid["단산"]) == len(blocks) and len(grid["호표"]) == 45
head = next(s for s in grid["책"]["시트"] if s["id"] == "s1")["칸"]
assert [head[f"{c}1"]["값"] for c in "ABCDEFGH"] == list(fn._LIST_HEAD)
first = next(iter(blocks.values()))
assert head["A2"]["값"] == first["코드"] and head["H2"]["값"] == first["저장"]["T"]
@@ -0,0 +1,284 @@
# 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) # 구성 줄 재료가 판에 옴