- 항목 짜기 = 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
255 lines
13 KiB
Python
255 lines
13 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)
|
||
|
||
|
||
#: 사본 주인 → 대가 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)
|