- 보고서: 원본 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>
347 lines
13 KiB
Python
347 lines
13 KiB
Python
"""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"
|