- 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
216 lines
8.4 KiB
Python
216 lines
8.4 KiB
Python
# -*- 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"}
|