Files
Aislo/resources/tester/test_stmate_report_link.py
T

341 lines
15 KiB
Python

"""STMATE 재현 앱 보고서 3차(G5) — 엑셀 수식 · 목차 · 안내 · 금액 한글 · 인쇄 선택을 원본 STMATE 엑셀과 대조.
정답 = 실무 STC 옆 원본 출력 엑셀(.xlsx) — 수식은 openpyxl 수식 읽기(data_only 아님) 글 그대로.
앱 쪽 = report_link.js(시트 간 참조 수식 · 바로가기) · report_book.js(목차 · 안내) · scr_report_opts.js(인쇄 선택).
빼는 칸: 시트 이름표 열 3행부터(재등록 숨은 메타 글) · 호표 머리 메타(N열 HYPERLINK) ·
설계내역서 행 번호(원본은 인쇄 쪽 채움 빈 줄이 더 있어 행 번호만 다름 — 같은 시트 안 참조 번호를 # 로 맞댐).
"""
import json
import re
import subprocess
import sys
import warnings
from functools import lru_cache
from pathlib import Path
import openpyxl
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent))
from test_stmate_report_sheets import ( # noqa: E402
DOCS,
FULL,
HELPER,
PAIRS,
book,
norm,
original,
server,
)
ROOT = Path(__file__).resolve().parents[2]
SAME = re.compile(r"(?<![!A-Z$])([A-Z]{1,2})(\d+)")
# 시트 id → 수식 대조 열(원본 수식·숨은 열쇠 칸)
EXACT = {
"재료비목록표": "EH",
"일위대가목록표": "EFGHK",
"단가산출근거목록표": "EFGHK",
"중기목록표": "EFGHK",
"재료비수량금액집계표": "FGJ",
"노무비수량금액집계표": "FGJ",
"경비수량금액집계표": "FGJ",
"중기시간금액집계표": "FGHIL",
"일위대가표": ["E", "F", "G", "H", "I", "J", "K", "L", "O", "P", "Q"]
+ ["S", "T", "U", "V", "W", "X", "Y", "Z"],
"중기사용료": ["E", "F", "G", "H", "I", "J", "K", "L", "O", "P", "Q", "Z"],
}
@lru_cache(maxsize=None)
def formulas(name, sheet):
"""원본 시트 칸 → 수식 글(또는 숨은 열쇠 글)."""
warnings.filterwarnings("ignore")
wb = openpyxl.load_workbook(DOCS / PAIRS[name][1], read_only=True)
out = {}
for row in wb[sheet].iter_rows():
for c in row:
if c.value is not None:
out[(c.column_letter, c.row)] = c.value
return out
def mine_formulas(sh):
out = {}
for i, r in enumerate(sh["rows"], start=1):
for k, v in (r.get("_f") or {}).items():
out[(k, i)] = v.replace("{r}", str(i)) if isinstance(v, str) else v
return out
def tag_col(sh):
return next((k for k, v in sh["rows"][1].items() if v == sh["name"]), None)
@pytest.mark.parametrize("name", FULL)
@pytest.mark.parametrize("sid", list(EXACT))
def test_cross_sheet_formulas_match_original(name, sid):
"""목록 · 집계 · 일위대가표 · 중기사용료 — 참조 수식 · 끝수 배분 도움 열 · SUMIF 열쇠 · 바로가기 글 그대로."""
sh = book(name)[sid]
cols = set(EXACT[sid])
orig = formulas(name, sh["name"])
tag = tag_col(sh)
want = {
k: v
for k, v in orig.items()
if k[0] in cols
and k[1] > 2
and (isinstance(v, str) and (v.startswith("=") or k[0] in "OPQ"))
and "목차 →" not in v
}
got = {
k: v
for k, v in mine_formulas(sh).items()
if k[0] in cols and k[1] > 2 and "목차 →" not in str(v)
}
if tag:
want = {k: v for k, v in want.items() if k[0] != tag}
bad = [(f"{c}{r}", want.get((c, r)), got.get((c, r))) for (c, r) in set(want) | set(got)]
bad = [b for b in bad if b[1] != b[2]]
assert len(want) > 1, f"{name} {sid} 원본 수식 없음"
assert not bad, f"{name} {sid} 수식 불일치 {len(bad)}건 {sorted(bad)[:4]}"
def content_index(rows_get, n, start=3):
return [i for i in range(start, n) if any(norm(rows_get(i, c)) for c in "ABCDEFGHIJKLMN")]
@pytest.mark.parametrize("name", FULL)
def test_design_sheet_formula_patterns_match_original(name):
"""설계내역서 — 묶음 SUMIF · 품목 단가 참조 · ROUNDDOWN · 열쇠 P/Q/R · 바로가기(같은 시트 행 번호만 #)."""
sh = book(name)["설계내역서"]
vals = original(name, sh["name"])
orig = formulas(name, sh["name"])
mine = mine_formulas(sh)
oi = content_index(
lambda i, c: vals[i][ord(c) - 65] if ord(c) - 65 < len(vals[i]) else None, len(vals)
)
mi = content_index(lambda i, c: sh["rows"][i].get(c), len(sh["rows"]))
assert len(oi) == len(mi) > 50
nz = lambda v: SAME.sub(r"\1#", v) if isinstance(v, str) else v # noqa: E731
bad = []
for a, b in zip(oi, mi):
for c in ["F", "G", "H", "I", "J", "K", "L", "M", "P", "Q", "R", "AC"]:
ov = orig.get((c, a + 1))
ov = ov if isinstance(ov, str) and (ov.startswith("=") or c in "PQR") else None
if nz(ov) != nz(mine.get((c, b + 1))):
bad.append((f"{c}{a + 1}", ov, mine.get((c, b + 1))))
assert not bad, f"{name} 설계내역서 수식 {len(bad)}건 {bad[:3]}"
@pytest.mark.parametrize("name", FULL)
def test_summary_group_rows_point_to_design_groups(name):
"""총괄 공종 줄 · 순공사비 = 설계내역서 묶음 줄 금액 참조 — 가리키는 설계 행의 공종·명칭이 원본과 같음."""
bk = book(name)
sm, ds = bk["총괄내역서"], bk["설계내역서"]
orig = formulas(name, sm["name"])
dvals = original(name, ds["name"])
mine = mine_formulas(sm)
ref = re.compile(r"^=[^!]+!G(\d+)$")
checked = 0
for (c, r), v in orig.items():
if c != "D" or not isinstance(v, str) or not ref.match(v):
continue
orow = int(ref.match(v).group(1))
m = ref.match(mine.get(("D", r), "") or "")
assert m, f"{name} 총괄 D{r} 참조 없음"
mrow = int(m.group(1))
o_key = [norm(x) for x in dvals[orow - 1][:3]]
m_key = [norm(ds["rows"][mrow - 1].get(k)) for k in "ABC"]
if o_key == [None, None, None]: # 끝 줄(순공사비) — 둘 다 끝 줄
assert ds["rows"][mrow - 1].get("_k") == "final"
else:
assert o_key == m_key, f"{name} 총괄 D{r} {o_key} ≠ {m_key}"
checked += 1
assert checked > 3
def build(name, opts=None, mode=None):
stc, _, stage = PAIRS[name]
load = server.api_load(server.LoadReq(source_path=str(DOCS / stc)))
body = {"load": load, "stage": stage, "opts": opts or {}}
if mode:
body["mode"] = mode
out = subprocess.check_output(
["node", str(HELPER)],
input=json.dumps(body, ensure_ascii=False),
text=True,
encoding="utf-8",
)
return json.loads(out)
@lru_cache(maxsize=None)
def ordered_like_original(name):
"""원본 엑셀 시트 차례를 인쇄순서(F1-52)로 넘겨 짠 묶음."""
ids = {v["name"]: k for k, v in book(name).items()}
wb = openpyxl.load_workbook(DOCS / PAIRS[name][1], read_only=True)
order = [ids[n] for n in wb.sheetnames if n in ids and not n.startswith(("〓", "※"))]
return wb.sheetnames, build(name, {"order": order})
@pytest.mark.parametrize("name", FULL)
def test_print_order_and_toc_sheet_match_original(name):
"""인쇄순서 = 원본 시트 차례 그대로 · 목차 시트 B·D 글 · 바로가기 수식 꼴."""
names, bk = ordered_like_original(name)
assert [v["name"] for v in bk.values()] == names
toc = bk["〓 목 차 〓"]
orig = original(name, "〓 목 차 〓")
for i, o in enumerate(orig):
m = toc["rows"][i] if i < len(toc["rows"]) else {}
for j, c in enumerate("BD"):
ov = o[1 + j * 2] if 1 + j * 2 < len(o) else None
assert (ov or None) == (m.get(c) or None), f"{name} 목차 {c}{i + 1}"
of = formulas(name, "〓 목 차 〓")
mf = mine_formulas(toc)
for r in range(4, len(orig) + 1):
assert of.get(("D", r)) == mf.get(("D", r)), f"{name} 목차 D{r} 수식"
assert of.get(("D", 1)) == mf.get(("D", 1))
@pytest.mark.parametrize("name", FULL)
def test_guide_sheet_matches_original(name):
"""안내 시트 — 판·산근 수식 갈래 글 · 알림 줄(% 단위 · 이윤보정액 · 총공사비 확인) · 설계내역서 줄 번호만 #."""
_, bk = ordered_like_original(name)
g = bk["※※안내※※"]["rows"]
orig = original(name, "※※안내※※")
nz = lambda v: re.sub(r"【설계내역서】\d+", "【설계내역서】#", v) if isinstance(v, str) else v # noqa: E731
bad = []
for i in range(max(len(orig), len(g))):
o = orig[i] if i < len(orig) else []
m = g[i] if i < len(g) else {}
for j, c in enumerate("ABC"):
ov = o[j] if j < len(o) else None
if nz(ov or None) != nz(m.get(c) or None):
bad.append((f"{c}{i + 1}", ov, m.get(c)))
assert not bad, f"{name} 안내 불일치 {bad[:3]}"
def test_amount_to_hangul_on_every_original_sample():
"""AMTTOHAN — 원본 엑셀이 보인 한글 금액 전부(NUMBERSTRING(n,1) 꼴): 영덕 금액대비표 2 · 대흥 2공구 속표지 3 · 울진 속표지 6."""
warnings.filterwarnings("ignore")
samples = []
yd = PAIRS["영덕변경"][1]
ws = openpyxl.load_workbook(DOCS / yd, data_only=True)["설계변경금액대비표"]
for r in (7, 8):
samples.append((ws[f"F{r}"].value, ws[f"C{r}"].value, False))
for f, sn in [
("2024년 간선임도(기번3-울진.대흥)(2공구)/수량산출/4.2 수량산출(2공구).xlsx", "속표지"),
(
"25년 산불진화임도(기번8)(울진소광)/1. 설계원본/03-내역/01-개요-2025년 산불진화임도사업(울진 금강송 소광).xlsx",
"속표지(남부)",
),
(
"25년 산불진화임도(기번8)(울진소광)/1. 설계원본/03-내역/01-개요-2025년 산불진화임도사업(울진 금강송 소광).xlsx",
"속표지(울진)",
),
]:
ws = openpyxl.load_workbook(DOCS / f, data_only=True)[sn]
for r in (9, 10, 11):
samples.append((ws[f"E{r}"].value, ws[f"F{r}"].value, True))
js = (
"const I=require('./tools/stmate-test/report_init.js');"
f"const s={json.dumps([[a, b] for a, _, b in samples])};"
"process.stdout.write(JSON.stringify(s.map(([v,b])=>I.amtToHan(v,{bare:b}))))"
)
got = json.loads(subprocess.check_output(["node", "-e", js], cwd=ROOT, encoding="utf-8"))
want = [t if not bare else t[2:-2] for _, t, bare in samples] # 「(금…원)」 → 금·원 걷은 본문
assert len(samples) == 11 and got == want
def test_print_options_change_generated_report():
"""인쇄 선택(F1-51~53) — 코드필드 · 비고 · 번호 자리 · 집계수준 · 순서 · 글꼴 · 방향이 묶음·엑셀을 실제로 바꿈."""
from io import BytesIO
from fastapi.testclient import TestClient
base = build("영월2024", {}, "ui")
alt = build(
"영월2024",
{"codeField": "1", "remark": "1", "padNo": "1", "level": 3, "font": "돋움체", "size": 9}
| {"orient": "portrait", "formulas": True},
"ui",
)
two = build("영월2024", {"order": ["설계내역서", "총괄내역서"]}, "ui")
by = lambda b: {s["id"]: s for s in b["sheets"]} # noqa: E731
b0, b1 = by(base), by(alt)
# 코드필드 — 영월 기본(SH_TYP1=0) 코드 · 호표면 번호(자리 맞춤 4칸)
assert b0["재료비목록표"]["rows"][3]["A"].startswith("M")
assert b1["재료비목록표"]["rows"][3]["A"] == " 1"
# 비고 — 기본(SH_TYP3=2) 가격지 위치 · 호표(1) 면 호표 번호
assert b0["노무비목록표"]["rows"][3]["F"] != b1["노무비목록표"]["rows"][3]["F"] == " 1"
# 집계수준 — 기본 FLT_JAB=1 → 계(+==) 이상 · 3 이면 T2 줄 빠짐
k0 = [r["_k"] for r in b0["총괄내역서"]["rows"] if r.get("_k", "").startswith("T")]
k1 = [r["_k"] for r in b1["총괄내역서"]["rows"] if r.get("_k", "").startswith("T")]
assert "T2" in k0 and "T2" not in k1 and len(k1) < len(k0)
# 순서 — 고른 두 시트만 그 차례로(목차·안내·INITIAL 은 앞뒤 고정)
assert [s["id"] for s in two["sheets"]][2:] == ["설계내역서", "총괄내역서", "〓 INITIAL 〓"]
assert two["sheets"][0]["rows"][4]["B"] == "2. 설계내역서"
# 엑셀 — 글꼴·크기·방향·수식
client = TestClient(server.app)
body = {"sheets": alt["sheets"], "filename": "o.xlsx", "formulas": alt["formulas"]}
res = client.post("/api/report/xlsx", json=body | {"print": alt["print"]})
wb = openpyxl.load_workbook(BytesIO(res.content))
ws = wb[b1["설계내역서"]["name"]]
assert ws["B5"].font.name == "돋움체" and ws["B5"].font.sz == 9
assert ws.page_setup.orientation == "portrait"
assert ws["G5"].value.startswith("=SUMIF(")
assert ws.column_dimensions[tag_col(b1["설계내역서"])].hidden
toc = wb["〓 목 차 〓"]
assert toc["D5"].value.startswith('=HYPERLINK("#"&공사원가계산서!G2')
res0 = client.post("/api/report/xlsx", json={"sheets": base["sheets"], "filename": "v.xlsx"})
ws0 = openpyxl.load_workbook(BytesIO(res0.content))[b0["설계내역서"]["name"]]
assert isinstance(ws0["G5"].value, (int, float)) and ws0["AC4"].hyperlink is not None
def test_stage_selection_switches_change_forms():
"""인쇄영역 차수(F1-53) — 영덕 1차수 = 단일 원가계산서 · 2차수 = 당초·변경·증감 양식(원본 엑셀 대조는 report_sheets)."""
one = {s["id"]: s for s in build("영덕변경", {}, "ui")["sheets"]}
stc, _, _ = PAIRS["영덕변경"]
load = server.api_load(server.LoadReq(source_path=str(DOCS / stc)))
body = {"load": load, "stage": 1, "opts": {}, "mode": "ui"}
out = subprocess.check_output(
["node", str(HELPER)],
input=json.dumps(body, ensure_ascii=False),
text=True,
encoding="utf-8",
)
first = {s["id"]: s for s in json.loads(out)["sheets"]}
assert one["공사원가계산서"]["rows"][3].get("D") == "당 초"
assert first["공사원가계산서"]["rows"][3].get("D") is None
def test_change_summary_two_line_form_matches_original():
"""총괄내역서 변경 양식(영덕 2차수) — 줄마다 당초(이름 칸) · 변경 두 줄 · 원가 줄 설명 · 마. 도급공사비로 끝(관급 없음)."""
mine = book("영덕변경")["총괄내역서"]
orig = original("영덕변경", mine["name"])
clean = lambda v: v.strip() if isinstance(v, str) else v # noqa: E731
bad = []
for i in range(2, 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 j, c in enumerate("ABCDEFG"):
ov = clean(norm(o[j])) if j < len(o) else None
if ov != clean(norm(m.get(c))):
bad.append((f"{c}{i + 1}", ov, m.get(c)))
assert len(orig) > 70 and not bad, f"영덕 변경 총괄 {bad[:3]}"
def test_gov_sheet_lists_excluded_zone_and_totals():
"""사·관급내역서(0607 · 견본 없음 부분) — 제외금액 구역 줄만 · 끝 계 = 설계내역서 제외 묶음 금액 합."""
from test_stmate_report_coll import _forms
f = _forms("영월2024", {})
g = f["관급내역서"]["rows"]
ds = book("영월2024")["설계내역서"]["rows"]
top = [r for r in ds if r.get("N") == "(제외금액)"]
assert top and g[-1]["_k"] == "final"
assert g[-1]["G"] == sum(r["G"] for r in top)
items = [r for r in g if r.get("_k") == "item"]
assert items and all(r.get("_code") for r in items)