- 보고서: 원본 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>
294 lines
11 KiB
Python
294 lines
11 KiB
Python
"""STMATE 재현 앱 단가산출근거 식 언어 — 함수·자리 처리·계표시·손료계산(금액변수·누계번지)·수량변수·문법 검사.
|
|
|
|
정답 = 원본 편집기 문법 안내문(REA 30 TWM_EDIT_SAN Memo_Use)의 예와 결과 설명 · 실무 STC 7건의 산근 원문.
|
|
글 안의 수치를 읽어 대조(코드에 박지 않음) · 단가표·수량코드는 예마다 만든 것.
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
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_qty_run.cjs")
|
|
DFM = (
|
|
ROOT / "resources/knowledge/original/경쟁사 프로그램/STmate/REA 분석/"
|
|
"30_수량문자열_수신편집기_작업자료_20261026/ST_EdSan/resource-TWM_EDIT_SAN.dfm.json"
|
|
)
|
|
DOCS = ROOT / "resources/knowledge/original/실무문서"
|
|
STC = sorted(DOCS.rglob("*.STC"))
|
|
|
|
|
|
def call(*calls):
|
|
out = subprocess.check_output(
|
|
["node", str(HELPER)],
|
|
input=json.dumps({"calls": [{"fn": f, "args": a} for f, *a in calls]}, ensure_ascii=False),
|
|
text=True,
|
|
encoding="utf-8",
|
|
)
|
|
res = json.loads(out)
|
|
for r in res:
|
|
assert not (isinstance(r, dict) and "__error" in r), r
|
|
return res
|
|
|
|
|
|
def memo_use() -> list[str]:
|
|
nodes = json.loads(DFM.read_text(encoding="utf-8"))["nodes"]
|
|
node = next(n for n in nodes if n.get("name") == "Memo_Use")
|
|
return node["properties"]["Lines.Strings"]
|
|
|
|
|
|
def test_rounding_examples_from_grammar_text():
|
|
"""(3) A=123.784, B=873.496 · C=A/B=0.14 · D=round(A/B,4)=0.1417 · E=A/B=[?:4R] · F=[?:2U] · H=int · I=[?:0C]."""
|
|
text = "\n".join(memo_use())
|
|
a = re.search(r"A=([\d.]+),\s*B=([\d.]+)", text)
|
|
rows = re.findall(r"\b([C-I])=([A-Za-z()/,\d ]+?)=(\[\?:\d[RUC]\]|[\d.]+)", text)
|
|
paren = dict(
|
|
m.groups()
|
|
for ln in memo_use()
|
|
if (m := re.search(r"\(([EFI])\).*\[\?:\d[RUC]\].*\(([\d.]+)\)\s*$", ln))
|
|
)
|
|
assert a and {k for k, *_ in rows} >= set("CDEFHI") and set(paren) == set("EFI"), (rows, paren)
|
|
lines = [f"A={a[1]}, B={a[2]}"] + [
|
|
f"{k}={e}=" + (t if t.startswith("[") else "") for k, e, t in rows
|
|
]
|
|
(ev,) = call(("evaluate", "\n".join(lines), {}, {"assignDigits": 2}))
|
|
for k, _e, t in rows:
|
|
want = float(paren[k] if t.startswith("[") else t)
|
|
assert abs(ev["vars"][k] - want) < 1e-9, (k, ev["vars"][k], want)
|
|
|
|
|
|
def test_functions_and_left_to_right():
|
|
"""함수 목록(cBoxFunc) 꼴 그대로 평가 · 곱·나눗셈 왼쪽부터 · RXXNM 18 §2.1."""
|
|
nodes = json.loads(DFM.read_text(encoding="utf-8"))["nodes"]
|
|
items = next(n for n in nodes if n.get("name") == "cBoxFunc")["properties"]["Items.Strings"]
|
|
funcs = [s.strip() for s in items if "(" in s]
|
|
res = call(*[("expr", f, {}) for f in funcs])
|
|
for f, r in zip(funcs, res):
|
|
assert r["error"] is None and r["value"] is not None, (f, r)
|
|
by = dict(zip(funcs, (r["value"] for r in res)))
|
|
import math
|
|
|
|
assert by["IIF(1>0, 2, 3)"] == 2 and by["LOG(10,1)"] == 0 and by["SQRT(1)"] == math.sqrt(1)
|
|
daily = 267360
|
|
(r,) = call(("expr", f"{daily}*1/8*16/12*25/20", {}))
|
|
step = daily
|
|
for op, n in [("/", 8), ("*", 16), ("/", 12), ("*", 25), ("/", 20)]:
|
|
step = step / n if op == "/" else step * n
|
|
assert r["value"] == step
|
|
|
|
|
|
def test_sonryo_amount_and_total_variables():
|
|
"""(9) 손료계산 ①~⑧ — 금액번지 A01(①+②) · 제외(->) · 누계번지 T01(①+③) · PA01 · PT01 · PC_ · PS_."""
|
|
lines = [ln for ln in memo_use() if re.match(r"\s*[①-⑧]\s*(\[|\(=|&)", ln)][:8]
|
|
body = [re.sub(r"^\s*[①-⑧]\s*", "", ln).split(" or ")[0].split("→")[0].strip() for ln in lines]
|
|
prices = {
|
|
"L00084": {"M": 0, "L": 150000, "S": 0, "unit": "인"},
|
|
"L00087": {"M": 0, "L": 120000, "S": 0, "unit": "인"},
|
|
"L00088": {"M": 0, "L": 90000, "S": 0, "unit": "인"},
|
|
}
|
|
text = "\n".join(b.replace("&L84&", "&L84&").replace("&PS_:M&", "&PS_:M&") for b in body)
|
|
(ev,) = call(("evaluate", text, prices, {}))
|
|
amt = [ln for ln in ev["lines"] if ln["kind"] == "amount"]
|
|
tot = lambda ln: ln["amounts"]["M"] + ln["amounts"]["L"] + ln["amounts"]["S"] # noqa: E731
|
|
one, two, three, five, six, seven, eight = amt
|
|
assert ev["errors"] == []
|
|
assert two["excluded"] and not one["excluded"]
|
|
assert ev["labels"]["A01"]["L"] == pytest.approx(one["amounts"]["L"] + two["amounts"]["L"])
|
|
assert ev["labels"]["T01"]["L"] == pytest.approx(one["amounts"]["L"] + three["amounts"]["L"])
|
|
trunc1 = lambda v: int(v * 10 + 1e-9) / 10 # noqa: E731
|
|
assert five["amounts"]["S"] == pytest.approx(trunc1(ev["labels"]["A01"]["L"] * 0.1))
|
|
assert six["amounts"]["S"] == pytest.approx(trunc1(ev["labels"]["T01"]["L"] * 0.1))
|
|
assert seven["amounts"]["M"] == pytest.approx(
|
|
trunc1((five["amounts"]["S"] + six["amounts"]["S"]) * 0.1)
|
|
)
|
|
allsum = tot(one) + tot(three) + tot(five) + tot(six) + tot(seven)
|
|
assert eight["amounts"]["M"] == pytest.approx(trunc1(allsum * 0.1))
|
|
assert ev["T"] == int(allsum + tot(eight))
|
|
|
|
|
|
def test_section_markers_exclusion_and_grammar_errors():
|
|
prices = {
|
|
"M00001": {"M": 1000, "L": 0, "S": 0, "unit": "M2"},
|
|
"L00001": {"M": 0, "L": 2000, "S": 0, "unit": "인"},
|
|
}
|
|
text = "\n".join(
|
|
["&M00001& * 2 =", "(=)", "&L00001& * 1 =", "(-=)", "&M00001& * 3 =", "(====)", "(=====)"]
|
|
)
|
|
(ev,) = call(("evaluate", text, prices, {}))
|
|
assert (ev["M"], ev["L"]) == (5000, 0) and ev["errors"] == []
|
|
bad = "\n".join(
|
|
[
|
|
"&M00001& + &L00001& =",
|
|
"&M00001& ^ 2 =",
|
|
"x = y + 1",
|
|
"'열린 따옴표",
|
|
"&Z99999& * 1 =",
|
|
"(======)",
|
|
"~Q1.QTY1 * 2",
|
|
"&M00001& * IMG(I00001) IMG(I00002)",
|
|
]
|
|
)
|
|
(ev,) = call(("evaluate", bad, prices, {"selfCode": "D00001"}))
|
|
lines = {e["lineNo"] for e in ev["errors"]}
|
|
assert lines == set(range(1, 9)), ev["errors"]
|
|
(ev,) = call(
|
|
(
|
|
"evaluate",
|
|
"&D00001& * 1 =",
|
|
{"D00001": {"M": 1, "L": 0, "S": 0, "unit": "M3"}},
|
|
{"selfCode": "D00001"},
|
|
)
|
|
)
|
|
assert any("순환" in e["msg"] for e in ev["errors"])
|
|
|
|
|
|
def test_quantity_variables_and_code_normalization():
|
|
"""~Q1.QTY1~ → Q00001 · QTY0~9 직접 · 그 밖 XCODE 「이름=값」 · 빈 값 0 · 없는 이름 오류(REA 30)."""
|
|
q = [{"CODE": "Q00001", "QTY1": "12.5", "XCODE": "절토량=120.5\r\n빈칸=\r\n"}]
|
|
prices = {"L00001": {"M": 0, "L": 1000, "S": 0, "unit": "인"}}
|
|
text = "\n".join(
|
|
[
|
|
"A = ~Q1.QTY1~",
|
|
"B = ~Q00001.절토량~",
|
|
"C = ~q01.빈칸~",
|
|
"&L00001& * A =",
|
|
"D = ~Q00001.없음~",
|
|
"E = ~Q00002.QTY1~",
|
|
]
|
|
)
|
|
(ev,) = call(("evaluate", text, prices, {"qcode": q}))
|
|
assert ev["vars"]["A"] == 12.5 and ev["vars"]["B"] == 120.5 and ev["vars"]["C"] == 0
|
|
assert ev["L"] == 12500
|
|
assert {e["lineNo"] for e in ev["errors"]} == {5, 6}
|
|
n = call(
|
|
("normQCode", "Q1"),
|
|
("normQCode", "q000001"),
|
|
("normQCode", "Q1234567"),
|
|
("normQCode", "X1"),
|
|
)
|
|
assert n == ["Q00001", "Q00001", None, None]
|
|
|
|
|
|
def test_code_insert_lines_zero_skip_and_result_strip():
|
|
row = {
|
|
"NAME": "굴삭기",
|
|
"SIZE": "0.7㎥",
|
|
"UNIT": "HR",
|
|
"MAMT1": "7701",
|
|
"LAMT1": "55700",
|
|
"SAMT1": "0",
|
|
}
|
|
prices = {"X00001": {"M": 7701, "L": 55700, "S": 0, "unit": "HR"}}
|
|
full, skip, gen = call(
|
|
("codeLines", "X00001", row, {"op": "/", "qtyVar": "Q"}),
|
|
("codeLines", "X00001", row, {"op": "*", "qtyVar": "T", "skipZero": True}),
|
|
(
|
|
"codeLines",
|
|
"M00009",
|
|
{"NAME": "합판", "SIZE": "12mm", "UNIT": "M2"},
|
|
{"trans": 3, "qty": 2},
|
|
),
|
|
)
|
|
assert len(full) == 3 and len(skip) == 2 and all("* T =" in s for s in skip)
|
|
assert "'합판 12mm'" in gen[0] and "'M2'" in gen[0]
|
|
(ev,) = call(("evaluate", "Q = 15.71\n" + "\n".join(full), prices, {}))
|
|
assert ev["L"] == int(55700 / 15.71 * 10) / 10 // 1 or ev["L"] == int(
|
|
int(55700 / 15.71 * 10) / 10
|
|
)
|
|
marks = call(*[("marker", lv, ex) for lv in range(1, 6) for ex in (False, True)])
|
|
stripped = call(
|
|
(
|
|
"stripResults",
|
|
"\n".join(
|
|
[
|
|
"소계",
|
|
"총합계(제외금액)",
|
|
"&M00001& * 2 = 2,000 '원'",
|
|
"&M00001& * 2 = 2,000",
|
|
"그냥 글",
|
|
]
|
|
),
|
|
)
|
|
)[0].split("\r\n")
|
|
assert stripped[0] == marks[0] and stripped[1] == marks[9]
|
|
assert (
|
|
stripped[2] == "&M00001& * 2 ='원'"
|
|
and stripped[3] == "&M00001& * 2 ="
|
|
and stripped[4] == "'그냥 글'"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("path", STC, ids=lambda p: p.stem[:20])
|
|
def test_real_memos_check_clean_and_strip_keeps_amounts(path):
|
|
"""실무 산근 원문 — 문법 오류 0 · 금액결과 삭제 뒤에도 금액 그대로 · 참조 그래프 순환 없음."""
|
|
d = server.api_load(server.LoadReq(source_path=str(path)))
|
|
load = {
|
|
k: d[k]
|
|
for k in ("costn_rows", "costn_memos", "start_ini", "bdqty_rows", "boq_rows", "sim_state")
|
|
}
|
|
smt, cyc = call(("smtAll", load), ("cascadeCycles", load))
|
|
errs = {c: v["errors"] for c, v in smt.items() if v["errors"]}
|
|
assert not errs, list(errs.items())[:3]
|
|
moved = {c: (v["sum"], v["stripped"]) for c, v in smt.items() if v["sum"] != v["stripped"]}
|
|
assert not moved, list(moved.items())[:3]
|
|
assert cyc == []
|
|
|
|
|
|
def test_cycles_composition_devd_rr_and_result_options():
|
|
(cyc,) = call(
|
|
(
|
|
"cycles",
|
|
{
|
|
"B00001": ["B00002"],
|
|
"B00002": ["D00001"],
|
|
"D00001": ["B00001"],
|
|
"B00003": ["L00001"],
|
|
},
|
|
)
|
|
)
|
|
assert len(cyc) == 1 and set(cyc[0]) == {"B00001", "B00002", "D00001"}
|
|
table = {
|
|
"L00001": {"M": 0, "L": 100000, "S": 0, "unit": "인"},
|
|
"M00001": {"M": 3000, "L": 0, "S": 0, "unit": "M3"},
|
|
}
|
|
rows = [
|
|
{"CODE": "L00001", "QTY1": "4", "DEVD1": "T", "PRPT": "1"},
|
|
{"CODE": "M00001", "QTY1": "2", "DEVD1": "F", "PRPT": "0"},
|
|
{"CODE": "RR", "QTY1": "0.03", "PRIN": "2", "PROUT": "1"},
|
|
{"CODE": "PR", "QTY1": "10", "PRIN": "2", "PROUT": "3"},
|
|
]
|
|
ev, adj, dv1, dv2, dv0 = call(
|
|
("composition", rows, table, {}),
|
|
("composition", rows, table, {"adj": {"M": 1, "L": 1.15, "S": 1}, "sbmok": "3"}),
|
|
("devd", 4, "인"),
|
|
("devd", 3, "%"),
|
|
("devd", 0, "인"),
|
|
)
|
|
lab = 100000 / 4
|
|
assert (ev["L"], ev["M"], ev["S"]) == (int(lab), int(6000 + lab * 0.03), int(lab * 0.1))
|
|
assert (
|
|
adj["M"] == 0
|
|
and adj["L"] == 0
|
|
and adj["S"] == ev["M"] + int(ev["L"] * 1.15 + 1e-9) + ev["S"]
|
|
)
|
|
assert dv1 == 0.25 and dv2 == round(100 / 3, 7) and dv0 == 0
|
|
|
|
|
|
def test_labor_rx_branches():
|
|
ini = {
|
|
"CONLD": "L00008L00009",
|
|
"CONTD": "L00030",
|
|
"RXXNM": "1/8*16/12*25/20",
|
|
"RXTNM": "1/8*16/12*25/20*1.2",
|
|
}
|
|
each, forced = call(("laborMap", ini, 0, ""), ("laborMap", ini, 2, ""))
|
|
assert each == {"L00008": ini["RXXNM"], "L00009": ini["RXXNM"], "L00030": ini["RXTNM"]}
|
|
assert set(forced.values()) == {ini["RXTNM"]}
|