- 보고서: 원본 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>
199 lines
8.2 KiB
Python
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")
|