Files
Aislo/resources/tester/test_145_3_b09_bill.py
T
eomsangdonandClaude Opus 5.5 489a682794 feat(B09): 설계내역서 — 공종 나무 엑셀 꼴 · B08 수량 · 호표 단가 · 관급 · 원가계산서 이음(145-3)
- 공종 나무 = 엑셀 꼴 고정(토공사 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
2026-10-09 15:13:05 +09:00

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)