Files
Aislo/resources/tester/test_145_4_b09_total_calc.py
T
eomsangdonandClaude Opus 5.5 472bf98ba2 feat(B09 원가계산서 · 708-2): 현행 비목 다섯 셈 — 퇴직공제 · 임금채권 · 석면 · 하도급 요율 셈 · 공사이행보증 금액 손입력
- 셈 짝(Calc_Total py ↔ ts) — STmate 일반형식 식 · 자리(지식DB 12 · 35 분석) · 경비 묶음 안 · 나.소계에 듦
  퇴직공제부금비 = 직접노무비 × 율 · 임금채권 · 석면 = (직노 + 간노) × 율 · 하도급 = 순공사비 × 율 ·
  공사이행보증 = 금액 직접 입력(performance_fee) · 자동 요율 없음 · 요율 빈칸 = 해당 없음(0)
- 요율 출처 「불러옴」 = rates_imported(707-2 불러오기가 실음 · 요율 고치면 사용자)
- 707-2 불러오기 — 「셈 없음」 알림 뺌 · 공사이행보증 줄 금액 = 손입력 칸
- 총괄 · 원가계산서 탭 · 인쇄 미리보기 = 같은 줄 짜기
- 낡은 시험 고침 — 현행 비목 기대값 빈칸 → 0 · 퇴직공제 요율 칸 고침 가능 · 거창 총공사비 = 엑셀

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 21:27:40 +09:00

265 lines
11 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 CURRENT_ONLY, 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,
**{key: 0 for key in CURRENT_ONLY}, # 708-2 현행 비목 — 엑셀(기번3)에 없음 = 요율 빈칸 = 0
}
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 not in ("supply", *CURRENT_ONLY))
assert all(blank["lines"][k] == 0 for k in CURRENT_ONLY) # 708-2 요율 빈칸 = 해당 없음
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}}],
[SUMS, CURRENT],
[{**SUMS, "labor": None}, CURRENT],
]
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, True, False] # 708-2 퇴직공제 요율 칸
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"}
CURRENT = { # 708-2 현행 비목 — 율 넷 · 공사이행보증 금액 · 불러온 율 표시
**ESTIMATE,
"rates": {
**RATES,
"retirement": 2.3,
"wage_claim": 0.09,
"asbestos": 0.006,
"subcontract": 0.1,
},
"rates_imported": ["retirement", "safety"],
"performance_fee": 1234567,
}
def test_708_2_현행_비목_다섯():
out = compute_total(SUMS, CURRENT)
ln, total_labor, direct = out["lines"], 346800554 + 50286080, 710359136
assert ln["retirement"] == int(346800554 * 2.3 / 100) # 직접노무비 × 율
assert ln["wage_claim"] == int(total_labor * 0.09 / 100) # (직노 + 간노) × 율
assert ln["asbestos"] == int(total_labor * 0.006 / 100)
assert ln["subcontract"] == int(direct * 0.1 / 100) # (재료 + 직노 + 산출경비) × 율
assert ln["performance"] == 1234567 # 금액 손입력
added = sum(ln[k] for k in CURRENT_ONLY)
assert out["subtotals"]["net_cost"] == EXCEL_SUBTOTALS["net_cost"] + added # 나.소계에 듦
assert out["cost"]["expense_sum"] == 277553778 + added # 경비 묶음 안
assert out["cost"]["retirement"] == ln["retirement"]
assert (
out["rates"]["retirement"]["from"] == "import"
and out["rates"]["safety"]["from"] == "import"
)
assert out["rates"]["wage_claim"]["from"] == "user"
# 요율 빈칸 = 해당 없음(0 · 소계 빈칸 아님) — 옛 공사 금액 그대로
old = compute_total(SUMS, ESTIMATE)
assert all(old["lines"][k] == 0 for k in CURRENT_ONLY)
assert old["subtotals"] == EXCEL_SUBTOTALS
def test_708_2_저장_칸():
from B09_Estimation import B09_Estimation_Store as store
raw = {"estimate": {"performance_fee": 1234567, "rates_imported": ["retirement", 3]}}
got = store.clean_user(raw)["estimate"]
assert got["performance_fee"] == 1234567 and got["rates_imported"] == ["retirement", "3"]