Files
Aislo/resources/tester/test_stmate_kanjub_opts.py
T
eomsangdonandClaude Opus 5.5 3d8fddfc34 feat(stmate-test): 보고서·제잡비·식언어·수량산출·화면 묶음 합침 · 재계산 후 저장 무변경
- 보고서: 원본 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>
2026-11-13 19:12:04 +09:00

347 lines
13 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 재현 앱 원가계산서 선택축 — 형식 4벌 · 절사 자리 · 부가세 5택 · 관급 절상/반올림/절사 · 사급 위치 4택
· 도급 절사 · 공급가액 절사(이윤 차감) · 총액 절사 · 추가금액 4묶음 · 안전관리비 3모드 · 공사보험 8갈래 · 고용개선 · 시공도작성비
· 간접비 순서 코드표 · 변경 차수 보험료.
기대값은 지식DB REA 18·20 정적 규칙(네이티브 식)을 이 파일에서 따로 손셈한 것 — 실무 표본이 없는 선택지의 지킴 시험.
"""
import json
import math
import subprocess
from pathlib import Path
HELPER = Path(__file__).with_name("helper_stmate_kanjub.cjs")
BOQ = {"directM": 100_000_000, "directL": 200_000_000, "directS": 50_000_000}
RATES = {
"KANNO": 14.5,
"SANBO": 3.56,
"KOBO": 1.01,
"KUNBO": 3.545,
"SLVBO": 12.95,
"YUNBO": 4.5,
"TBUGUM": 0,
"ANJUN": 1.86,
"ANJUN1": 1.86,
"ANJUN2": 1.86,
"KITA": 6,
"HWAN": 0.8,
"EQIP": 0.4,
"ILBAN": 6,
"IYUN": 15,
}
BASE_KEYS = {
"JAB_TYPE": 0,
"CAL_ACTL": 0,
"I_TOT_JEOL": 0,
"I_BUGA": 0,
"I_KWN_JEOLSA": 3,
"I_KWN_UPDN": 0,
"I_ANJUN_K": 1,
"I_ANJUNAUTO": 1,
"I_POS_SA": 3,
"C_ANJUN_G": 5_349_000,
"C_ANJUN1_G": 5_349_000,
"C_ANJUN2_G": 5_349_000,
"C_KWANGUP_D": 110_000_000,
"C_T_JODAL": 1_000_000,
}
td = lambda v, d=0: math.trunc(v / 10**d + (1e-9 if v >= 0 else -1e-9)) * 10**d # noqa: E731
tu = lambda v, d=0: math.ceil(v / 10**d - 1e-9) * 10**d # noqa: E731
def cfg(rates=None, **keys):
return {
"slot": 1,
"keys": BASE_KEYS | keys,
"rates": RATES | (rates or {}),
"addRows": keys.pop("addRows", None) or [],
}
def run(cases):
return json.loads(
subprocess.check_output(
["node", str(HELPER)], input=json.dumps(cases), text=True, encoding="utf-8"
)
)
def ref_t(M=100e6, L=200e6, S=50e6, J=0, gov=110e6, jodal=1e6, rates=RATES):
"""일반형식 손셈 — 순서·밑수·절사(REA 18·20)."""
r = rates
kanno = td(L * r["KANNO"] / 100, J)
Lp = L + kanno
sanbo, kobo = td(Lp * r["SANBO"] / 100, J), td(Lp * r["KOBO"] / 100, J)
kunbo = td(L * r["KUNBO"] / 100, J)
slvbo, yunbo = td(kunbo * r["SLVBO"] / 100, J), td(L * r["YUNBO"] / 100, J)
kn = (gov - jodal) / 1.1
c190, c198 = (
td((M + L) * r["ANJUN"] / 100 + 5_349_000),
td((M + L + kn) * r["ANJUN"] / 100 + 5_349_000),
)
anjun = td(1.2 * c190, J) if 1.2 * c190 < c198 else td(c198, J)
kita = td((M + L + kanno) * r["KITA"] / 100, J)
T = M + L + S
hwan, eqip = td(T * r["HWAN"] / 100, J), td(T * r["EQIP"] / 100, J)
exp = S + sanbo + kobo + kunbo + slvbo + yunbo + anjun + kita + hwan + eqip
pure = M + Lp + exp
ilban = td(pure * r["ILBAN"] / 100, J)
iyun = td((pure + ilban - M) * r["IYUN"] / 100)
supply = pure + ilban + iyun
vat = td(supply * 0.1)
govf = tu(gov, 3)
return dict(
kanno=kanno,
anjun=anjun,
kita=kita,
pure=pure,
ilban=ilban,
iyun=iyun,
supply=supply,
vat=vat,
gov=govf,
grand=supply + vat + govf,
)
def test_general_scheme_order_and_bases():
(r,) = run([{"boq": BOQ, "cfg": cfg()}])
e = ref_t()
assert (
r["kanno"] == e["kanno"]
and r["items"]["ANJUN"] == e["anjun"]
and r["items"]["KITA"] == e["kita"]
)
assert (
r["pureConstructionCost"] == e["pure"]
and r["ilbanAmt"] == e["ilban"]
and r["iyunFinal"] == e["iyun"]
)
assert (r["supplyFinal"], r["vatFinal"], r["govFinal"], r["grandTotal"]) == (
e["supply"],
e["vat"],
e["gov"],
e["grand"],
)
assert r["keys"]["C_KN_SA"] == (110e6 - 1e6) / 1.1
def test_indirect_truncation_digits():
(r,) = run([{"boq": BOQ, "cfg": cfg(I_KAN_JEOLSA=3)}])
e = ref_t(J=3)
for c in ("KANNO", "SANBO", "KOBO", "KUNBO", "SLVBO", "YUNBO", "ANJUN", "KITA", "HWAN", "EQIP"):
assert r["items"][c] % 1000 == 0, c
assert r["ilbanAmt"] == e["ilban"] and r["grandTotal"] == e["grand"]
def test_four_schemes_change_bases():
s_rates = {f"S_{x}": 1.0 for x in "SUDO BOKRI SOMO TONGSIN SEGUM PEGI DOSEO JIGUP".split()}
t, s, ta, sa = run(
[
{"boq": BOQ, "cfg": cfg()},
{"boq": BOQ, "cfg": cfg(s_rates, JAB_TYPE=3)},
{"boq": BOQ, "cfg": cfg(CAL_ACTL=1, I_MTYP=1, I_LTYP=1)},
{"boq": BOQ, "cfg": cfg(s_rates, JAB_TYPE=3, CAL_ACTL=1, I_MTYP=1, I_LTYP=1)},
]
)
# 수공 — 기타경비 대신 세부경비 8 · 밑수 재료비+노무비(직+간)
assert "KITA" not in s["items"] and s["items"]["S_SUDO"] == td(
(100e6 + 200e6 + t["kanno"]) * 0.01
)
# 실적 — 기타경비 밑수 직접공사비 · 이윤 밑수 재료비 포함(계 × 율)
assert ta["items"]["KITA"] == td(350e6 * 0.06)
assert ta["iyunRaw"] == td((ta["pureConstructionCost"] + ta["ilbanAmt"]) * 0.15)
assert sa["items"]["S_SUDO"] == td(350e6 * 0.01) and "KITA" not in sa["items"]
assert len({x["grandTotal"] for x in (t, s, ta, sa)}) == 4
def test_vat_five_modes_and_plus_one():
rs = run(
[{"boq": BOQ, "cfg": cfg(I_BUGA=b, C_BUGA_M=1_000_000)} for b in range(5)]
+ [{"boq": BOQ, "cfg": cfg(B_BUGA_UP=1)}]
)
sup = rs[0]["supplyFinal"]
assert rs[0]["vatFinal"] == td(sup * 0.1)
assert rs[1]["vatFinal"] == td(100e6 * 0.1)
assert rs[2]["vatFinal"] == 0
assert rs[3]["vatFinal"] == td((350e6 - 200e6) * 0.1)
assert rs[4]["vatFinal"] == td((350e6 - 200e6 - 1e6) * 0.1)
assert rs[5]["vatFinal"] == rs[0]["vatFinal"] + 1
def test_gov_rounding_modes_and_separate_install():
g = {"C_KWANGUP_D": 110_500_500, "C_KWANGUP_K": 20_001_500}
up, half, down, comb = run(
[
{"boq": BOQ, "cfg": cfg(I_KWN_UPDN=0, **g)},
{"boq": BOQ, "cfg": cfg(I_KWN_UPDN=1, I_KWN_K_UPDN=1, **g)},
{"boq": BOQ, "cfg": cfg(I_KWN_UPDN=2, I_KWN_K_UPDN=2, **g)},
{"boq": BOQ, "cfg": cfg(I_KWN_COMB=1, **g)},
]
)
assert (up["govFinal"], up["govK"]) == (110_501_000, 20_002_000)
assert (half["govFinal"], half["govK"]) == (
110_500_000,
20_002_000,
) # 반올림 = 최근접 짝수(11번 §4.2)
assert (down["govFinal"], down["govK"]) == (110_500_000, 20_001_000)
assert (comb["govFinal"], comb["govK"]) == (130_502_000, 0) # 통합 = 도급자+관급자 한 번에
def test_private_material_position_four_ways():
K = 11_000_000
p0, p1, p2, p3 = run([{"boq": BOQ, "cfg": cfg(C_SAGUP=K, I_POS_SA=p)} for p in (0, 1, 2, 3)])
base = run([{"boq": BOQ, "cfg": cfg()}])[0]
assert p0["grandTotal"] == base["grandTotal"] + K # 관급자재대 다음
assert p2["contractFinal"] == base["contractFinal"] + K # 도급공사비 직전(부가세 밖)
assert p1["supplyFinal"] == base["supplyFinal"] + K and p1["vatFinal"] == td(
p1["supplyFinal"] * 0.1
) # 이윤 다음(과세)
assert p3["dM"] == 100e6 + K # 재료비에 포함
def test_contract_and_supply_truncation():
dog, jeol = run([{"boq": BOQ, "cfg": cfg(I_DOG_JEOL=4)}, {"boq": BOQ, "cfg": cfg(I_JEOLSA=3)}])
base = run([{"boq": BOQ, "cfg": cfg()}])[0]
assert dog["contractFinal"] == td(base["contractFinal"], 4)
assert jeol["supplyFinal"] == td(base["supplyFinal"], 3)
assert jeol["pretaxProfitAdj"] == base["supplyFinal"] - jeol["supplyFinal"]
assert jeol["iyunFinal"] == base["iyunFinal"] - jeol["pretaxProfitAdj"]
def test_total_truncation_hits_target():
cases = [
{"boq": dict(BOQ, directL=200_000_000 + 7_777 * i), "cfg": cfg(I_TOT_JEOL=d)}
for i in range(6)
for d in (3, 4)
]
for c, r in zip(cases, run(cases)):
d = c["cfg"]["keys"]["I_TOT_JEOL"]
assert r["grandTotal"] % 10**d == 0, r["grandTotal"]
assert r["keys"]["C_IYUN_M"] >= 0
def test_add_amount_groups_and_vat_flag():
rows = [
{"group": 1, "name": "추가1", "amts": [1_000_000, 0, 0, 0, 0]},
{"group": 1, "name": "자동", "amts": [5_000_000, 0, 0, 0, 0], "addt": 1},
{"group": 2, "name": "폐기물", "amts": [2_000_000, 0, 0, 0, 0], "vat": True},
{"group": 3, "name": "검증수수료", "amts": [300_000, 0, 0, 0, 0]},
{"group": 4, "name": "총액 추가", "amts": [40_000, 0, 0, 0, 0]},
]
base, a, forest = run(
[
{"boq": BOQ, "cfg": cfg()},
{"boq": BOQ, "cfg": cfg(addRows=rows)},
{"boq": BOQ, "cfg": cfg(addRows=rows, I_BUGA=3)},
]
)
assert a["totalExpense"] == base["totalExpense"] + 1_000_000 # 묶음1 = 경비(ADDT 행 제외)
assert a["ilbanAmt"] == td((base["pureConstructionCost"] + 1_000_000) * 0.06)
assert (
a["supplyFinal"] - (a["pureConstructionCost"] + a["ilbanAmt"] + a["iyunFinal"]) == 2_000_000
)
assert a["contractFinal"] == a["supplyFinal"] + a["vatFinal"] + 300_000
assert a["grandTotal"] == a["contractFinal"] + a["govFinal"] + 40_000
assert forest["vatFinal"] == td(
(350e6 - 200e6 + 2_000_000) * 0.1
) # 산림조합 = 과세 추가금액 포함
def test_safety_three_ways():
k0b1, k2, manual, raw = run(
[
{"boq": BOQ, "cfg": cfg(I_ANJUN_K=0, I_ANJUN_B=1)},
{"boq": BOQ, "cfg": cfg(I_ANJUN_K=2, C_SAGUP=22_000_000, I_KN_SA_TYPE=0, I_POS_SA=0)},
{"boq": BOQ, "cfg": cfg(I_ANJUNAUTO=0, I_ANJUN_B=1)},
{"boq": BOQ, "cfg": cfg(I_ANJUN_K=0, I_ANJUN_B=1, I_AN_DN_TYPE=1)},
]
)
c190 = (300e6 * 1.86 / 100) + 5_349_000
assert k0b1["items"]["ANJUN"] == td(1.2 * td(c190))
assert k2["items"]["ANJUN"] == td((300e6 + 22e6) * 1.86 / 100 + 5_349_000) # 사급 포함 후보
c198 = (300e6 + 109e6 / 1.1) * 1.86 / 100 + 5_349_000
assert manual["items"]["ANJUN"] == td(1.2 * td(c198)) # 수동 — 관급 포함 후보의 1.2배
assert raw["items"]["ANJUN"] == td(1.2 * c190) # 소수 버림 전 1.2배
def test_construction_insurance_eight_cases():
cases = [
{"boq": BOQ, "cfg": cfg({"KONGSA": 0.5}, I_KN_SA_TYPE=t, C_SAGUP=11e6, I_POS_SA=2)}
for t in range(8)
]
rs = run(cases)
A, K = 109e6, 11e6
for t, r in enumerate(rs):
# 대상액 = 공급가액(공사보험 앞) + 관급·사급 갈래 항(REA 20 C_KONGSA_J 8갈래)
term = [A / 1.1, A, K / 1.1, K, A / 1.1 + K, A + K / 1.1, A / 1.1 + K / 1.1, A + K][t]
assert abs(r["kongsaJ"] - (r["supplyFinal"] + term)) < 1, t
assert r["items"]["KONGSA"] == td(r["kongsaJ"] * 0.005)
def test_employment_support_and_class4_fee():
eis, c4 = run(
[
{
"boq": BOQ,
"cfg": cfg(
{"EIS_HOLIR1": 20, "EIS_HOLIR2": 50, "EIS_SOCIR2": 10, "EIS_INCENT": 5},
JAB_TYPE=5,
),
},
{"boq": BOQ, "cfg": cfg({"S_SIGONG": 2, "S_SIGONG_1": 0.5}, JAB_TYPE=4)},
]
)
L = 200e6
hp, soc, inc = (
td(L * 0.2 * 0.5 * 1.4),
td(L * (3.545 + 4.5) / 100 * 0.1),
td(L * 0.2 * 0.5 * 0.05),
)
assert eis["items"]["EIS_SUPPAY"] == hp + soc + inc
k4 = 0.02 * 0.5
A = c4["pureConstructionCost"] + c4["ilbanAmt"]
assert c4["items"]["S_SIGONG"] == td((A + c4["iyunFinal"]) * k4)
def test_kan_ord_code_table_orders_items():
(r,) = run(
[
{
"boq": BOQ,
"cfg": dict(cfg(), kanOrd={"1": "301;309;311;302;303;310;304;305;306;312;313;321"}),
}
]
)
assert r["order"][:4] == ["KANNO", "HWAN", "EQIP", "SANBO"]
def test_change_stage_insurance_modes():
prev = {"boq": BOQ, "cfg": cfg()}
cur = dict(BOQ, directL=230_000_000)
rs = run(
[
prev,
{"boq": cur, "cfg": cfg(I_BF_CH_BO=1), "prev": 0, "pclass": 7},
{"boq": cur, "cfg": cfg(I_BF_CH_BO=2), "prev": 0, "pclass": 7},
{"boq": cur, "cfg": cfg(I_COST_BO=1, C_KUNBO=123, C_YUNBO=456), "prev": 0, "pclass": 7},
]
)
old = rs[0]["items"]["KUNBO"]
assert rs[1]["items"]["KUNBO"] == old + td(30e6 * 3.545 / 100) # 당초액 + 직노증감 × 요율
assert rs[2]["items"]["KUNBO"] == old + td(30e6 * old / 200e6) # 당초액 + 증감액(비율)
assert (rs[3]["items"]["KUNBO"], rs[3]["items"]["YUNBO"]) == (123, 456) # 직접입력 유지
def test_legacy_default_state_still_computes():
out = subprocess.check_output(
[
"node",
"-e",
"const C=require('./tools/stmate-test/calc.js');const s=C.createDefaultState();const r=C.calculateAll(s);process.stdout.write(String(r.kanjubComputed.grandTotal))",
],
cwd=str(Path(__file__).resolve().parents[2]),
text=True,
)
assert out == "0"