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:
@@ -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) # 구성 줄 재료가 판에 옴
|
||||
Reference in New Issue
Block a user