Files
Aislo/resources/tester/test_145_4_b09_total_calc.py
T
eomsangdonandClaude Opus 5.5 8013fd901b feat(B09): 총괄설계내역서 · 공사원가계산서 탭 · 제비율 셈 파이썬 · TS 짝(145-4)
- compute_total(sums, estimate) · computeTotal — 설계내역서 비목 합(bill.sums) + 사용자 요율 · 손입력
- 끝수 엑셀 그대로(줄마다 ROUNDDOWN 0 · 관급 ROUNDUP −3 · 이윤 − 보정 · 부가세 + 조정) · 안전관리비 A/B 작은 쪽
- 요율 빈칸 시작 · 빈칸이면 그 줄 · 소계 빈칸 · 후보 = M01 요율_조달청제비율 글(확정 아님)
- 엑셀에 없는 현행 비목 다섯 = 줄만 · 원가계산서 = 총괄 거울 + 구성비 · 줄 누름 = 총괄 그 줄
- 엑셀 검산(순공사원가 868,874,829 · 총공사비 1,201,879,000) · 거울 시험

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL
2026-10-09 15:12:40 +09:00

216 lines
8.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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", "-e", 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.rows[at(t.rows, "grand")].amount;
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", "-e", 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"}