- 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>
265 lines
9.4 KiB
Python
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
|