- 총괄 끝수 = 레미콘 ROUND 2 · 철근 · 와이어메쉬 ROUND 3 · 시멘트 ROUND 0 · 그 밖 ROUNDUP 0 (TS · 파이썬 짝) - 총괄 막자갈 줄(할증 4 % · 수량 빈칸 — 채집) - 총괄 · 콘크리트소요 위 「단위수량 빈 줄 n개」 + 구조물 집계표 알림 한 줄 - B06 ford_pipe_length_m(그린 관 한 련 길이 1 m 올림 · 주인 측점만) → 구조물 집계표 관매설 · 커플링 × 련 - test_121 기대값 31.62 · 새 test_131 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
216 lines
9.6 KiB
Python
216 lines
9.6 KiB
Python
"""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.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"]["이름"]
|
||
return summary_mod.pipe_summary(root, designs, names, 6.0)
|
||
|
||
|
||
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
|