Files
Aislo/resources/tester/test_stmate_report_cost_fmla.py
T
eomsangdonandClaude Opus 5.5 01ed06f5f6 feat(stmate-test): 총괄내역서 원가 줄·공사원가계산서 엑셀 수식(F5-31) · 화면 파일 캐시 확인(no-cache) · 장부 95.9%
- report_cost_fmla.js — 원가 줄 D·E 수식 · H 숨은 메타 · I~P 보정·요율 칸 · 이윤 끝전 사슬 · 공사원가계산서 참조·소계·구성비·바로가기(변경 양식 포함)
- 원본 5건(봉화·영월·대흥·울진 착공·영덕 변경) 1392칸 수식 글·값 일치 · 24·25판 괄호 구분(Ghidra 0x494b53)
- server.py — /api 밖 응답 Cache-Control: no-cache(고친 뒤 옛 js 가 섞여 「require is not defined」 나던 문제)
- 브라우저 확인(봉화2024): 전체 엑셀 내보내기에 원가 줄 수식 포함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 16:46:31 +09:00

265 lines
9.4 KiB
Python

"""STMATE 재현 앱 보고서 F5-31 — 총괄내역서 원가 줄 · 공사원가계산서 엑셀 수식을 원본 STMATE 엑셀과 대조.
정답 = 실무 STC 옆 원본 출력 엑셀(.xlsx) — 수식 글(openpyxl 수식 읽기) · 셈한 값(data_only) 그대로.
앱 쪽 = report_cost_fmla.js(원가 줄 D·E 식 · H 숨은 메타 · I~P 보정·요율 칸 · 원가계산서 참조·비고·바로가기).
값 = 앱 수식을 작은 엑셀 셈틀로 다시 셈(다른 시트 참조 칸 = 앱 모형 값) → 원본 셈 값과 맞댐.
대조 범위: 총괄 가. 줄 아래 C(글)·D 값 칸·E 글 칸 뺀 수식·수 칸 / 원가계산서 5행부터 수식 칸.
"""
import re
import sys
import warnings
from decimal import ROUND_DOWN, ROUND_HALF_UP, ROUND_UP, Decimal
from functools import lru_cache
from io import BytesIO
from pathlib import Path
import openpyxl
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent))
from test_stmate_report_link import formulas, mine_formulas # noqa: E402
from test_stmate_report_sheets import DOCS, PAIRS, book, server # noqa: E402
SIDS = ("총괄내역서", "공사원가계산서")
def _first(sh, sid, name):
"""대조 시작 행 — 총괄 = 가. 줄(변경 양식은 두 줄) 다음 · 원가계산서 = 5행."""
if sid == "공사원가계산서":
return 5
ga = next(i for i, r in enumerate(sh["rows"], 1) if r.get("_c") == "GA")
return ga + (2 if PAIRS[name][2] > 1 else 1)
@lru_cache(maxsize=None)
def pairs(name, sid):
"""(원본 수식·수 칸, 앱 수식·수 칸) — 같은 범위."""
sh = book(name)[sid]
lo = _first(sh, sid, name)
orig = formulas(name, sh["name"])
cols = "DEFGHIJKLMNOP" if sid == "총괄내역서" else "DEFGHIJ"
def keep(k, v):
if k[1] < lo or k[0] not in cols:
return False
if sid == "공사원가계산서" or k[0] in "DE":
return isinstance(v, str) and v.startswith("=")
return True
want = {k: v for k, v in orig.items() if keep(k, v)}
got = {k: v for k, v in mine_formulas(sh).items() if k[1] >= lo and k[0] in cols}
return want, got
@pytest.mark.parametrize("name", list(PAIRS))
@pytest.mark.parametrize("sid", SIDS)
def test_cost_formulas_match_original(name, sid):
"""원가 줄 수식 글 · 숨은 메타 · 보정·요율 칸 · 원가계산서 참조·비고·바로가기 — 원본 글 그대로."""
want, got = pairs(name, sid)
bad = [(f"{c}{r}", want.get((c, r)), got.get((c, r))) for (c, r) in set(want) | set(got)]
bad = sorted(b for b in bad if b[1] != b[2])
assert len(want) > 60, f"{name} {sid} 원본 수식 없음"
assert not bad, f"{name} {sid} 수식 불일치 {len(bad)}건 {bad[:4]}"
# ── 작은 엑셀 셈틀 — 원가 줄 수식에 나오는 꼴만(ROUNDDOWN·ROUNDUP·ROUND·ABS·IF·TEXT·& · 비교) ──
TOK = re.compile(
r'\s*(?:(?P<s>"(?:[^"]|"")*")|(?P<ref>(?:(?:\'[^\']+\'|[^\W\d][\w]*)!)?\$?[A-Z]{1,2}\$?\d+)'
r"|(?P<n>\d+(?:\.\d+)?)|(?P<fn>[A-Z]+)\(|(?P<op><>|>=|<=|[-+*/&(),<>=]))"
)
def _num(v):
return 0 if v in (None, "") else v
def _text(v):
if isinstance(v, str):
return v
v = _num(v)
return str(int(v)) if float(v).is_integer() else ("%.15g" % v)
def _rnd(x, d, mode):
q = Decimal(1).scaleb(-int(d))
return float(Decimal(format(_num(x), ".15g")).quantize(q, rounding=mode))
FUNCS = {
"ROUNDDOWN": lambda x, d: _rnd(x, d, ROUND_DOWN),
"ROUNDUP": lambda x, d: _rnd(x, d, ROUND_UP),
"ROUND": lambda x, d: _rnd(x, d, ROUND_HALF_UP),
"ABS": lambda x: abs(_num(x)),
"IF": lambda c, a, b: a if c else b,
"TEXT": lambda v, f: f"{round(_num(v)):,}",
}
class Book:
"""시트 모형 묶음 셈 — live {시트: 첫 행} 아래 _f 수식만 셈 · 그 밖(공종 줄 · 다른 시트)은 모형 값."""
def __init__(self, sheets, live):
self.by = {s["name"]: s for s in sheets.values()}
self.live = dict(live)
self.memo = {}
def cell(self, sheet, col, row):
key = (sheet, col, row)
if key in self.memo:
return self.memo[key]
rows = self.by[sheet]["rows"]
r = rows[row - 1] if row <= len(rows) else {}
f = (r.get("_f") or {}).get(col) if row >= self.live.get(sheet, 10**9) else None
if isinstance(f, str) and f.startswith("="):
v = self.eval(f[1:], sheet)
elif f is not None and not isinstance(f, str):
v = f
else:
v = r.get(col)
self.memo[key] = v
return v
def eval(self, text, sheet):
toks = [m for m in TOK.finditer(text)]
self.t, self.i, self.sheet = toks, 0, sheet
return self.cmp()
def peek(self, kind=None, val=None):
if self.i >= len(self.t):
return None
m = self.t[self.i]
if kind and m.group(kind) is None:
return None
if val is not None and m.group("op") != val:
return None
return m
def take(self):
self.i += 1
return self.t[self.i - 1]
def cmp(self):
a = self.cat()
while self.peek("op") and self.peek().group("op") in (">", "<", "=", "<>"):
op = self.take().group("op")
b = self.cat()
a = {">": a > b, "<": a < b, "=": a == b, "<>": a != b}[op]
return a
def cat(self):
a = self.add()
while self.peek(val="&"):
self.take()
a = _text(a) + _text(self.add())
return a
def add(self):
a = self.mul()
while self.peek("op") and self.peek().group("op") in "+-":
op = self.take().group("op")
b = self.mul()
a = _num(a) + _num(b) if op == "+" else _num(a) - _num(b)
return a
def mul(self):
a = self.unary()
while self.peek("op") and self.peek().group("op") in "*/":
op = self.take().group("op")
b = self.unary()
a = _num(a) * _num(b) if op == "*" else _num(a) / _num(b)
return a
def unary(self):
if self.peek("op") and self.peek().group("op") in "+-":
op = self.take().group("op")
v = _num(self.unary())
return -v if op == "-" else v
return self.prim()
def prim(self):
m = self.take()
if m.group("s"):
return m.group("s")[1:-1].replace('""', '"')
if m.group("n"):
return float(m.group("n"))
if m.group("ref"):
ref = m.group("ref").replace("$", "")
sh, _, at = ref.rpartition("!")
sh = sh.strip("'") if sh else self.sheet
c, r = re.match(r"([A-Z]+)(\d+)", at).groups()
state = (self.t, self.i, self.sheet)
v = self.cell(sh, c, int(r))
self.t, self.i, self.sheet = state
return v
if m.group("fn"):
args = []
while not self.peek(val=")"):
args.append(self.cmp())
if self.peek(val=","):
self.take()
self.take()
return FUNCS[m.group("fn")](*args)
if m.group("op") == "(":
v = self.cmp()
self.take()
return v
raise ValueError(m.group(0))
@lru_cache(maxsize=None)
def original_values(name, sheet):
warnings.filterwarnings("ignore")
wb = openpyxl.load_workbook(DOCS / PAIRS[name][1], data_only=True, read_only=True)
return {
(c.column_letter, c.row): c.value
for row in wb[sheet].iter_rows()
for c in row
if c.value is not None
}
@pytest.mark.parametrize("name", list(PAIRS))
def test_cost_formula_values_match_original(name):
"""앱 수식을 다시 셈한 값 = 원본 엑셀 셈 값(절사·끝전 보정 포함) · 총괄 D = 앱 계산 엔진 금액."""
bk = book(name)
live = {bk[s]["name"]: _first(bk[s], s, name) for s in SIDS}
ev = Book(bk, live)
bad, n = [], 0
for sid in SIDS:
sh = bk[sid]
want, got = pairs(name, sid)
ov = original_values(name, sh["name"])
for (c, r), f in got.items():
if not (isinstance(f, str) and f.startswith("=")) or "HYPERLINK" in f:
continue
v, o = ev.cell(sh["name"], c, r), ov.get((c, r))
n += 1
same = abs(v - o) <= 1e-9 * max(1, abs(o)) if isinstance(o, (int, float)) else v == o
if not same:
bad.append((sid, f"{c}{r}", o, v))
if c == "D" and sid == "총괄내역서" and v != sh["rows"][r - 1].get("D"):
bad.append((sid, f"{c}{r} 엔진", sh["rows"][r - 1].get("D"), v))
assert n > 80 and not bad, f"{name} 값 불일치 {len(bad)}/{n} {bad[:4]}"
def test_xlsx_writes_cost_formulas_meta_and_numbers():
"""엑셀 쓰기(수식 모드) — 총괄 원가 줄 D 식 · H 숨은 메타 글 · I~P 수 칸 · 원가계산서 참조 식이 원본 칸 그대로."""
from fastapi.testclient import TestClient
name = "봉화2024"
bk = book(name)
body = {"sheets": list(bk.values()), "filename": "c.xlsx", "formulas": True}
res = TestClient(server.app).post("/api/report/xlsx", json=body)
warnings.filterwarnings("ignore")
wb = openpyxl.load_workbook(BytesIO(res.content))
checked = 0
for sid in SIDS:
sh = bk[sid]
want, _ = pairs(name, sid)
ws = wb[sh["name"]]
for (c, r), v in want.items():
assert ws[f"{c}{r}"].value == v, f"{sid} {c}{r}"
checked += 1
assert checked > 200