- 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
237 lines
9.8 KiB
Python
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 # 요율 모두 자동 — 도급공사비까지 셈(관급은 수수료율 빈칸)
|