- 셈 짝(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>
265 lines
11 KiB
Python
265 lines
11 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 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"]
|