"""STMATE 재현 앱 공사 단위 파일 작업 — STC 검사 · 해당분만 압축 · 복사등록(차수/계약 변환) · 새 공사 · 백업/복원. 정답은 실무 STC 자체 — 소광 신설(BOQ11+BOQ12 통합형) → 변경1차(분리형) 짝으로 차수 인계 규칙(16번 §4.6) 대조. 쓰기는 임시 폴더(server.WORK_DBF_ROOT 바꿈)에서만. """ import sys from pathlib import Path import pytest ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT / "tools/stmate-test")) import api_file # noqa: E402 import server # noqa: E402 from dbf_memo import SixMemo, ini_items # noqa: E402 from dbf_rw import DbfTable # noqa: E402 from stc_archive import build_stc, check_stc, files_of, read_entries # noqa: E402 from stc_project import copy_register, new_project, pack_selected # noqa: E402 from stc_save import _leaf, boq_spans # noqa: E402 DOCS = ROOT / "resources/knowledge/original/실무문서" STCS = sorted(DOCS.rglob("*.STC")) IDS = [p.stem[:16] for p in STCS] SOGWANG = DOCS / "25년 산불진화임도(기번8)(울진소광)" JOINT = next(SOGWANG.glob("2025년*.STC")) # BOQ11(당초) + BOQ12(현재안) SPLIT = next(SOGWANG.glob("변경*.STC")) # 분리형 변경1차 def _files(p): return files_of(read_entries(p.read_bytes())) def _memo_text(files, i): t = DbfTable(files["PRJLST.DBF"]) return SixMemo(files["PRJLST.SMT"]).read(t.raw(i, "PRJ_MEMO")).decode("cp949") @pytest.fixture def work(tmp_path, monkeypatch): monkeypatch.setattr(server, "WORK_DBF_ROOT", tmp_path) return tmp_path @pytest.mark.parametrize("stc", STCS, ids=IDS) def test_check_stc_accepts_real_files(stc): res = check_stc(stc.read_bytes()) assert res["ok"], res["problems"] assert res["tables"]["PRJLST.DBF"]["nrec"] >= 1 assert not check_stc(b"PK-not-a-zip")["ok"] def test_pack_selected_carries_current_plan_into_split_file(): src = _files(JOINT) packed = pack_selected(src, "12") assert packed["BOQ11.DBF"] == src["BOQ12.DBF"] and "BOQ12.DBF" not in packed pt, st = DbfTable(packed["PRJLST.DBF"]), DbfTable(src["PRJLST.DBF"]) assert len(pt) == 1 and pt.get(0, "PCODE") == "11" for f in pt.order: if f not in ("PCODE", "PRJ_MEMO"): assert pt.raw(0, f) == st.raw(1, f), f assert _memo_text(packed, 0) == _memo_text(src, 1) assert SixMemo(packed["PRJLST.SMT"]).next_free == -(-len(packed["PRJLST.SMT"]) // 64) # 16번 §4.6 — 변경1차 파일의 차수1 직접비 = 앞 파일 BOQ12 공사 직접비 real = DbfTable(_files(SPLIT)["PRJLST.DBF"]) for c in ("MCOST1", "LCOST1", "SCOST1", "TCOST1"): assert float(pt.get(0, c)) == float(real.get(0, c)), c assert check_stc(build_stc(read_entries(JOINT.read_bytes()), packed))["ok"] def test_copy_register_change_mode_opens_next_stage(): base = pack_selected(_files(JOINT), "12") out, pcode, _log = copy_register(base, "11", "change") assert pcode == "12" and out["BOQ11.DBF"] == base["BOQ11.DBF"] a, b = DbfTable(base["BOQ11.DBF"]), DbfTable(out["BOQ12.DBF"]) assert len(a) == len(b) for i in range(len(a)): for f in ("QTY", "MAMT", "LAMT", "SAMT", "TAMT", "M_AMT", "T_AMT", "BDCODE"): assert b.raw(i, f"{f}2") == a.raw(i, f"{f}1"), (i, f) assert b.raw(i, f"{f}1") == a.raw(i, f"{f}1"), (i, f) pt = DbfTable(out["PRJLST.DBF"]) real = DbfTable(_files(SPLIT)["PRJLST.DBF"]) assert len(pt) == 2 and pt.get(1, "PCODE") == "12" assert pt.get(1, "K_ORD") == real.get(0, "K_ORD") == "1" assert pt.get(1, "TCOST1") == pt.get(1, "TCOST2") == real.get(0, "TCOST1") secs = {s for s, _k, _v in ini_items(_memo_text(out, 1))} assert {"1_KANJUB", "2_KANJUB"} <= secs one = {k[2:]: v for s, k, v in ini_items(_memo_text(out, 1)) if s == "1_KANJUB"} two = {k[2:]: v for s, k, v in ini_items(_memo_text(out, 1)) if s == "2_KANJUB"} assert one == two assert _memo_text(out, 0) == _memo_text(base, 0) # 원래 공사 메모 그대로 def test_copy_register_contract_mode_applies_rates_except_excluded(): src = _files(STCS[0]) same, _, log = copy_register(src, "11", "contract", rates={"M": 100, "L": 100, "S": 100}) assert DbfTable(same["BOQ12.DBF"]).recs == DbfTable(src["BOQ11.DBF"]).recs and not log out, pcode, _ = copy_register(src, "11", "contract", rates={"M": 87.745, "L": 90, "S": 0}) a, b = DbfTable(src["BOQ11.DBF"]), DbfTable(out[f"BOQ{pcode}.DBF"]) spans, excl = boq_spans(a) for i in range(len(a)): if not _leaf(a.get(i, "BDCODE1")): continue if i in excl: assert b.recs[i] == a.recs[i] continue assert int(float(b.get(i, "MAMT1"))) == int(float(a.get(i, "MAMT1") or 0) * 0.87745 + 1e-9) assert float(b.get(i, "SAMT1") or 0) == 0 # 0% = 공내역 for r, x, y, isx in spans: tot = sum( int(float(b.get(k, "T_AMT1") or 0)) for k in range(x, y + 1) if _leaf(b.get(k, "BDCODE1")) and (isx or k not in excl) ) assert int(float(b.get(r, "T_AMT1") or 0)) == tot direct = sum( int(float(b.get(k, "T_AMT1") or 0)) for k in range(len(b)) if _leaf(b.get(k, "BDCODE1")) and k not in excl ) assert int(float(DbfTable(out["PRJLST.DBF"]).get(1, "TCOST1"))) == direct def test_new_project_opens_empty(work): out = new_project(_files(STCS[0]), "새 임도 공사", "2026-01") p = work / "new.STC" p.write_bytes(build_stc(read_entries(STCS[0].read_bytes()), out)) assert check_stc(p.read_bytes())["ok"] load = server.api_load(server.LoadReq(source_path=str(p))) assert load["costn_count"] == 0 and load["boq_count"] == 0 assert load["prjlst"]["PNAME"] == "새 임도 공사" and load["prjlst"]["PCODE"] == "11" assert float(load["prjlst"]["TCOST1"] or 0) == 0 smt = load["prjlst_smt"] assert smt and all(float(v) == 0 for k, v in smt.items() if k.startswith("1_C_")) assert any(k.startswith("1_R_") and float(v) > 0 for k, v in smt.items()) # 요율 설정은 틀에서 def test_file_api_endpoints(work): src = str(JOINT) assert api_file.api_file_check(api_file.PathReq(source_path=src))["ok"] r = api_file.api_file_pack(api_file.PackReq(source_path=src, pcode="12", target_name="pk")) assert Path(r["saved_stc"]).parent == work and check_stc(Path(r["saved_stc"]).read_bytes())["ok"] r2 = api_file.api_file_copy_register( api_file.CopyRegReq(source_path=r["saved_stc"], mode="change", target_folder_name="cr") ) assert r2["new_pcode"] == "12" and (work / "cr" / "BOQ12.DBF").exists() load = server.api_load(server.LoadReq(source_path=str(work / "cr"))) assert sorted(load["boq_tables"]) == ["BOQ.DBF", "BOQ11.DBF", "BOQ12.DBF"] r3 = api_file.api_file_new( api_file.NewPrjReq(template_path=src, pname="새 공사", target_folder_name="nw") ) assert Path(r3["saved_stc"]).exists() api_file.api_file_new(api_file.NewPrjReq(template_path=src, pname="새 공사2", target_folder_name="nw")) bks = api_file.api_file_backups("nw")["backups"] assert len(bks) == 1 api_file.api_file_restore(api_file.RestoreReq(backup_path=bks[0]["path"], target_folder_name="nw")) assert server.api_load(server.LoadReq(source_path=str(work / "nw")))["prjlst"]["PNAME"] == "새 공사" with pytest.raises(Exception): api_file.api_file_restore(api_file.RestoreReq(backup_path=src, target_folder_name="x")) @pytest.mark.parametrize("stc", STCS, ids=IDS) def test_load_reads_side_tables_and_all_settings(stc): load = server.api_load(server.LoadReq(source_path=str(stc))) assert load["kongjong_rows"] and all("CODE" in r for r in load["kongjong_rows"]) assert load["costa_count"] == 0 and load["qcode_rows"] == [] rows = load["prjlst_smt_all"] assert len(rows) == len(load["prjlst_all"]) first = rows[0] assert first["key_count"] >= len(load["prjlst_smt"]) > 100 and "KANJUB" in first["sections"] for k, v in load["prjlst_smt"].items(): # 첫 공사 읽기(parse_prjlst_smt)와 같은 값 assert any(s.get(k) == v for s in first["sections"].values()), k assert "START" in load["start_ini_sections"]