Files
Aislo/resources/tester/test_145_3_b09_bill.py
T
eomsangdonandClaude Opus 5.5 03fdde07fb feat(B09): 설계내역서 구조물 줄 = 대가 블록마다 나눔 — B08 열 owners 잇기(145-3)
- 항목 짜기 = B08 구조물 집계표 열 owners {사본 주인 id: 값}(sub8 덧칸) 를 싣고
- 짝 셈 split_by_block / splitByBlock = 주인마다 대가 단가(price_of = 145-2 daega_price 자리) → 같은 대가 블록끼리 한 줄(수량 합 ROUND 3) · 대가 없는 주인 = 한 줄 「연결 없음」
- price_of 없으면 열 하나 그대로(단가 빈칸)
- 시험 = test_145_3_b09_bill 더함(대가17 × 20 = 2,491,880 · 대가24 × 12.5 = 2,078,274 · 거울)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X
2026-10-09 15:24:46 +09:00

255 lines
13 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.
"""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)
#: 사본 주인 → 대가 1 단위(145-2 `daega_price` 자리 견본) · 엑셀 일위대가목록표 대가17 · 24
DAEGA = {
"pipe@100.0__gsoiladd1": {"no": "대가17", "labor": 96868, "material": 11309, "expense": 16417},
"pipe@200.0__gsoiladd1": {"no": "대가17", "labor": 96868, "material": 11309, "expense": 16417},
"pipe@300.0__gsoiladd1": {"no": "대가24", "labor": 129370, "material": 15059, "expense": 21833},
}
REVET = {
"key": "st:pipe:revet|돌쌓기(찰)|2.0", "section": "2.1", "name": "흙막이",
"spec": "찰쌓기 H=2.0", "unit": "m", "qty": 42.5, "note": "",
"ref": {"tab": "dg_list", "key": "pipe:revet|돌쌓기(찰)|2.0"},
"owners": {"pipe@100.0__gsoiladd1": 10, "pipe@300.0__gsoiladd1": 12.5,
"pipe@200.0__gsoiladd1": 10, "pipe@400.0__gsoiladd1": 10},
} # fmt: skip
SPLIT_INPUTS = {**INPUTS, "bill": {"items": [REVET]}}
def test_구조물_열_대가_블록마다_나눔():
sheets = compute_estimation(SPLIT_INPUTS, USER, DAEGA.get)
rows = [r for r in sheets["bill"]["rows"] if r["key"].startswith("st:")]
got = [(r["key"].split("#")[-1], r["qty"], r["sum"], r["note"]) for r in rows]
assert got == [
("대가17", 20, 2491880, "대가17"), # 124,594 × 20 — 비목마다 ROUNDDOWN
("대가24", 12.5, 2078274, "대가24"), # 129,370 · 15,059 · 21,833 × 12.5
("st:pipe:revet|돌쌓기(찰)|2.0", 10, None, ""), # 대가 없는 주인 = 「연결 없음」
]
assert rows[0]["labor"] == 1937360 and rows[1]["material"] == 188237 # 15,059 × 12.5 버림
assert ("bill", "st:pipe:revet|돌쌓기(찰)|2.0") in {
(g["tab"], g["key"]) for g in sheets["gaps"]
}
# 대가 잇기 전(price_of 없음) = 열 하나 그대로 · 단가 빈칸
plain = compute_estimation(SPLIT_INPUTS, USER)
one = [r for r in plain["bill"]["rows"] if r["key"].startswith("st:")]
assert [(r["qty"], r["sum"]) for r in one] == [(42.5, None)]
def test_거울_블록_나누기():
calc = "".join(
IMPORTS.sub("", (ROOT / "B09_Estimation" / f).read_text(encoding="utf-8"))
for f in ["B09_Estimation_Calc_Base.ts", "B09_Estimation_Calc_Bill.ts"]
)
body = (
f"\nconst d = {json.dumps(DAEGA, ensure_ascii=False)};"
f"\nconst i = {json.dumps(SPLIT_INPUTS, ensure_ascii=False)};"
f"\nconst u = {json.dumps(USER, ensure_ascii=False)};"
"\nconsole.log(JSON.stringify(computeEstimation(i, u, (o) => d[o] ?? null)));"
)
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", calc + body],
capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == compute_estimation(SPLIT_INPUTS, USER, DAEGA.get)