- 보고서: 원본 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>
250 lines
8.7 KiB
Python
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"]
|