Files
Aislo/resources/tester/test_stmate_qty_sheet.py
T
eomsangdonandClaude Opus 5.5 82742c0309 feat(stmate-test): REA MCP(Ghidra) 보고서 양식·수량산출서 — 인쇄 분기표 · 0609 총괄집계표 구성비 · 수량산출서 파일 바이트(IFORM) · 셀별명 읽기 · 장부 93.1%
- ST_Prt 0x44fc34 서식 번호 → 출력 함수 표 · 0609 = Prn_Won_A · 0608 = Prn_Wonga
- IFORM = VTS·XLS 파일 바이트 · .XLS·.VTS · 16MB 거절 · ReadDefName 「이름=값」 · QTYn 칸
- 사·관급내역서 관급 구역 품목 줄 누락 수정
- 장부 F3-23 · F3-26 · F5-22 구현 · 지식DB 35번 · stmate 시험 732건 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 13:54:48 +09:00

252 lines
8.9 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_B64": "0M8R4KGxGuEAAQID", # 파일 바이트(OLE 머리) 그대로 왕복
"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"]
# ReadDefName(REA 35) — 셀별명마다 XCODE 「이름=값」CRLF · QTYn 꼴 이름 → QTYn 칸 · 그 코드 한 행만
sheet["aliases"]["Qty1"] = "C1"
q = [{"CODE": "Q00001", "XCODE": ""}, {"CODE": "Q00002", "XCODE": "그대로"}]
(asg,) = call(("assignFromSheet", q, sheet, "Q00001"))
assert float(asg["rows"][0]["QTY1"]) == 360 and "절토=180\r\n" in asg["rows"][0]["XCODE"]
assert asg["rows"][1]["XCODE"] == "그대로" and asg["errors"] == []
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 * 360 + 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_SIZE": 0x1000001},
{"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 ("같은 수량코드", "숫자가 아님", "다섯 자리", "이름=값", "16MB", "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"]