Files
Aislo/resources/tester/test_166_1_b09_rate_auto.py
T
eomsangdonandClaude Opus 5.5 58283a578f feat(b09): 총괄 요율 자동 고름 · 사용자 값 앞섬 · 고르는 값 칸(166-1)
- Rates.rate_table: M01 조달청 제비율 압축 표(구간 위 끝 · 토목) → payload rate_table
- Calc_Total 짝(py · ts): pick_rates 자동 고름(직접공사비 × 공기 · 추정가격 · 안전 대상액 + 기초액 · 연도 · 환경 공종) · 사용자 값 앞섬
- 총괄 탭: 추정가격 · 공사기간 · 환경보전비 공종 칸 · 요율 출처 칸(자동 근거 풍선 · 누르면 자동으로)
- Store: environment_kind 칸
- 시험: test_166_1 새 · test_145_4 node 입력을 stdin 으로(명령 줄 길이)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt
2026-10-10 00:06:34 +09:00

237 lines
9.8 KiB
Python

# -*- coding: utf-8 -*-
"""166-1 B09 총괄 요율 자동 — M01 압축 표(`B09_Estimation_Rates.rate_table`) · 구간 고르기 · 사용자 값 앞섬 ·
엑셀 검산(손값 그대로) · 거울(`B09_Estimation_Calc_Total.py` ↔ `.ts`) · 화면 칸(고르는 값 · 되돌리기).
엑셀 2024 요율은 2026 표와 연도 차로 다름 — 자동값은 꼴(구간 고르기)로만 잼.
"""
import json
import re
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B09_Estimation.B09_Estimation_Calc_Total import compute_total # noqa: E402
from B09_Estimation.B09_Estimation_Rates import rate_table # noqa: E402
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
SUMS = {"material": 194234417, "labor": 346800554, "expense": 169324165, "supply_net": 69474220}
RATES = { # 엑셀 2024 M49~M64
"indirect_labor": 14.5, "accident": 3.56, "employment": 1.01, "health": 3.545,
"care": 12.95, "pension": 4.5, "safety": 1.86, "other": 6, "environment": 0.8,
"machine": 0.4, "overhead": 6, "profit": 15, "vat": 10,
} # fmt: skip
EXCEL = {
"rates": RATES,
"safety_base": 5349000,
"profit_adjust": 905980,
"procurement_fee_pct": 0.54,
}
PICK = {"estimated_price": 1_000_000_000, "duration_days": 183, "procurement_fee_pct": 0.54}
def total(sums, est, year=2026):
return compute_total(sums, est, rate_table(), year)
def rate(out, key):
return out["rates"][key]["rate"]
def test_압축_표_꼴():
t = rate_table()
assert t["indirect_labor"]["direct"] == [10**9, 5 * 10**9, 30 * 10**9, 100 * 10**9] # 십억
assert t["indirect_labor"]["days"] == [184, 366, 1096]
assert t["overhead"]["price"] == [5 * 10**9, 30 * 10**9, 100 * 10**9]
assert t["employment"]["pcts"][0] == 1.01 and t["employment"]["pcts"][-1] == 1.57
assert t["safety"] == {
"rt": "RT000006",
"target": [5 * 10**8, 5 * 10**9],
"pcts": [3.15, 2.53, 2.6],
"bases": [0, 3300000, 0],
"note": "토목",
}
assert t["environment"]["kinds"] == {"road": 0.9, "other": 0.8}
def test_직접공사비_10억_공기_183일_경계():
small = total({"material": 0, "labor": 999_999_999, "expense": 0}, PICK)
assert rate(small, "indirect_labor") == 19.1 and rate(small, "other") == 5.5
assert (
small["rates"]["indirect_labor"]["why"] == "직접공사비 10억 미만 · 공기 184일 미만 · 토목"
)
edge = total({"material": 0, "labor": 1_000_000_000, "expense": 0}, PICK)
assert rate(edge, "indirect_labor") == 18.9 and rate(edge, "other") == 5.8
longer = total(
{"material": 0, "labor": 1_000_000_000, "expense": 0}, {**PICK, "duration_days": 184}
)
assert rate(longer, "indirect_labor") == 19.3
assert longer["rates"]["indirect_labor"]["why"].startswith(
"직접공사비 10억 이상 50억 미만 · 공기 184일 이상"
)
def test_추정가격_50억_경계():
under = total(SUMS, {**PICK, "estimated_price": 4_999_999_999})
assert (rate(under, "overhead"), rate(under, "profit"), rate(under, "employment")) == (
8,
15,
1.01,
)
at = total(SUMS, {**PICK, "estimated_price": 5_000_000_000})
assert (rate(at, "overhead"), rate(at, "profit")) == (6.5, 12)
assert at["rates"]["overhead"]["why"] == "추정가격 50억 이상 300억 미만 · 종합(토목) 표"
def test_안전_5억_경계와_기초액():
small = total(
{"material": 100_000_000, "labor": 399_999_999, "expense": 0, "supply_net": 0}, PICK
)
assert rate(small, "safety") == 3.15 and small["rates"]["safety"]["base"] == 0
# 견본 = 재료비 + 직노 + 관급 ÷ 1.1 = 6.04억 → 5억~50억 2.53 + 3,300,000
out = total(SUMS, PICK)
assert rate(out, "safety") == 2.53 and out["rates"]["safety"]["base"] == 3300000
target = 194234417 + 346800554 + 69474220 / 1.1
assert out["safety"]["a"] == int(target * 2.53 / 100 + 3300000)
mine = total(SUMS, {**PICK, "safety_base": 1000})
assert mine["rates"]["safety"]["base"] == 1000
def test_연금_해_환경_공종_단일():
out = total(SUMS, PICK)
assert rate(out, "pension") == 4.75 and rate(total(SUMS, PICK, 2027), "pension") == 5.0
late = total(SUMS, PICK, 2033)
assert rate(late, "pension") is None and late["subtotals"]["net_cost"] is None
assert late["rates"]["pension"]["why"] == "2033년 요율 없음"
assert rate(out, "environment") == 0.8
assert rate(total(SUMS, {**PICK, "environment_kind": "road"}), "environment") == 0.9
assert (rate(out, "accident"), rate(out, "care"), rate(out, "vat"), rate(out, "machine")) == (
3.56,
13.14,
10,
0.4,
)
assert out["subtotals"]["grand"] is not None and out["rates"]["vat"]["from"] == "auto"
def test_사용자_값이_앞섬_지우면_자동():
out = total(SUMS, {**PICK, "rates": {"indirect_labor": 14.5, "care": None}})
assert out["rates"]["indirect_labor"] == {
"rate": 14.5,
"from": "user",
"auto": 19.1,
"why": "직접공사비 10억 미만 · 공기 184일 미만 · 토목",
}
assert out["lines"]["indirect_labor"] == 50286080 # 엑셀 D49
assert out["rates"]["care"]["from"] == "auto" and rate(out, "care") == 13.14
def test_고르는_값_빈칸이면_자동_없음():
out = total(SUMS, {"procurement_fee_pct": 0.54})
for key in ("indirect_labor", "other", "overhead", "profit", "employment"):
assert rate(out, key) is None and out["rates"][key]["from"] is None
assert out["lines"]["indirect_labor"] is None and out["subtotals"]["net_cost"] is None
assert rate(out, "health") == 3.595 # 단일 요율은 그대로
def test_엑셀_검산_손값이면_그대로():
out = total(SUMS, EXCEL, 2026)
assert out["subtotals"]["grand"] == 1201879000
assert out["lines"]["safety"] == 16586996 and out["lines"]["profit"] == 108109955
assert all(r["from"] == "user" for r in out["rates"].values())
def _node(script: str) -> str:
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript"],
input=script, capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
return result.stdout
def _source(*names: str) -> str:
return "".join(
IMPORTS.sub("", (ROOT / "B09_Estimation" / n).read_text(encoding="utf-8")) for n in names
)
CASES = [
[SUMS, PICK, 2026],
[{"material": 0, "labor": 1_000_000_000, "expense": 0}, {**PICK, "duration_days": 184}, 2026],
[SUMS, {**PICK, "estimated_price": 5_000_000_000, "environment_kind": "road"}, 2027],
[{"material": 100_000_000, "labor": 399_999_999, "expense": 0, "supply_net": 0}, PICK, 2026],
[SUMS, {**PICK, "safety_base": 1000, "rates": {"indirect_labor": 14.5, "care": None}}, 2026],
[SUMS, {"procurement_fee_pct": 0.54}, 2033],
[SUMS, EXCEL, 2026],
[None, PICK, 2026],
]
def test_거울_TS_가_같은_값():
body = (
f"\nconst table = {json.dumps(rate_table(), ensure_ascii=False)};"
f"\nconst cases = {json.dumps(CASES, ensure_ascii=False)};"
"\nconsole.log(JSON.stringify(cases.map(([s, e, y]) => computeTotal(s, e, table, y))));"
)
out = json.loads(
_node(_source("B09_Estimation_Calc_Base.ts", "B09_Estimation_Calc_Total.ts") + body)
)
assert out == [total(s, e, y) for s, e, y in CASES]
def test_화면_고르는_값_칸과_되돌리기():
body = f"""
let user = {{ estimate: {{}} }};
const setUser = (u) => (user = u);
const table = {json.dumps(rate_table(), ensure_ascii=False)};
const sums = {json.dumps(SUMS)};
const ctx = () => ({{ sheets: {{ total: computeTotal(sums, user.estimate, table, 2026) }},
payload: {{ rate_table: table, rate_candidates: {{}} }}, user, setUser }});
let t = buildTotal(ctx());
const at = (key) => t.rows.findIndex((r) => r.key === key);
const out = {{}};
out.editable = [t.editable(at("in_price"), "hand"), t.editable(at("in_days"), "hand"),
t.editable(at("in_env"), "hand")];
out.blank = t.rows[at("indirect_labor")].rate;
t.onEdit(at("in_price"), "hand", "1,000,000,000");
t = buildTotal(ctx());
t.onEdit(at("in_days"), "hand", "183");
t = buildTotal(ctx());
out.price = user.estimate.estimated_price;
out.auto = [t.rows[at("indirect_labor")].rate, t.rows[at("indirect_labor")].candidate];
out.tip = t.tip(at("indirect_labor"), "rate");
t.onCell(at("in_env"), "name");
t = buildTotal(ctx());
out.env = [user.estimate.environment_kind, t.rows[at("environment")].rate, t.rows[at("in_env")].name];
t.onEdit(at("indirect_labor"), "rate", "14.5");
t = buildTotal(ctx());
out.mine = [t.rows[at("indirect_labor")].rate, t.rows[at("indirect_labor")].candidate];
t.onCell(at("indirect_labor"), "candidate");
t = buildTotal(ctx());
out.back = [t.rows[at("indirect_labor")].rate, "indirect_labor" in user.estimate.rates];
out.contract = t.rows[at("contract")].amount;
console.log(JSON.stringify(out));
"""
names = (
"B09_Estimation_Calc_Base.ts",
"B09_Estimation_Calc_Total.ts",
"B09_Estimation_UI_Tabs.ts",
"B09_Estimation_UI_Tabs_Total.ts",
)
out = json.loads(_node(_source(*names) + body))
assert out["editable"] == [True, True, False]
assert out["blank"] is None # 고르는 값 빈칸 = 간노 자동 없음
assert out["price"] == 1_000_000_000
assert out["auto"] == [19.1, "자동 — 직접공사비 10억 미만 · 공기 184일 미만 · 토목"]
assert "자동 근거" in out["tip"]
assert out["env"] == ["road", 0.9, "환경보전비 공종 = 도로"]
assert out["mine"] == [14.5, "사용자 값 · 누르면 자동 19.1%으로"]
assert out["back"] == [19.1, False]
assert out["contract"] is not None # 요율 모두 자동 — 도급공사비까지 셈(관급은 수수료율 빈칸)