- 공종 나무 = 엑셀 꼴 고정(토공사 1.1~1.6 · 구조물공 2.1~2.3 · 운반공 · 자재대 · 관급자재대 제외금액) · 공구 안 나눔
- 항목 짜기(서버) = B08 토공집계표 짝 · 구조물 집계표 열(구조물군 → 공종 짝 · 커플링 뺌) · 운반공 수량 = B08 총괄자재
- 조합 밖 토공 · 운반 산근 · 준비공 = 빈칸 「연결 없음」 · 구조물 단가 = 대가 호표 자리(145-2 hopyo)
- 4 자재대 = 대비표 「별도」 · 관급자재대 = 「관급」 표시 + 조달수수료(율 사용자 입력 · ROUNDDOWN 0)
- 셈 = 파이썬 · TS 짝 + 거울 · 금액 ROUNDDOWN(단가 × 수량, 0) 비목마다 · 제목 = 아는 금액 합 + 빈 줄 수
- 원가계산서 이음 = sheets.bill.sums {material · labor · expense · supply_net} · 빈 줄 있으면 null
- 시험 = test_145_3_b09_bill(엑셀 G9 361,494 · G14 11,003,825 · 관급 69,474,220 + 375,160 = 69,849,380)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X
200 lines
9.8 KiB
Python
200 lines
9.8 KiB
Python
"""145-3 B09 설계내역서 — 엑셀 검산 대조 · 항목 짜기 · 원가계산서 이음 꼴 ·
|
|
거울(`Calc_Bill.py` ↔ `.ts`).
|
|
|
|
견본 = 기번3 공통 엑셀 설계내역서:
|
|
9 · 10줄 규준틀(대가 5 · 8) · 14줄 절토(토사)(산근 13) — 같은 호표 단가면 같은 금액
|
|
246~253줄 관급자재대 — 순자재대 69,474,220 · 조달수수료 0.54 % = 375,160 · 계 69,849,380
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
if str(ROOT) not in sys.path:
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
from B09_Estimation.B09_Estimation_Bill_Items import bill_items # noqa: E402
|
|
from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation # noqa: E402
|
|
|
|
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
|
|
|
|
#: 관급 자재(엑셀 246~252 · 재료비목록표 단가) — [키, 품명, 규격, 단위, 수량, 단가]
|
|
OWNER = [
|
|
("b08:remicon_25_18_80", "레미콘", "25-18-80", "M3", 32, 102970),
|
|
("b08:pipe_800", "파형강관", "Φ800m/m, 2.7t", "M", 212, 157000),
|
|
("b08:pipe_1000", "파형강관", "Φ1000m/m, 2.7t", "M", 132, 196270),
|
|
("b08:pipe_1200", "파형강관", "Φ1200m/m, 3.2t", "M", 22, 273370),
|
|
("b08:cp_800", "파형강관이음관", "Φ800m/m", "개", 22, 21200),
|
|
("b08:cp_1000", "파형강관이음관", "Φ1000m/m", "개", 12, 32200),
|
|
("b08:cp_1200", "파형강관이음관", "Φ1200m/m", "개", 3, 40200),
|
|
]
|
|
MATERIALS = [
|
|
{"key": k, "name": n, "spec": s, "unit": u, "origin": "B08", "prices": {"관급": p}, "qty": q}
|
|
for k, n, s, u, q, p in OWNER
|
|
] + [
|
|
{"key": "b08:sand", "name": "골재", "spec": "모래", "unit": "㎥", "origin": "B08",
|
|
"prices": {"물가정보": 30000}, "qty": 16},
|
|
{"key": "b08:rubble", "name": "골재", "spec": "막자갈", "unit": "㎥", "origin": "B08",
|
|
"prices": {}, "qty": 5},
|
|
] # fmt: skip
|
|
ITEMS = [
|
|
{"key": "earth:1", "section": "1.1", "name": "규준틀설치 종단규준틀", "unit": "개소", "qty": 14,
|
|
"ref": {"tab": "dg_list", "key": "B00194"}},
|
|
{"key": "earth:2", "section": "1.1", "name": "규준틀설치 횡단규준틀", "unit": "개소", "qty": 28,
|
|
"ref": {"tab": "dg_list", "key": "B00467"}},
|
|
{"key": "earth:4", "section": "1.2", "name": "절토(토사)", "unit": "㎥", "qty": 5675,
|
|
"ref": {"tab": "sg_list", "key": "D01341"}},
|
|
{"key": "earth:5", "section": "1.2", "name": "절토(연암)", "unit": "㎥", "qty": 972,
|
|
"ref": None,
|
|
"why": "조합 밖 토공 산근 — 연결 없음"},
|
|
] # fmt: skip
|
|
HOPYO = {
|
|
"B00194": {"labor": 21936, "material": 3885, "expense": 0}, # 일위대가목록표 F8~H8
|
|
"B00467": {"labor": 33032, "material": 6942, "expense": 0},
|
|
"D01341": {"labor": 1116, "material": 360, "expense": 463}, # 단가산출근거목록표 16줄
|
|
}
|
|
INPUTS = {"materials": MATERIALS, "bill": {"items": ITEMS}, "hopyo": HOPYO}
|
|
USER = {
|
|
"materials": {**{k: {"supply": "관급"} for k, *_ in OWNER}, "b08:sand": {"supply": "별도"},
|
|
"b08:rubble": {"supply": "별도"}},
|
|
"estimate": {"procurement_fee_pct": 0.54},
|
|
} # fmt: skip
|
|
|
|
|
|
def _run_ts(inputs, user):
|
|
files = ["B09_Estimation_Calc_Base.ts", "B09_Estimation_Calc_Bill.ts"]
|
|
source = "".join(
|
|
IMPORTS.sub("", (ROOT / "B09_Estimation" / f).read_text(encoding="utf-8")) for f in files
|
|
)
|
|
body = (
|
|
f"\nconst i = {json.dumps(inputs, ensure_ascii=False)};"
|
|
f"\nconst u = {json.dumps(user, ensure_ascii=False)};"
|
|
"\nconsole.log(JSON.stringify(computeEstimation(i, u)));"
|
|
)
|
|
result = subprocess.run(
|
|
["node", "--experimental-strip-types", "--no-warnings",
|
|
"--input-type=module-typescript", "-e", source + body],
|
|
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
|
) # fmt: skip
|
|
assert result.returncode == 0, result.stderr
|
|
return json.loads(result.stdout)
|
|
|
|
|
|
def _rows(sheets):
|
|
return {row["key"]: row for row in sheets["bill"]["rows"]}
|
|
|
|
|
|
def test_호표_단가_줄_엑셀_금액():
|
|
rows = _rows(compute_estimation(INPUTS, USER))
|
|
row = rows["earth:1"]
|
|
assert (row["price_sum"], row["labor"], row["material"], row["expense"], row["sum"]) == (
|
|
25821, 307104, 54390, 0, 361494,
|
|
) # fmt: skip # 설계내역서 F9 · I9 · K9 · M9 · G9
|
|
assert rows["earth:2"]["sum"] == 1119272 # G10
|
|
row = rows["earth:4"]
|
|
assert (row["labor"], row["material"], row["expense"], row["sum"]) == (
|
|
6333300, 2043000, 2627525, 11003825,
|
|
) # fmt: skip # 설계내역서 I14 · K14 · M14 · G14
|
|
assert rows["earth:5"]["sum"] is None and rows["earth:5"]["qty"] == 972 # 단가 연결 없음
|
|
|
|
|
|
def test_관급자재대_조달수수료_엑셀():
|
|
sheets = compute_estimation(INPUTS, USER)
|
|
rows = _rows(sheets)
|
|
assert rows["mat:b08:pipe_800"]["material"] == 33284000 # K247
|
|
assert rows["mat:b08:remicon_25_18_80"]["section"] == "G"
|
|
fee = rows["fee"]
|
|
assert (fee["qty"], fee["price_material"], fee["material"]) == (0.54, 69474220, 375160) # 253줄
|
|
assert rows["sec:G"]["material"] == 69849380 # K245 관급자재대
|
|
assert "(제외금액)" in rows["sec:G"]["note"]
|
|
owner = sheets["bill"]["totals"]["owner"]
|
|
assert owner == {"net_material": 69474220, "fee": 375160, "total": 69849380}
|
|
# 「별도」 = 4 자재대 · 단가 없는 별도 자재 = 빈 줄
|
|
assert rows["mat:b08:sand"]["section"] == "4" and rows["mat:b08:sand"]["material"] == 480000
|
|
assert rows["mat:b08:rubble"]["sum"] is None and rows["sec:4"]["blank"] == 1
|
|
|
|
|
|
def test_제목_합_빈_줄과_원가계산서_이음():
|
|
sheets = compute_estimation(INPUTS, USER)
|
|
rows = _rows(sheets)
|
|
keys = [row["key"] for row in sheets["bill"]["rows"] if row["kind"] == "title"]
|
|
assert keys[:3] == ["sec:1", "sec:1.1", "sec:1.2"] and keys[-1] == "sec:G"
|
|
assert rows["sec:1.1"]["sum"] == 361494 + 1119272 and rows["sec:1.1"]["blank"] == 0
|
|
assert rows["sec:1"]["sum"] == 361494 + 1119272 + 11003825 # 빈 줄은 0 이 아니라 빈 줄 수
|
|
assert rows["sec:1"]["blank"] == 1 and "빈 줄 1" in rows["sec:1"]["note"]
|
|
# 145-4 이음 = {material, labor, expense, supply_net} · 빈 줄 있으면 None
|
|
assert sheets["bill"]["sums"] == {
|
|
"material": None,
|
|
"labor": None,
|
|
"expense": None,
|
|
"supply_net": 69474220,
|
|
}
|
|
full = {**INPUTS, "bill": {"items": ITEMS[:3]}, "materials": MATERIALS[:-1]}
|
|
sums = compute_estimation(full, USER)["bill"]["sums"]
|
|
assert sums["labor"] == 307104 + 924896 + 6333300 # I9 + I10 + I14
|
|
assert sums["material"] == 54390 + 194376 + 2043000 + 480000
|
|
assert sums["expense"] == 2627525
|
|
|
|
|
|
def test_조달수수료_율_빈칸이면_빈칸():
|
|
user = {**USER, "estimate": {}}
|
|
sheets = compute_estimation(INPUTS, user)
|
|
fee = _rows(sheets)["fee"]
|
|
assert fee["material"] is None and fee["qty"] is None
|
|
assert ("bill", "fee") in {(g["tab"], g["key"]) for g in sheets["gaps"]}
|
|
assert sheets["bill"]["totals"]["owner"]["total"] == 69474220
|
|
|
|
|
|
def test_항목_짜기_B08_수량():
|
|
earthwork = {"summary": [
|
|
{"group": "흙깍기", "kind": "토사", "spec": "기계(굴삭기)", "unit": "㎥", "value": 5675},
|
|
{"group": "흙깍기", "kind": "연암", "spec": "굴삭기 + 브레카", "unit": "㎥", "value": 972},
|
|
{"group": "토사운반", "kind": "", "spec": "BH+DT+BH", "unit": "㎥", "value": 120},
|
|
{"group": "토사운반", "kind": "", "spec": "19 TON B/D", "unit": "㎥", "value": 80},
|
|
{"group": "구조물터파기", "kind": "토사", "spec": "기계(굴삭기)", "unit": "㎥",
|
|
"value": None},
|
|
]} # fmt: skip
|
|
summary = {
|
|
"columns": [
|
|
{"id": "pipe:revet|돌쌓기(찰)|2.0", "group": ["흙막이", "찰쌓기", "H=2.0"],
|
|
"label": "m", "combos": ["기슭막이 찰"]},
|
|
{"id": "pipe:len|800", "group": ["관 공", "Φ800m/m", "관매설"], "label": "m"},
|
|
{"id": "pipe:cp|800", "group": ["관 공", "Φ800m/m", "커플링"], "label": "ea"},
|
|
{"id": "pipe:chute|찰붙임", "group": ["돌붙임", "찰붙임"], "label": "㎡"},
|
|
],
|
|
"total": {"pipe:revet|돌쌓기(찰)|2.0": 20, "pipe:len|800": 84, "pipe:cp|800": 9},
|
|
} # fmt: skip
|
|
materials = {"material": {"rows": [
|
|
{"key": "sand", "amount": 16}, {"key": "rebar_d13", "amount": 0.5},
|
|
{"key": "rebar_d16", "amount": 0.25}, {"key": "gravel", "amount": None},
|
|
]}} # fmt: skip
|
|
items = {i["key"]: i for i in bill_items({"earthwork": earthwork, "summary": summary,
|
|
"materials": materials})["items"]} # fmt: skip
|
|
assert items["earth:4"]["qty"] == 5675 and items["earth:4"]["name"] == "절토(토사)"
|
|
assert items["earth:5"]["qty"] == 972 and items["earth:6"]["qty"] is None # 경암 줄 없음
|
|
assert items["earth:13"]["qty"] == 80 and items["earth:14"]["qty"] == 120 # 도자 · 덤프
|
|
assert items["earth:7"]["qty"] is None # 구조물터파기 = B08 도 빈칸
|
|
assert items["earth:1"]["why"].startswith("준비공") and items["earth:4"]["ref"] is None
|
|
st = {k: v for k, v in items.items() if k.startswith("st:")}
|
|
assert [(v["section"], v["qty"]) for v in st.values()] == [
|
|
("2.1", 20),
|
|
("2.2", 84),
|
|
("2.3", None),
|
|
]
|
|
assert "st:pipe:cp|800" not in items # 커플링 = 관급 자재
|
|
assert items["st:pipe:revet|돌쌓기(찰)|2.0"]["ref"] == {
|
|
"tab": "dg_list",
|
|
"key": "pipe:revet|돌쌓기(찰)|2.0",
|
|
}
|
|
haul = {v["name"]: v["qty"] for k, v in items.items() if k.startswith("haul:")}
|
|
assert haul == {"중기운반": None, "기계운반": None, "모래운반": 16, "자갈운반": None,
|
|
"시멘트운반": None, "철근운반": 0.75} # fmt: skip
|
|
|
|
|
|
def test_거울_TS_가_같은_값():
|
|
for user in (USER, {**USER, "estimate": {}}, {}):
|
|
assert _run_ts(INPUTS, user) == compute_estimation(INPUTS, user)
|