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