Files
Aislo/resources/tester/test_stmate_qty_sheet.py
T
eomsangdonandClaude Opus 5.5 3d8fddfc34 feat(stmate-test): 보고서·제잡비·식언어·수량산출·화면 묶음 합침 · 재계산 후 저장 무변경
- 보고서: 원본 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>
2026-11-13 19:12:04 +09:00

250 lines
8.7 KiB
Python

"""STMATE 재현 앱 수량산출서 — QCODE 읽기·쓰기 · 시트 셈(셀별명) · 기본/확장 수량변수 · 내역 가져가기 ·
코드 종류별 수량 · 내역↔BDQTY 수량 연결 · 수량코드 검사 · 자재 후보단가 채택.
정답 = 실무 STC 7건(QCODE 필드 정의 · 내역 수량 · BDQTY 구성 · 가격지 후보) 에서 따로 셈한 값 · 규칙 문서(REA 28 · 30).
"""
import json
import subprocess
import sys
from collections import defaultdict
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "tools/stmate-test"))
import api_qty # noqa: E402
import qty_qcode # noqa: E402
import server # noqa: E402
HELPER = Path(__file__).with_name("helper_stmate_qty_run.cjs")
STC = sorted((ROOT / "resources/knowledge/original/실무문서").rglob("*.STC"))
STRUCT = ("T", "C", "E")
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
@pytest.fixture(scope="module")
def loads():
return {p: server.api_load(server.LoadReq(source_path=str(p))) for p in STC}
def test_qcode_read_write_round_trip(tmp_path, monkeypatch):
files = server.read_project_files(str(STC[0]))
got = qty_qcode.read_qcode(files)
names = [f["name"] for f in got["fields"]]
assert (
got["present"]
and names[:3] == ["CODE", "NAME", "BQCODE"]
and names[-2:] == ["IFORM", "XCODE"]
)
rows = [
{
"CODE": "Q00001",
"NAME": "절토",
"BQCODE": "B00001",
"QTY0": "12.5",
"QTY3": "7",
"IFORM": "QTY1=절토량*2",
"XCODE": "절토량=120.5\r\n성토량=80",
},
{"CODE": "Q00002", "NAME": "빈 줄", "QTY1": "0"},
]
dbf, smt = qty_qcode.build_qcode(rows, files)
back = qty_qcode.read_qcode({**files, "QCODE.DBF": dbf, "QCODE.SMT": smt})["rows"]
for want, have in zip(rows, back):
for k, v in want.items():
if k.startswith("QTY"):
assert float(have[k] or 0) == float(v), (k, have[k], v)
else:
assert have[k].replace("\r\n", "\n") == v.replace("\r\n", "\n"), (k, have[k], v)
# 저장 API — work_dbf 대신 임시 폴더 · STC 안 QCODE 두 파일이 바뀜
monkeypatch.setattr(server, "WORK_DBF_ROOT", tmp_path)
res = api_qty.api_qty_qcode_save(
api_qty.QcodeSaveReq(source_path=str(STC[0]), target_folder_name="qty시험", rows=rows)
)
again = api_qty.api_qty_qcode(api_qty.QcodeReq(source_path=res["saved_stc"]))
assert [r["CODE"] for r in again["rows"]] == ["Q00001", "Q00002"]
with pytest.raises(Exception):
api_qty.api_qty_qcode_save(
api_qty.QcodeSaveReq(source_path=str(STC[0]), rows=[{"CODE": "X1"}])
)
api_qty.api_qty_eq_save(
api_qty.EqSaveReq(
items=[api_qty.EqItem(name="굴삭기", text="Q = 3600*q*K*f*E/Cm =", ins_x=1)]
)
)
assert api_qty.api_qty_eq()["items"][0]["ins_x"] == 1
def test_sheet_aliases_assign_and_smt_transfer():
sheet = {
"rows": 10,
"cols": 8,
"start": 4,
"end": 1,
"cells": {
"A1": "120",
"B1": "=A1*1.5",
"C1": "=절토*2",
"D1": "=D2",
"D2": "=D1",
"A2": "B00001",
"B2": "토공",
"D3": "5",
},
"aliases": {"절토": "B1", "순환": "D1"},
"roles": {"code": "A", "name": "B", "qty": "D", "mamt": "E", "lamt": "F", "samt": "G"},
}
(ev,) = call(("evalSheet", sheet))
assert ev["values"]["B1"] == 180 and ev["values"]["C1"] == 360
assert "D1" in ev["errors"] and "@순환" in ev["errors"]
q = [{"CODE": "Q00001", "IFORM": "QTY1=절토/2\r\nQTY2=없는별명", "XCODE": ""}]
(asg,) = call(("assignFromSheet", q, sheet))
assert float(asg["rows"][0]["QTY1"]) == 90 and "절토=180" in asg["rows"][0]["XCODE"]
assert len(asg["errors"]) == 1
prices = {"L00001": {"M": 0, "L": 1000, "S": 0, "unit": "인"}}
(smt,) = call(
(
"evaluate",
"&L00001& * ~Q1.QTY1~ =\n&L00001& * ~Q1.절토~ =",
prices,
{"qcode": asg["rows"]},
)
)
assert smt["L"] == 1000 * 90 + 1000 * 180 and smt["errors"] == []
def test_sheet_to_boq_inclusive_range_without_qty_multiply():
cells = {
"A2": "B00001",
"B2": "토공",
"D2": "3",
"E2": "100",
"F2": "200",
"G2": "50",
"A4": "M00001",
"D4": "=D2*2",
"B5": "명칭만",
}
sheet = {
"rows": 6,
"cols": 7,
"start": 4,
"end": 1,
"cells": cells,
"aliases": {},
"roles": {
"code": "A",
"name": "B",
"size": "C",
"unit": "C",
"qty": "D",
"mamt": "E",
"lamt": "F",
"samt": "G",
},
}
by_code, by_name, missing = call(
("sheetToBoq", sheet, "code"),
("sheetToBoq", sheet, "name"),
("sheetToBoq", {**sheet, "roles": {"code": "A"}}, "code"),
)
assert [i["row"] for i in by_code["items"]] == [2, 4] and by_code["items"][1]["qty"] == 6
first = by_code["items"][0]
assert first["T"] == first["M"] + first["L"] + first["S"] == 350
assert [i["row"] for i in by_name["items"]] == [2, 5]
assert missing["error"] and not missing["items"]
def test_qcode_check_and_next_code_and_file_signature():
rows = [
{"CODE": "Q00001", "BQCODE": "B00001"},
{"CODE": "Q00001", "QTY1": "1x"},
{"CODE": "Q1", "XCODE": "값만", "IFORM": "식"},
{"CODE": "Q00003", "BQCODE": "Z99999"},
]
errs, nxt, bad, rt = call(
("checkQcodes", rows, ["B00001"]),
("nextQcode", rows),
("deserialize", '{"sig":"다른 것"}'),
(
"roundTrip",
{
"rows": 3,
"cols": 2,
"cells": {"A1": "1"},
"aliases": {},
"roles": {},
"start": 0,
"end": 0,
},
rows,
),
)
joined = "\n".join(errs)
for frag in ("같은 수량코드", "숫자가 아님", "다섯 자리", "이름=값", "QTYn=식", "Z99999"):
assert frag in joined, (frag, errs)
assert (
nxt == "Q00004"
and bad["error"]
and rt["sheet"]["cells"] == {"A1": "1"}
and len(rt["qcode"]) == 4
)
@pytest.mark.parametrize("path", STC, ids=lambda p: p.stem[:20])
def test_qty_by_code_and_rollup_on_real_boq(loads, path):
d = loads[path]
boq, bd = d["boq_rows"], d["bdqty_rows"]
want = defaultdict(float)
for r in boq:
c = str(r.get("BDCODE1") or "").strip()
if c and not (c[0] in STRUCT and (len(c) == 1 or c[1:].isdigit())):
want[c] += float(r.get("QTY1") or 0)
got, roll = call(("qtyByCode", boq, "ALL", "QTY1"), ("rollup", boq, bd, "QTY1"))
assert {o["code"]: round(o["qty"], 6) for o in got} == {k: round(v, 6) for k, v in want.items()}
assert roll["cycles"] == []
# 한 단계 연결 — 내역 B코드 수량 × 구성 수량이 하위 코드 소요량에 들어감
groups = defaultdict(list)
for r in bd:
groups[r["BDCODE"]].append(r)
for c, q in want.items():
for row in groups.get(c, []):
sub = str(row["CODE"]).strip()
if (
sub
and sub not in ("PR", "RR")
and not (sub[0] in STRUCT and sub[1:].isdigit())
and q
):
assert roll["totals"][sub] >= q * float(row["QTY1"] or 0) - 1e-6, (c, sub)
@pytest.mark.parametrize("path", STC, ids=lambda p: p.stem[:20])
def test_material_candidate_minimum_matches_stored(loads, path):
"""최소단가 채택 — 후보가 있는 자재의 재료비는 0원 뺀 최저 후보(저장값과 같음) · 보호표시는 그대로."""
rows = loads[path]["costn_rows"]
(res,) = call(("adopt", rows, {"mode": "min", "slot": 1}))
assert res["changed"] == []
m = next(r for r in rows if r["CODE"].startswith("M") and float(r.get("AMT_11") or 0) > 0)
probe = [
{**m, "AMT_21": str(float(m["AMT_11"]) / 2), "UC": ""},
{**m, "CODE": "M99999", "AMT_21": "1", "UC": "T"},
]
(res,) = call(("adopt", probe, {"mode": "min", "slot": 1}))
assert res["changed"][0]["to"] == float(m["AMT_11"]) / 2 and res["changed"][0]["src"] == 2
assert res["kept"] == ["M99999"]