- 요율 조회(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>
233 lines
9.8 KiB
Python
233 lines
9.8 KiB
Python
"""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)
|