"""121-1 B08 총괄자재 · 콘크리트소요자재 · 자재수량 집계표 — 2공구 꼴 견본 수치 · 거울 · 저장. 견본 = 실무 `4.2 수량산출(2공구).xlsx` 의 구조물 집계표 계 · 단위수량 (기슭막이 · 돌붙임 · 관공 · 돌집수정) → 자재수량집계표 66행 계 · 콘크리트소요자재집계표 11행 · 총괄자재수량집계표 G열과 맞춤. 거울 = `B08_Quantity_Material_Calc.ts` 를 node 로 돌려 파이썬 짝과 같은 결과. """ import asyncio 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 B08_Quantity import B08_Quantity_Router_Material as router_mod # noqa: E402 from B08_Quantity.B08_Quantity_Material_Calc import clean_overrides, compute # noqa: E402 IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) def _col(cid, group, label, total): return cid, {"id": cid, "group": group, "label": label}, total # (열, 계, 단위수량) — 실무 엑셀 구조물집계표 16행 · 자재수량집계표 단위수량 줄 SAMPLE = [ (_col("pipe:revet|돌쌓기(찰)|2.0", ["흙막이", "찰쌓기", "H=2.0"], "m", 20), {"remicon_25_18_80": 0.31, "concrete_machine": 0.42, "mortar_1_3": 0.019, "stone_buy_45": 1.92, "bed_stone": 0.31, "rubble": 0.75}), (_col("pipe:revet|돌쌓기(메)|1.5", ["흙막이", "메쌓기", "H=1.5"], "m", 20), {"remicon_25_18_80": 0.31, "stone_pick_45": 0.54, "bed_stone": 0.24, "rubble": 0.56}), (_col("pipe:revet|돌쌓기(메)|2.0", ["흙막이", "메쌓기", "H=2.0"], "m", 60), {"remicon_25_18_80": 0.31, "stone_pick_45": 0.72, "bed_stone": 0.31, "rubble": 0.75}), (_col("pipe:chute|찰붙임", ["돌붙임", "찰붙임"], "㎡", 20), {"concrete_machine": 0.16, "mortar_1_3": 0.009, "stone_buy_35": 0.71, "bed_stone": 0.12}), (_col("pipe:chute|메붙임", ["돌붙임", "메붙임"], "㎡", 90), {"stone_pick_35": 0.27, "bed_stone": 0.12}), (_col("pipe:len|800", ["관 공", "Φ800m/m", "관매설"], "m", 84), None), (_col("pipe:cp|800", ["관 공", "Φ800m/m", "커플링"], "ea", 9), None), (_col("pipe:basin|800|U", ["관 공", "Φ800m/m", "관보호공"], "돌집수정ㄷ형", 2), {"concrete_machine": 4.03, "mortar_1_3": 0.157, "stone_pick_35": 1.71, "stone_pick_45": 13.8, "bed_stone": 2.54, "rubble": 3.75}), (_col("pipe:basin|800|L", ["관 공", "Φ800m/m", "관보호공"], "돌집수정ㄴ형", 3), {"concrete_machine": 2.69, "mortar_1_3": 0.096, "stone_pick_35": 1.71, "stone_pick_45": 7.6, "bed_stone": 1.53, "rubble": 2.08}), (_col("pipe:len|1000", ["관 공", "Φ1000m/m", "관매설"], "m", 16), None), (_col("pipe:cp|1000", ["관 공", "Φ1000m/m", "커플링"], "ea", 2), None), (_col("pipe:len|1200", ["관 공", "Φ1200m/m", "관매설"], "m", 22), None), (_col("pipe:cp|1200", ["관 공", "Φ1200m/m", "커플링"], "ea", 3), None), ] # fmt: skip SUMMARY = { "columns": [col for (_, col, _), _ in SAMPLE], "total": {cid: total for (cid, _, total), _ in SAMPLE}, } UNITS = {cid: units for (cid, _, _), units in SAMPLE if units} #: 실무 엑셀 ㎥당 배합(모래 · 자갈 ㎥ 은 엑셀 값) — 엑셀 계와 맞춰 보기 EXCEL_MIX = {"c40": {"sand": 0.48, "gravel": 0.65}} #: 실무 엑셀 할증(모래 6 · 자갈 4 · 시멘트 2) — 기본은 좌측 패널 12 · 5 · 3(124) EXCEL_PANEL = {"sand": 6, "gravel": 4, "cement": 2} def _over(**parts): return clean_overrides({"units": UNITS, **parts}) def _run_ts(summary, overrides): calc = ROOT / "B08_Quantity" / "B08_Quantity_Material_Calc.ts" body = ( f"\nconst s = {json.dumps(summary, ensure_ascii=False)};" f"\nconst o = {json.dumps(overrides, ensure_ascii=False)};" "\nconsole.log(JSON.stringify(computeMaterialSheets(s, o)));" ) script = IMPORTS.sub("", calc.read_text(encoding="utf-8")) + body result = subprocess.run( ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript", "-e", script], capture_output=True, text=True, encoding="utf-8", timeout=60, ) # fmt: skip assert result.returncode == 0, result.stderr return json.loads(result.stdout) def _total_rows(sheets): return {row["key"]: row for row in sheets["material"]["rows"]} def test_자재수량_계가_엑셀_66행과_같다(): sheets = compute(SUMMARY, _over()) total = sheets["quantity"]["total"] assert total["remicon_25_18_80"] == 31 # H66 assert total["concrete_machine"] == 27.73 # I66 assert total["mortar_1_3"] == 1.162 # R66 assert total["stone_buy_35"] == 14.2 and total["stone_buy_45"] == 38.4 # W66 · X66 assert total["stone_pick_35"] == 32.85 and total["stone_pick_45"] == 104.4 # Y66 · Z66 assert total["bed_stone"] == 52.47 and total["rubble"] == 84.94 # AA66 · AB66 assert total["pipe_800"] == 84 and total["pipe_1000"] == 16 and total["pipe_1200"] == 22 # 수량 = C × 단위수량 · 커플링은 자재 줄이 아님 · 관보호공 모양은 규격으로 rows = {row["id"]: row for row in sheets["quantity"]["rows"]} assert "pipe:cp|800" not in rows revet = rows["pipe:revet|돌쌓기(찰)|2.0"] assert revet["qty"] == 20 and revet["amounts"]["stone_buy_45"] == 38.4 assert revet["amounts"]["sod"] is None # 빈 단위수량 = 빈칸(0 아님) basin = rows["pipe:basin|800|U"] assert (basin["name"], basin["spec"], basin["unit"]) == ( "관 공", "Φ800m/m 관보호공 돌집수정ㄷ형", "개소", ) keys = [m["key"] for m in sheets["quantity"]["materials"]] assert keys.index("rebar_d25") + 1 == keys.index("pipe_800") assert keys.index("pipe_1200") + 1 == keys.index("mortar_1_3") def test_콘크리트소요_엑셀_배합이면_엑셀_계(): sheets = compute(SUMMARY, _over(mix=EXCEL_MIX), EXCEL_PANEL) total = sheets["concrete"]["total"] assert total["cement"] == 9549.41 # E11 assert total["sand"] == 14.5886 # G11 assert total["gravel"] == 18.0245 # I11 rows = _total_rows(sheets) assert rows["sand"]["amount"] == 16 and rows["gravel"]["amount"] == 19 # G4 · G5 assert rows["cement"]["qty"] == 238.73525 and rows["cement"]["amount"] == 244 # G18 def test_기본_배합_원문값(): sheets = compute(SUMMARY, _over()) per = {row["key"]: row["per"] for row in sheets["concrete"]["rows"]} assert per["c40"] == {"cement": 323, "sand": 0.456, "gravel": 0.612} # 775/1700 · 1101/1800 assert per["c25"] == {"cement": 346, "sand": 0.487, "gravel": 0.562} assert per["c40_plain"] == {"cement": None, "sand": None, "gravel": None} # 근거 없음 assert per["m12"]["cement"] == 680 and per["m13"] == { "cement": 510, "sand": 1.1, "gravel": None, } amounts = {row["key"]: row for row in sheets["concrete"]["rows"]} assert amounts["c40"]["qty"] == 27.73 and amounts["m13"]["qty"] == 1.162 assert amounts["c25"]["qty"] is None # 끌어올 열 없음 assert amounts["c40"]["amounts"]["cement"] == 8956.79 # 323 × 27.73 def test_총괄_할증_올림_관_커플링(): rows = _total_rows(compute(SUMMARY, _over())) # 파형강관 = 구조물 집계표 관매설 계 · 커플링 = 집계표 계 그대로 · 관급 할증 0 assert [rows[f"pipe_{d}"]["amount"] for d in (800, 1000, 1200)] == [84, 16, 22] assert [rows[f"coupling_{d}"]["amount"] for d in (800, 1000, 1200)] == [9, 2, 3] assert rows["pipe_800"]["surcharge"] == 0 # 레미콘 25-18 = ROUND(31 × 1.02, 2) = 31.62 — 엑셀 G11 꼴(131) assert rows["remicon_25_18_80"]["surcharge"] == 2 assert rows["remicon_25_18_80"]["amount"] == 31.62 # 깬돌 = 할증 빈칸 · 올림(14.2 → 15 · 38.4 → 39) = 엑셀 G26 · G27 assert rows["stone_buy_35"]["amount"] == 15 and rows["stone_buy_45"]["amount"] == 39 assert rows["stone_buy_35"]["surcharge"] is None # 끌어올 값이 없는 줄 = 빈칸 assert rows["rebar_d13"]["qty"] is None and rows["rebar_d13"]["amount"] is None assert rows["sod"]["surcharge"] == 10 and rows["cement"]["surcharge"] == 3 def test_사용자_값이_기본을_이김(): over = _over( surcharge={"stone_buy_45": 5, "pipe_800": None}, mix={"c40_plain": {"cement": 312}}, units={**UNITS, "pipe:len|800": {"pipe_800": None}, "pipe:chute|메붙임": {"sod": 1}}, ) sheets = compute(SUMMARY, over) rows = _total_rows(sheets) assert rows["stone_buy_45"]["amount"] == 41 # 올림(38.4 × 1.05 = 40.32) assert rows["pipe_800"]["qty"] is None # 관매설 단위수량을 비우면 파형강관도 빈칸 assert rows["sod"]["qty"] == 90 and rows["sod"]["amount"] == 99 # 떼 10 % plain = next(r for r in sheets["concrete"]["rows"] if r["key"] == "c40_plain") assert plain["per"]["cement"] == 312 def test_다듬기(): out = clean_overrides( {"units": {"a": {"x": "1", "y": True, "z": None, "w": 2}, "b": "깨짐"}, "mix": {"c40": {"cement": float("nan")}}, "surcharge": {"sand": 7, "bad": "x"}, "etc": 1} ) # fmt: skip assert out == {"units": {"a": {"z": None, "w": 2.0}}, "mix": {}, "surcharge": {"sand": 7.0}} assert clean_overrides(None) == {"units": {}, "mix": {}, "surcharge": {}} def test_거울_TS와_파이썬이_같다(): for over in ( _over(), _over(mix=EXCEL_MIX, surcharge={"sand": 12, "pipe_1000": 3}), clean_overrides({}), ): assert _run_ts(SUMMARY, over) == compute(SUMMARY, over) empty = {"columns": [], "total": {}} assert _run_ts(empty, clean_overrides({})) == compute(empty, clean_overrides({})) def test_저장은_고친_칸만_서버가_다시_셈(tmp_path, monkeypatch): async def summary(_project_id): return {**SUMMARY, "rows": [], "notes": []} async def project_root(_project_id): return tmp_path monkeypatch.setattr(router_mod, "structure_summary", summary) monkeypatch.setattr(router_mod, "_project_root", project_root) first = asyncio.run(router_mod.material_sheets("p")) assert first["overrides"] == {"units": {}, "mix": {}, "surcharge": {}} assert _total_rows(first["sheets"])["pipe_800"]["amount"] == 84 body = {"overrides": {"units": UNITS, "surcharge": {"sand": 12}}} saved = asyncio.run(router_mod.save_material_sheets("p", body)) assert saved["sheets"] == compute(SUMMARY, clean_overrides(body["overrides"])) stored = json.loads((tmp_path / "project_settings.json").read_text(encoding="utf-8")) sheet = stored["quantity"]["material_sheets"] assert sheet["surcharge"] == {"sand": 12} and sheet["mix"] == {} # 기본값은 안 씀 # 지운 칸은 지워짐(통째 갈아 끼움) asyncio.run(router_mod.save_material_sheets("p", {"overrides": {"units": {}}})) again = asyncio.run(router_mod.material_sheets("p")) assert again["overrides"] == {"units": {}, "mix": {}, "surcharge": {}} def test_화면_연결(): page = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Page.ts").read_text(encoding="utf-8") assert "renderMaterialSheet(body, projectId, picked)" in page ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Material.ts").read_text(encoding="utf-8") assert "computeMaterialSheets(" in ui # 조작 중 = 짝 계산 assert 'method: "PUT"' in ui and "setTimeout" not in ui # [저장]만 · 자동 저장 없음 assert "일위대가 재료와 별개" in ui main = (ROOT / "main.py").read_text(encoding="utf-8") assert "include_router(b08_material_router" in main state = (ROOT / "A00_Common" / "b_page_state.ts").read_text(encoding="utf-8") assert '"b08-materials": { bucket: "draft", scope: "project" }' in state def test_공용_표_고칠_칸(): table = ROOT / "ui_template" / "ui_template_data_table.ts" body = """ const html = renderDataTableHtml({ columns: [ { key: "a", label: "A" }, { key: "b", label: "B", kind: "number", decimals: 3, trim: true }, ], rows: [{ a: "x", b: 0.31 }, { a: "y", b: 6.2 }], total: { a: "계", b: 6.51 }, editable: (i, key) => i === 0 && key === "b", }); console.log(JSON.stringify(html)); """ script = IMPORTS.sub("", table.read_text(encoding="utf-8")) + body result = subprocess.run( ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript", "-e", script], capture_output=True, text=True, encoding="utf-8", timeout=60, ) # fmt: skip assert result.returncode == 0, result.stderr html = json.loads(result.stdout) assert '6.2' in html # 끝 0 지움(6.200 아님)