Files
Aislo/resources/tester/test_stmate_std_market.py
T
eomsangdonandClaude Opus 5.5 7eac0d93b0 feat(stmate-test): 적용기준 행자부(I_SET_BASE 1) 요율 조회 화면 연결 — 간접노무비·일반관리비·이윤 = RATE_M
- 요율 조회(scr_kanjub_rate.lookup) — 적용기준 1 이면 rateMLookup(ST_KanJub 0x47a1bc) 값으로 KANNO·ILBAN·IYUN · 그 밖 RATE_R 그대로
- 적용기준 콤보를 바꾸면 제비율표 다시 조회(요율 축에 I_SET_BASE)
- 브라우저 확인(봉화2024): 행자부 → 간접노무비 14.5 → 14.6(RATE_M 11줄 RATE1) · 지방계약법 되돌림 14.5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 15:50:01 +09:00

233 lines
9.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""STMATE 재현 앱 — 표준시장단가(A 코드) 내역·구성 연결 · 외화 환산 EXRNM · 행자부 RATE_M 조회 · 적용기준 콤보 짝.
근거(지식DB REA 36 · 34 §6·§11·§17):
표준시장단가 단가 읽기 Main 0x430c60 — A 줄 = COSTA(0x437a4b) · S_JDA 참 = 저장 단가 · 거짓 = 0x45a468 나눔
L0 = T×R/100 · X = T×(100−R)/100 · ACTOPT 0 절사 · 1 OPTIMIZ{L0,0,0,X}[0] · 2 OPTIMIZ{X,0,0,L0}[3](동률 = 앞 칸)
R = 0 이고 LAMTn_R 있으면 그 값이 노무 · 나머지 ACTTYP 1 재료 · 2 경비 · 0 넷째 칸(A) · ACTTYP·ACTOPT = PRJLST(0x45c938)
EXRNM 참 — S 줄 단가 = MROUNDUD(단가 × 환율/1000, ECD, 0)(0x437aa2~0x437df2) · $ 10만 미만 EXL · € /1000 · ¥ /100000
행자부(SET_BASE 1) — ST_KanJub 0x47a1bc: 줄 b+11 / b+15 · 칸 RATE{기간(+5)} · 일반관리비 줄 23 RATE1 · 이윤 줄 24 RATE0
적용기준 콤보 = TWM_KANJUB.dfm cmBx_Set_Base Items/Values
기대값은 위 규칙 손셈 또는 실물 STC(RATE_M 표 · PRJLST 값) · DFM 원자료에서 읽음.
"""
import json
import subprocess
import sys
from pathlib import Path
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_std.cjs")
DOCS = ROOT / "resources/knowledge/original/실무문서"
STC = DOCS / (
"25년 산불진화임도(기번8)(울진소광)/2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외).STC"
)
DFM = (
ROOT / "resources/knowledge/original/경쟁사 프로그램/STmate/REA 분석/"
"21_제경비_입력화면연결_작업자료_20261025/ST_KanJub/resource-TWM_KANJUB.dfm.json"
)
def run(req):
out = subprocess.check_output(
["node", str(HELPER)],
input=json.dumps(req, ensure_ascii=False),
text=True,
encoding="utf-8",
)
return json.loads(out)
def item(aamt, rate_l, lamt_r=(0, 0, 0, 0), stored=None):
st = stored or {"L": [0] * 4, "M": [0] * 4, "S": [0] * 4}
base = {"code": "A00001", "name": "표준", "unit": "m", "aamt": aamt, "rateL": rate_l}
return base | {"lamtR": list(lamt_r), "stored": st}
ROW = [{"id": "BOQ_1", "code": "A00001", "refId": "A00001", "qty": 3, "changedQty": 3}]
def boq(it, std, slot=1, rows=ROW):
return run({"mode": "boq", "items": [it], "rows": rows, "std": std, "slot": slot})
def test_std_market_boq_actopt_tie():
# T 10001 · R 50 → L0 = X = 5000.5(동률) — 0 절사 5000 · 1 노무 칸 앞 → 5001 · 2 노무 칸 뒤 → 5000
it = item([10001, 0, 0, 0], 50)
r0, r1, r2 = (boq(it, {"acttyp": 1, "actopt": op})["rows"][0] for op in (0, 1, 2))
assert (r0["uL"], r0["uM"]) == (5000, 5001)
assert (r1["uL"], r1["uM"]) == (5001, 5000)
assert (r2["uL"], r2["uM"]) == (5000, 5001)
# 수량 3 — 비목별 원 미만 절사 · 합계 = 단가 합 × 3
assert (r1["L"], r1["M"], r1["S"], r1["T"]) == (15003, 15000, 0, 30003)
def test_std_market_boq_rest_column_and_preset():
it = item([10001, 0, 0, 0], 50)
s = boq(it, {"acttyp": 2, "actopt": 0})["rows"][0]
assert (s["L"], s["M"], s["S"]) == (15000, 0, 15003)
a = boq(it, {"acttyp": 0, "actopt": 0})
# ACTTYP 0 — 나머지는 넷째 칸 A(비목 밖) · 줄·내역 합계에는 들어감
assert (a["rows"][0]["M"], a["rows"][0]["A"], a["rows"][0]["T"]) == (0, 15003, 30003)
assert a["directA"] == 15003 and a["directT"] == 30003
# R 0 · LAMT1_R 4000 → 노무 4000 그대로 · 나머지 6001 재료
p = boq(item([10001, 0, 0, 0], 0, (4000, 0, 0, 0)), {"acttyp": 1, "actopt": 2})["rows"][0]
assert (p["uL"], p["uM"]) == (4000, 6001)
def test_std_market_boq_stored_and_slot2():
stored = {"L": [111, 0, 0, 0], "M": [222, 0, 0, 0], "S": [33, 0, 0, 0]}
s = boq(item([10001, 0, 0, 0], 50, stored=stored), {"useStored": True})["rows"][0]
assert (s["uL"], s["uM"], s["T"]) == (111, 222, (111 + 222 + 33) * 3)
# 변경(2칸) = AAMT2 로 다시 나눔
r = boq(item([10001, 20000, 0, 0], 50), {"acttyp": 1, "actopt": 0}, slot=2)["rows"][0]
assert (r["uL"], r["uM"]) == (10000, 10000)
COSTA = {"CODE": "A00001", "NAME": "표준", "UNIT": "m", "AAMT1": "10001", "RATE_L": "50"}
def test_std_market_composition_lookup():
data = {"costa_rows": [COSTA], "prjlst": {"ACTTYP": "1", "ACTOPT": "1"}}
(r,) = run(
{
"mode": "comp",
"cases": [
{"data": data, "rows": [{"CODE": "A00001", "QTY1": 2}], "peek": "A00001"},
],
}
)
assert (r["unit"]["L"], r["unit"]["M"], r["unit"]["S"]) == (5001, 5000, 0)
assert (r["L"], r["M"], r["S"]) == (10002, 10000, 0)
def test_exrnm_unit_price_rounds_to_ecd():
# $123.45 × 1289/1000 = 159.12705 천원 · 수량 10
costn = [{"CODE": "S00001", "UNIT": "$", "SAMT1": "123.45", "TAMT1": "123.45"}]
ini = {"EXLNM1": "1289", "EXHNM1": "1300"}
rows = [{"CODE": "S00001", "QTY1": 10}]
cases = [
{"data": {"costn_rows": costn, "start_ini": ini | {"EXRNM": "1"}}, "rows": rows},
{
"data": {"costn_rows": costn, "start_ini": ini | {"EXRNM": "1", "ECDNM": "2"}},
"rows": rows,
},
# 키 없음 = 로더 기본 1000.00 → 123.45 × 1 → ECD 0 절사 123
{"data": {"costn_rows": costn, "start_ini": {"EXRNM": "1"}}, "rows": rows},
]
a, b, c = run({"mode": "comp", "cases": cases})
assert a["S"] == 1590 # 159(ECD 0 버림) × 10
assert b["S"] == 1591 # 159.12 × 10 = 1591.2
assert c["S"] == 1230
def test_exrnm_only_s_lines_and_fullwidth_dollar():
ini = {"EXRNM": "1", "EXLNM1": "1500", "EXHNM1": "1500"}
costn = [
{"CODE": "M00009", "UNIT": "$", "MAMT1": "100", "TAMT1": "100"},
{"CODE": "S00002", "UNIT": "$", "SAMT1": "10", "TAMT1": "10"},
]
data = {"costn_rows": costn, "start_ini": ini}
m, s = run(
{
"mode": "comp",
"cases": [
{"data": data, "rows": [{"CODE": "M00009", "QTY1": 1}]},
{"data": data, "rows": [{"CODE": "S00002", "QTY1": 1}]},
],
}
)
# 원본 환산 조건 = 줄 코드 S(0x437adb) — 재료 $ 줄은 그대로 · 전각 $ 도 $ 와 같음
assert m["M"] == 100 and s["S"] == 15
def _rate_m_rows():
files = server.read_project_files(str(STC))
return server.parse_dbf_bytes(files["RATE_M.DBF"])[1]
def test_rate_m_lookup_against_stc_table():
rows = _rate_m_rows()
cases = [
{"scale": 40e8, "kongType": 0, "ilban": 0, "kikan": 1},
{"scale": 100e8, "kongType": 3, "ilban": 0, "kikan": 2},
{"scale": 500e8, "kongType": 0, "ilban": 1, "kikan": 0},
{"scale": 10e8, "kongType": 5, "ilban": 0, "kikan": -1},
]
res = run({"mode": "ratem", "rows": rows, "cases": cases})["out"]
want = [(11, 1), (12, 7), (17, 5), (15, 5)] # (줄, 칸) = 규칙 손셈
for got, (line, col) in zip(res, want):
assert got["cells"]["KANNO"] == [line, col]
assert got["rates"]["KANNO"] == server.fnum(rows[line - 1][f"RATE{col}"])
assert got["rates"]["ILBAN"] == server.fnum(rows[22]["RATE1"])
assert got["rates"]["IYUN"] == server.fnum(rows[23]["RATE0"])
assert [g["band"] for g in res] == [0, 1, 2, 0]
def test_rate_screen_lookup_uses_rate_m_for_set_base_1():
"""요율 조회 화면(scr_kanjub_rate.lookup) — 적용기준 행자부(1)면 간접노무비·일반관리비·이윤만 RATE_M · 그 밖은 RATE_R."""
files = server.read_project_files(str(STC))
rate_r = server.parse_dbf_bytes(files["RATE_R.DBF"])[1]
rows = _rate_m_rows()
opts = {"scale": 100e8, "kongType": 3, "ilbanType": 0, "kikan": 2, "rateMRows": rows}
req = [{"matrix": rate_r, "opts": {**opts, "setBase": b}} for b in (1, 0, 2)]
helper = HELPER.with_name("helper_stmate_kanjub_rate.cjs")
out = subprocess.run(
["node", str(helper)],
input=json.dumps(req),
capture_output=True,
text=True,
encoding="utf-8",
check=True,
)
mo, local, nation = json.loads(out.stdout)
want = run(
{
"mode": "ratem",
"rows": rows,
"cases": [{"scale": 100e8, "kongType": 3, "ilban": 0, "kikan": 2}],
}
)
for c in ("KANNO", "ILBAN", "IYUN"):
assert mo["rates"][c] == want["out"][0]["rates"][c]
assert mo["cells"][c][0] == "RATE_M"
assert local["rates"] == nation["rates"] # 지방·국가는 RATE_R 조회 그대로
for c in ("SANBO", "KUNBO", "ANJUN"):
assert mo["rates"][c] == local["rates"][c]
def test_set_base_combo_pairs_from_dfm():
nodes = json.loads(DFM.read_text(encoding="utf-8"))["nodes"]
combo = next(o for o in _walk(nodes) if o.get("name") == "cmBx_Set_Base")
p = combo["properties"]
want = [[int(v), t] for v, t in zip(p["Values.Strings"], p["Items.Strings"])]
got = run({"mode": "ratem", "rows": [], "cases": []})["setBase"]
assert got == want
def _walk(x):
if isinstance(x, dict):
if "name" in x and "properties" in x:
yield x
for v in x.values():
yield from _walk(v)
elif isinstance(x, list):
for v in x:
yield from _walk(v)
def test_api_load_passes_std_market_settings():
loaded = server.api_load(server.LoadReq(source_path=str(STC)))
files = server.read_project_files(str(STC))
prj = server.parse_dbf_bytes(files["PRJLST.DBF"])[1][0]
ini = server.parse_ini_bytes(files.get("START8X.INI") or b"")
std = loaded["sim_state"]["sysConfig"]["stdMarket"]
assert std["acttyp"] == int(server.fnum(prj["ACTTYP"]))
assert std["actopt"] == int(server.fnum(prj["ACTOPT"]))
assert std["useStored"] == (ini.get("S_JDA", "").strip() == "1")
costa = server.parse_dbf_bytes(files["COSTA.DBF"])[1]
assert len(loaded["sim_state"]["stdMarketItems"]) == len(costa)
assert len(loaded["costa_rows"]) == len(costa)