"""STMATE 재현 앱 보고서(G5) — 원본 STMATE 가 내보낸 엑셀 시트와 셀 값 대조. 정답 = 실무 STC 옆 원본 출력 엑셀(.xlsx · 계산된 값). 코드에 정답값을 박지 않음. 앱 쪽 = STC 불러오기 → 연쇄 재계산(계산완료) → 보고서 모형(tools/stmate-test/report_*.js). 울진 = 「변경 1차분」 STC 의 당초(착공) 차수 ↔ 착공 때 내보낸 엑셀(원가 줄만 — 코드 번호가 뒤에 바뀜). 영덕 변경 = 변경 차수 원가계산서(당초·변경·증감) — 목록류는 변경분만 고른 출력이라 대조 밖. 빼는 칸: 원본 부속 칸(STmate 표지·바로가기·숨은 메타) · BDQTY 암호 글 칸(글 줄·비율 줄 명칭) · DBF 읽기에서 앞 빈칸이 깎인 글(앞뒤 빈칸 걷고 맞댐). """ import json import subprocess import sys import warnings from functools import lru_cache from pathlib import Path import openpyxl import pytest ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT / "tools/stmate-test")) import server # noqa: E402 HELPER = Path(__file__).with_name("helper_stmate_report.cjs") DOCS = ROOT / "resources/knowledge/original/실무문서" PAIRS = { "봉화2024": ( "04.산불 봉화/01. 내역서/13-2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km.STC", "04.산불 봉화/01. 내역서/2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km.xlsx", 1, ), "영월2024": ( "05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.STC", "05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.xlsx", 1, ), "대흥2024": ( "2024년 간선임도(기번3-울진.대흥)(공통)/1. 설계원본/01 내역자료/2024년 간선임도사업(기번3).STC", "2024년 간선임도(기번3-울진.대흥)(공통)/1. 설계원본/01 내역자료/2024년 간선임도사업(기번3).xlsx", 1, ), "울진착공": ( "25년 산불진화임도(기번8)(울진소광)/변경 1차분-2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외).STC", "25년 산불진화임도(기번8)(울진소광)/2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외).xlsx", 1, ), "영덕변경": ( "20250527 2025년 계류보전사업(기번1-영덕.병곡.영리.산214-1)(변경)/01.내역자료/2025년 계류보전사업(기번1 영덕.병곡.영.산214)(변경).STC", "20250527 2025년 계류보전사업(기번1-영덕.병곡.영리.산214-1)(변경)/01.내역자료/2025년 계류보전사업(기번1 영덕.병곡.영.산214)(변경).xlsx", 2, ), } FULL = ("봉화2024", "영월2024", "대흥2024") # 시트 id → 대조 열(원본 엑셀 보이는 칸) UNIT_SHEETS = { "일위대가목록표": "ABCDEFGHI", "단가산출근거목록표": "ABCDEFGHI", "중기목록표": "ABCDEFGHI", "재료비목록표": "ABCDEF", "노무비목록표": "ABCDEF", "경비목록표": "ABCDEF", "자재단가대비표": "ABCDEFGHIJKLMNOPQ", "환율및기초자료": "ABCDEFGH", "일위대가표": "ABCDEFGHIJKLM", "중기사용료": "ABCDEFGHIJKLM", } @lru_cache(maxsize=None) def book(name): stc, _, stage = PAIRS[name] load = server.api_load(server.LoadReq(source_path=str(DOCS / stc))) out = subprocess.check_output( ["node", str(HELPER)], input=json.dumps({"load": load, "stage": stage}, ensure_ascii=False), text=True, encoding="utf-8", ) return json.loads(out) @lru_cache(maxsize=None) def original(name, sheet): warnings.filterwarnings("ignore") wb = openpyxl.load_workbook(DOCS / PAIRS[name][1], data_only=True, read_only=True) return [list(r) for r in wb[sheet].iter_rows(values_only=True)] def norm(v): if v is None or v == "": return None if isinstance(v, (int, float)): return None if abs(v) < 1e-9 else round(float(v), 4) return str(v).strip() or None def cell(row, col): i = ord(col) - 65 return row[i] if i < len(row) else None def unreadable(mrow, col): """BDQTY 암호 글 칸 — 설명 줄과 비율(PR) 줄의 명칭·규격.""" return col in "AB" and (mrow.get("_k") == "text" or mrow.get("_code") == "PR") def grid_diffs(name, sid, cols): mine = book(name)[sid] orig = original(name, mine["name"]) diffs = [] for i in range(max(len(orig), len(mine["rows"]))): o = orig[i] if i < len(orig) else [] m = mine["rows"][i] if i < len(mine["rows"]) else {} for c in cols: if unreadable(m, c): continue ov, mv = norm(cell(o, c)), norm(m.get(c)) if ov != mv: diffs.append((f"{c}{i + 1}", ov, mv)) return diffs, len(orig) @pytest.mark.parametrize("name", FULL) @pytest.mark.parametrize("sid", list(UNIT_SHEETS)) def test_unit_sheet_cells_match_original(name, sid): diffs, n = grid_diffs(name, sid, UNIT_SHEETS[sid]) assert n > 4, f"{name} {sid} 원본 시트 비어 있음" assert not diffs, f"{name} {sid} 불일치 {len(diffs)}건 {diffs[:5]}" @pytest.mark.parametrize("name", list(PAIRS)) def test_won_sheet_matches_original(name): cols = "ABCDEFGH" if PAIRS[name][2] > 1 else "ABCDEF" diffs, n = grid_diffs(name, "공사원가계산서", cols) assert n > 20 assert not diffs, f"{name} 공사원가계산서 불일치 {diffs[:5]}" def content_rows(rows, cols, get): out = [] for r in rows: v = [norm(get(r, c)) for c in cols] if any(v): out.append(v) return out @pytest.mark.parametrize("name", FULL) def test_design_boq_rows_match_original(name): """설계내역서 — 쪽 넘김 빈 줄 수는 인쇄 쪽 길이에 달려 내용 줄끼리 차례대로 맞댐.""" mine = book(name)["설계내역서"] cols = "ABCDEFGHIJKLMN" orig = content_rows(original(name, mine["name"])[2:], cols, cell) got = content_rows(mine["rows"][2:], cols, lambda r, c: r.get(c)) bad = [(i, o, g) for i, (o, g) in enumerate(zip(orig, got)) if o != g] assert len(orig) == len(got) > 50 assert not bad, f"{name} 설계내역서 불일치 {len(bad)} {bad[:3]}" @pytest.mark.parametrize("name", FULL + ("울진착공",)) def test_summary_cost_lines_and_groups_match_original(name): """총괄내역서 — 원가 줄(가.~바.)은 그대로 · 공종 묶음 줄은 원본에 나온 단계만 차례대로(출력 단계 선택).""" mine = book(name)["총괄내역서"] cols = "ABCDEFG" orig = content_rows(original(name, mine["name"])[3:], cols, cell) got = content_rows(mine["rows"][3:], cols, lambda r, c: r.get(c)) cut_o = next(i for i, r in enumerate(orig) if r[0] == "가.") cut_g = next(i for i, r in enumerate(got) if r[0] == "가.") assert orig[cut_o:] == got[cut_g:], f"{name} 원가 줄 불일치" it = iter(got[:cut_g]) missing = [o for o in orig[:cut_o] if not any(o == g for g in it)] assert cut_o > 2 and not missing, f"{name} 공종 줄 없음 {missing[:3]}" def test_xlsx_export_writes_every_sheet_with_model_values(): """엑셀 내보내기(api_report → report_xlsx) — 시트 차례 · 이름 · 값이 모형 그대로, 목차는 바로가기.""" from io import BytesIO from fastapi.testclient import TestClient client = TestClient(server.app) sheets = list(book("영월2024").values()) res = client.post("/api/report/xlsx", json={"sheets": sheets, "filename": "t.xlsx"}) assert res.status_code == 200 wb = openpyxl.load_workbook(BytesIO(res.content)) assert wb.sheetnames == [s["name"] for s in sheets] for s in sheets: ws = wb[s["name"]] for i, row in enumerate(s["rows"], start=1): for c, v in row.items(): if c.isupper() and v not in (None, ""): got = ws[f"{c}{i}"].value same = abs(got - v) < 1e-9 if isinstance(v, float) else got == v assert same, f"{s['name']} {c}{i}" assert wb["〓 목 차 〓"]["D5"].hyperlink is not None csv = client.post("/api/report/csv", json={"sheets": [sheets[2]], "filename": "c"}) assert csv.status_code == 200 and "공사원가계산서" in csv.content.decode("utf-8-sig")