# -*- coding: utf-8 -*- """145-4 B09 총괄설계내역서 · 공사원가계산서 셈 — 엑셀 검산 대조 · 거울(`B09_Estimation_Calc_Total.py` ↔ `.ts`). 견본 = 기번3 공통 엑셀(`2024년 간선임도사업(기번3).xlsx`) 총괄 48 비목 합 · M49~M64 요율 · N55 기초액 · I62 이윤 보정 · 설계내역서 J253 관급 순자재대 · D253 조달수수료율 → 총괄 D49~D67 · E55 A/B · 원가계산서 구성비. """ import json import re import subprocess import sys from pathlib import Path import pytest 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_candidates # noqa: E402 IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) SUMS = {"material": 194234417, "labor": 346800554, "expense": 169324165, "supply_net": 69474220} RATES = { "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, } ESTIMATE = { "rates": RATES, "safety_base": 5349000, "profit_adjust": 905980, "procurement_fee_pct": 0.54, } EXCEL_LINES = { # 총괄 D49~D58 · D60 · D62 · D64 · D66 "indirect_labor": 50286080, "accident": 14136284, "employment": 4010575, "health": 12294079, "care": 1592083, "pension": 15606024, "safety": 16586996, "other": 35479263, "environment": 5682873, "machine": 2841436, "overhead": 52132489, "profit": 108109955, "vat": 102911727, "supply": 69850000, } EXCEL_SUBTOTALS = { # 총괄 D59 · D61 · D63 · D65 · D67 "net_cost": 868874829, "da": 921007318, "supply_price": 1029117273, "contract": 1132029000, "grand": 1201879000, } def test_엑셀_검산_총괄(): out = compute_total(SUMS, ESTIMATE) assert out["sums"]["direct"] == 710359136 # 가. 순공사비계 D48 assert out["lines"] == EXCEL_LINES assert out["subtotals"] == EXCEL_SUBTOTALS assert out["safety"] == {"a": 16586996, "b": 18494700} # E55 글 A · B assert out["profit_raw"] == 109015935 # J62 보정 전 assert out["supply"] == {"net": 69474220, "fee": 375160, "raw": 69849380} # I66 · O66 def test_엑셀_검산_원가계산서_거울_구성비(): cost = compute_total(SUMS, ESTIMATE)["cost"] share = compute_total(SUMS, ESTIMATE)["share"] assert cost["direct_material"] == 194234417 and cost["material"] == 194234417 # D5 · D8 assert cost["labor"] == 397086634 # D11 assert cost["expense_sum"] == 277553778 # D22 assert cost["supply_price"] == 1029117273 and cost["grand"] == 1201879000 # D25 · D29 assert share["direct_material"] == pytest.approx(16.160896146783497) # E5 assert share["labor"] == pytest.approx(33.03881954839048) # E11 assert share["supply"] == pytest.approx(5.8117331278772655) # E28 assert share["grand"] == 100 def test_간접재료비_작업설은_직접재료비만_바꿈(): est = {**ESTIMATE, "indirect_material": 1000, "scrap": 300} out = compute_total(SUMS, est) assert out["cost"]["direct_material"] == 194234417 - 1000 + 300 # 엑셀 D5 = F48 − D6 + D7 assert out["cost"]["material"] == 194234417 assert out["subtotals"] == EXCEL_SUBTOTALS def test_빈칸_규칙(): blank = compute_total(SUMS, {}) assert all(v is None for k, v in blank["lines"].items() if k != "supply") assert blank["subtotals"]["net_cost"] is None and blank["subtotals"]["grand"] is None assert blank["lines"]["supply"] is None # 조달수수료율 빈칸 # 요율 하나만 빈칸 — 그 줄 · 소계 빈칸 · 다른 줄은 셈 one = compute_total(SUMS, {**ESTIMATE, "rates": {**RATES, "care": None}}) assert one["lines"]["care"] is None and one["lines"]["health"] == 12294079 assert one["subtotals"]["net_cost"] is None and one["lines"]["supply"] == 69850000 # 비목 합이 아직 없음(145-3 전) — 모두 빈칸 none = compute_total(None, ESTIMATE) assert none["subtotals"]["grand"] is None and none["lines"]["indirect_labor"] is None # 관급 없음 — 수수료율 빈칸이어도 관급 0 zero = compute_total({**SUMS, "supply_net": 0}, {**ESTIMATE, "procurement_fee_pct": None}) assert zero["lines"]["supply"] == 0 and zero["subtotals"]["grand"] is not None def test_안전관리비_B_가_작으면_B(): big = compute_total({**SUMS, "supply_net": 2_000_000_000}, ESTIMATE) assert big["safety"]["a"] > big["safety"]["b"] assert big["lines"]["safety"] == big["safety"]["b"] def test_요율_후보_글(): cand = rate_candidates() assert cand["RT000001"] == "M01 RT000001 3.56%" assert "구간" in cand["RT000002"] and cand["RT000019"].endswith("10%") def _run_ts(cases: list) -> list: base = ROOT / "B09_Estimation" / "B09_Estimation_Calc_Base.ts" calc = ROOT / "B09_Estimation" / "B09_Estimation_Calc_Total.ts" body = ( f"\nconst cases = {json.dumps(cases, ensure_ascii=False)};" "\nconsole.log(JSON.stringify(cases.map(([s, e]) => computeTotal(s, e))));" ) script = "".join(IMPORTS.sub("", p.read_text(encoding="utf-8")) for p in (base, calc)) + body 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 json.loads(result.stdout) def test_거울_TS_가_같은_값(): cases = [ [SUMS, ESTIMATE], [SUMS, {**ESTIMATE, "indirect_material": 1000, "scrap": 300, "vat_adjust": -27}], [SUMS, {}], [None, ESTIMATE], [{**SUMS, "supply_net": 0}, {**ESTIMATE, "procurement_fee_pct": None}], [{**SUMS, "supply_net": 2_000_000_000}, ESTIMATE], [{**SUMS, "labor": None}, ESTIMATE], [SUMS, {**ESTIMATE, "rates": {**RATES, "care": None, "profit": 12.5}}], ] assert _run_ts(cases) == [compute_total(s, e) for s, e in cases] def test_화면_탭_요율_손입력_칸(): """총괄 탭 — 요율 · 손입력 칸만 고침 · 빈칸 = 지움 · 원가계산서 줄 누름 = 총괄 그 줄.""" files = [ ROOT / "B09_Estimation" / name for name in ( "B09_Estimation_Calc_Base.ts", "B09_Estimation_Calc_Total.ts", "B09_Estimation_UI_Tabs.ts", "B09_Estimation_UI_Tabs_Total.ts", ) ] body = f""" let user = {{ estimate: {json.dumps(ESTIMATE)} }}; const setUser = (u) => (user = u); const sums = {json.dumps(SUMS)}; const ctx = () => ({{ sheets: {{ total: computeTotal(sums, user.estimate) }}, payload: {{}}, user, setUser }}); let t = buildTotal(ctx()); const at = (rows, key) => rows.findIndex((r) => r.key === key); const out = {{}}; out.editable = [ t.editable(at(t.rows, "care"), "rate"), t.editable(at(t.rows, "care"), "hand"), t.editable(at(t.rows, "safety"), "hand"), t.editable(at(t.rows, "retirement"), "rate"), t.editable(at(t.rows, "net_cost"), "rate"), ]; t.onEdit(at(t.rows, "care"), "rate", "13.14"); out.care = user.estimate.rates.care; t = buildTotal(ctx()); t.onEdit(at(t.rows, "care"), "rate", ""); out.careGone = "care" in user.estimate.rates; t = buildTotal(ctx()); out.grandBlank = t.total.amount; // 257-2 총공사비 = 표 아래 고정 줄 t.onEdit(at(t.rows, "safety"), "hand", "1,000"); out.base = user.estimate.safety_base; const c = buildCost(ctx()); out.link = c.link(at(c.rows, "safety")); c.onEdit(at(c.rows, "scrap"), "amount", "300"); out.scrap = user.estimate.scrap; console.log(JSON.stringify(out)); """ script = "".join(IMPORTS.sub("", p.read_text(encoding="utf-8")) for p in files) + body 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 out = json.loads(result.stdout) assert out["editable"] == [True, False, True, False, False] assert out["care"] == 13.14 and out["careGone"] is False assert out["grandBlank"] is None # 요율 지우면 그 줄 · 소계 빈칸 assert out["base"] == 1000 and out["scrap"] == 300 assert out["link"] == {"tab": "total", "key": "safety"}