"""131-1 B08 자재 집계표 고침 — 끝수 줄마다 · 막자갈 줄 · 세월교 관 길이 → 파형강관 · 커플링 · 안내 글. 견본 = test_121 의 2공구 꼴 · test_120 의 세월교(400 · Φ1000×2련) · B06 견본 횡단(173.087 세월교). """ from __future__ import annotations import asyncio import copy import json import re import subprocess from pathlib import Path import pytest from test_120_b08_drain_summary import POINTS, STRUCTURES from test_121_b08_material_sheets import SUMMARY, UNITS, _run_ts from B08_Quantity import B08_Quantity_Router_Material as router_mod from B08_Quantity import B08_Quantity_Router_Summary as summary_mod from B08_Quantity.B08_Quantity_Material_Calc import clean_overrides, compute, finish from M02_MasterTemplete import M02_Item_Snapshot as snapshots from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition ROOT = Path(__file__).resolve().parents[2] FIXTURES = Path(__file__).parent / "fixtures" IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) needs_node = pytest.mark.skipif( not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치" ) def _rows(sheets): return {row["key"]: row for row in sheets["material"]["rows"]} # ── 끝수 처리 줄마다(엑셀 G열 꼴) ────────────────────────────────────────── def test_끝수_규칙(): assert finish(31.62, "r2") == 31.62 and finish(31.625, "r2") == 31.63 # 반 올림 assert finish(1.271123, "r3") == 1.271 and finish(1.271123, "u0") == 2 assert finish(243.4, "r0") == 243 and finish(243.5, "r0") == 244 assert finish(15.0000000001, "u0") == 15 # 떠도는 끝수는 안 올림 #: 철근 D13 1234.1 kg · 와이어메쉬 10.0004 ㎡ · 레미콘 25-24 10.004 ㎥ — 규칙마다 갈리는 값 EXTRA = {"pipe:chute|메붙임": {"rebar_d13": 1234.1 / 90, "wire_mesh": 10.0004 / 90}} def _extra_over(): units = { **UNITS, "pipe:chute|메붙임": {**UNITS["pipe:chute|메붙임"], **EXTRA["pipe:chute|메붙임"]}, } units["pipe:revet|돌쌓기(찰)|2.0"] = { **UNITS["pipe:revet|돌쌓기(찰)|2.0"], "remicon_25_24_15": 10.004 / 20, } return clean_overrides({"units": units}) def test_총괄_끝수_줄마다(): rows = _rows(compute(SUMMARY, _extra_over())) assert rows["remicon_25_18_80"]["amount"] == 31.62 # ROUND(31 × 1.02, 2) assert rows["remicon_25_24_15"]["amount"] == 10.1 # ROUND(10.004 × 1.01, 2) = 10.10 assert rows["rebar_d13"]["amount"] == 1.271 # ROUND(1.2341 × 1.03, 3) assert rows["wire_mesh"]["amount"] == 10.0 # ROUND(10.0004, 3) — 올림 0 이면 11 cement = rows["cement"] assert cement["amount"] == round(cement["qty"] * 1.03) # ROUND 0 assert rows["stone_buy_45"]["amount"] == 39 # 그 밖 = 올림(38.4) assert (cement["rounding"], rows["sod"]["rounding"]) == ("round", "up") assert (rows["rebar_d13"]["places"], rows["remicon_25_18_80"]["places"]) == (3, 2) def test_막자갈_줄_할증_4_수량_빈칸(): sheets = compute(SUMMARY, clean_overrides({"units": UNITS})) keys = [row["key"] for row in sheets["material"]["rows"]] assert keys[:4] == ["sand", "gravel", "rubble", "mixed_aggregate"] rubble = _rows(sheets)["rubble"] assert (rubble["group"], rubble["spec"], rubble["unit"]) == ("골재", "막자갈", "㎥") assert rubble["surcharge"] == 4 assert rubble["qty"] is None and rubble["amount"] is None # 채집 — 자재수량 계가 있어도 assert sheets["quantity"]["total"]["rubble"] == 84.94 def test_거울_끝수_막자갈(): for over in (_extra_over(), clean_overrides({"units": UNITS})): assert _run_ts(SUMMARY, over) == compute(SUMMARY, over) # ── 세월교 관 길이 → 구조물 집계표 관매설 · 커플링 → 총괄 파형강관 · 커플링 ──────── def _ford_summary(tmp_path: Path, designs: list[dict]) -> dict: root = tmp_path / "proj" edits = root / "B04_PreProcess/drainage/edits" edits.mkdir(parents=True) points = [{**p, "source": "user"} for p in POINTS] (edits / "pipe_points.json").write_text( json.dumps({"points": points}, ensure_ascii=False), encoding="utf-8" ) route = root / "B05_Profile/route" route.mkdir(parents=True) (route / "structures.json").write_text( json.dumps({"revision": 1, "structures": STRUCTURES}, ensure_ascii=False), encoding="utf-8", ) names = definition.read()["종류"]["pipe"]["이름"] linked = {owner: {} for owner in snapshots.assignments(root)} # 143-1 지정 전부 사본 있음 return summary_mod.pipe_summary(root, designs, names, 6.0, linked=linked) def _cell(out: dict, at: float, cid: str): row = next(r for r in out["rows"] if abs(r["chainage_m"] - at) < 0.01) return row["values"].get(cid) def test_세월교_관_길이_련수만큼_관매설_커플링(tmp_path): designs = [ {"chainage_m": 300.0, "design": {"pipe_length_m": 18, "box_length_m": 9.6}}, {"chainage_m": 400.0, "design": {"ford_pipe_length_m": 9}}, ] out = _ford_summary(tmp_path, designs) assert _cell(out, 400, "pipe:len|1000") == 18 # 9 m × 2련 assert _cell(out, 400, "pipe:cp|1000") == 2 # (올림(9 ÷ 6) − 1) × 2련 column = next(c for c in out["columns"] if c["id"] == "pipe:len|1000") assert column["group"] == ["관 공", "Φ1000m/m", "관매설"] fords = [p for p in out["pipes"] if p["column"] == "pipe:cp|1000"] assert len(fords) == 2 and all(p["length_m"] == 9 for p in fords) # 1본 다시 셈 = 련마다 assert not any("세월교 관 길이" in n for n in out["notes"]) sheets = compute({"columns": out["columns"], "total": out["total"]}, clean_overrides({})) rows = _rows(sheets) assert rows["pipe_1000"]["amount"] == 18 and rows["coupling_1000"]["amount"] == 2 assert rows["pipe_800"]["amount"] == 18 # 관 몫은 그대로 def test_세월교_관_길이_없으면_빈칸_알림(tmp_path): out = _ford_summary(tmp_path, [{"chainage_m": 300.0, "design": {"pipe_length_m": 18}}]) assert _cell(out, 400, "pipe:len|1000") is None assert any("세월교 관 길이 없음 1곳" in n for n in out["notes"]) @needs_node def test_서버가_세월교_관_길이를_주인_측점에만(): from B06_Section.B06_Section_Server_Calc_Prebuild import _NPM_SCRIPT, BUNDLE from common_util.common_util_node_bundle import run_bundle_json data = json.loads((FIXTURES / "b06_73_4_body_guard_sections.json").read_text("utf-8")) sections = data["cross_sections"] ford_at = next(s for s in sections if s.get("ford")) neighbour = next(s for s in sections if s["chainage_m"] == 180) neighbour["ford"] = copy.deepcopy(ford_at["ford"]) output = run_bundle_json(BUNDLE, _NPM_SCRIPT, {"detail": {"cross_sections": sections}}) rows = {round(r["chainage_m"], 3): r for r in output["areas"]} length = rows[173.087]["ford_pipe_length_m"] assert length > 0 and length == int(length) # 1 m 올림 assert "ford_pipe_length_m" not in rows.get(180, {}) def test_B06_새_키_한_벌(): from B06_Section.B06_Section_Schema import CrossSectionPatch from B06_Section.B06_Section_Server_Calc_Prebuild import _AREA_KEYS assert "ford_pipe_length_m" in _AREA_KEYS assert CrossSectionPatch(chainage_m=1, ford_pipe_length_m=9).ford_pipe_length_m == 9 with pytest.raises(ValueError): CrossSectionPatch(chainage_m=1, ford_pipe_length_m=-1) layouts = (ROOT / "B06_Section" / "B06_Section_Structure_Layouts.ts").read_text("utf-8") block = layouts[layouts.index("STRUCTURE_ROW_KEYS = [") :][:900] assert '"ford_pipe_length_m"' in block # ── 안내 글 · 열 머리 줄바꿈 ───────────────────────────────────────────── def test_단위수량_빈_줄_수_TS(): calc = ROOT / "B08_Quantity" / "B08_Quantity_Material_Calc.ts" full = json.dumps({"units": UNITS, "mix": {}, "surcharge": {}}, ensure_ascii=False) body = ( f"\nconst s = {json.dumps(SUMMARY, ensure_ascii=False)};" f"\nconst full = computeMaterialSheets(s, {full});" "\nconst none = computeMaterialSheets(s, {units: {}, mix: {}, surcharge: {}});" "\nconsole.log(JSON.stringify([blankUnitRows(full), blankUnitRows(none)]));" ) 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 # 자재 줄 10(관매설 3 은 기본 단위 1) — 다 넣으면 0 · 안 넣으면 관매설 뺀 7 assert json.loads(result.stdout) == [0, 7] def test_서버_알림이_자재_응답에(tmp_path, monkeypatch): async def summary(_project_id): return {**SUMMARY, "rows": [], "notes": ["집수정 모양 기록 없음 9곳 — B06 횡단 [저장] 뒤"]} async def project_root(_project_id): return tmp_path monkeypatch.setattr(router_mod, "structure_summary", summary) monkeypatch.setattr(router_mod, "_project_root", project_root) payload = asyncio.run(router_mod.material_sheets("p")) assert payload["notes"] == ["집수정 모양 기록 없음 9곳 — B06 횡단 [저장] 뒤"] def test_화면_안내_글_열_머리(): ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Material.ts").read_text(encoding="utf-8") assert "단위수량 빈 줄 ${blank}개 — 자재수량 탭에서 넣음" in ui assert "payload.notes" in ui and 'sheet !== "quantity"' in ui css = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Page.css").read_text(encoding="utf-8") assert "[data-b08-material] thead th" in css