Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1

This commit is contained in:
2026-10-08 20:24:15 +09:00
13 changed files with 415 additions and 66 deletions
@@ -150,8 +150,9 @@ def test_총괄_할증_올림_관_커플링():
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 = 올림(31 × 1.02) — 엑셀은 ROUND 2 (31.62) · 지시 = 올림
assert rows["remicon_25_18_80"]["surcharge"] == 2 and rows["remicon_25_18_80"]["amount"] == 32
# 레미콘 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
@@ -0,0 +1,215 @@
"""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
@@ -45,6 +45,8 @@ _COMPUTED_FIELDS = {
"box_ground_slope_pct",
# BOX 본체 길이(120) — 같은 다리.
"box_length_m",
# 세월교 관 한 련 길이(131) — 같은 다리.
"ford_pipe_length_m",
}