"""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)