Files
Aislo/resources/tester/test_stmate_report_sheets.py
T
eomsangdonandClaude Opus 5.5 3d8fddfc34 feat(stmate-test): 보고서·제잡비·식언어·수량산출·화면 묶음 합침 · 재계산 후 저장 무변경
- 보고서: 원본 STMATE 엑셀과 셀 대조(목록표·자재단가대비·중기·일위대가·산근 목록·설계내역서·원가계산서) · 조달청 XML 공식 XSD 검증 · PM_Prt 화면
- 제잡비: 원본 계산 순서 4형식(T/S/TA/SA) · 실무 7건 저장 결과키 전수 1원 · 요율표 조회 · 선택지 계산 · 제잡비·제비율 화면
- 식언어·수량: 함수·조건식·금액변수·문법 검사·수량변수·QCODE · 산근·구성·중기공식 편집 화면 · 순환 알림
- 화면: 원본 메뉴 체계·단축키 · 설계내역서 편집(행 넣기·지우기·이동·레벨·보관함) · 초기값 · 단가목록표
- 저장: 초기값(Start8x.Ini) 바뀐 키 저장 · 노임 환산 직종 = 초기값 CONLD · 산근·구성 결과에 적용단가 칸 채우지 않음(원본 0)
- DBF 글 칸 앞 빈칸 보존 · 시험 도우미 표준입력 utf8(한글 조각 깨짐 원인)
- 실무 5건 재계산 후 저장 = 바뀐 칸 0 · 시험 332건 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 19:12:04 +09:00

199 lines
8.2 KiB
Python

"""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")