"""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"]