- 식 칸 옆 반올림 고르개·자리수 · 고르개에 「버림 — 엑셀 ROUNDDOWN」처럼 엑셀 이름과 음수 보기 - 고친 반올림은 고친 식과 같은 자리(양식 + 프로젝트)에 저장 · 양식과 같으면 지움 · [양식대로]로 식·반올림 함께 되돌림 - build_table 도 양식을 m당(L=1)으로 풀고 연장을 곱함 — 실무 시트처럼 뒷줄이 반올림한 값을 보고 구조물도와 안 갈림 - 「대안 후보」(물구멍 2.5㎡ 등) 표 밑에 보임 · UI 700줄 넘어 식 칸 표를 _Formula.ts 로 뗌 - 시험 2개 추가 · 전체 1533 통과 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
299 lines
13 KiB
Python
299 lines
13 KiB
Python
"""구조물도 창구(B08) — B07 에서 옮긴 뒤 조회·제원 저장이 한 벌로 도는지 (2026-09-13, PLAN 3장 ②).
|
||
|
||
겨누는 것 셋
|
||
① 조회가 장을 냄 — 옛 B07 `/standard-sheets` 자리
|
||
② ⚠ 기초잡석 두께가 **산출 조건 값을 따름** — 옛 B07 창구는 두께를 안 넘겨 늘 0.2 로 섰음
|
||
(원단위 탭과 구조물도가 같은 구조물에서 다른 값을 냈음)
|
||
③ 제원 저장이 그 장의 개소에 걸리고, 다시 조회하면 새 제원으로 섬
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
from fastapi import FastAPI
|
||
from fastapi.testclient import TestClient
|
||
|
||
ROOT = Path(__file__).resolve().parents[2]
|
||
sys.path.insert(0, str(ROOT))
|
||
|
||
import B08_Quantity.B08_Quantity_Router_Material as material_module # noqa: E402
|
||
import B08_Quantity.B08_Quantity_Router_StructureSheet as router_module # noqa: E402
|
||
from B05_Profile.B05_Profile_Structures_Repository import save_structures # noqa: E402
|
||
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance # noqa: E402
|
||
|
||
PROJECT_ID = "22222222-2222-2222-2222-222222222222"
|
||
SHEETS = f"/api/projects/{PROJECT_ID}/quantity/structure-sheets"
|
||
|
||
|
||
@pytest.fixture()
|
||
def project(tmp_path: Path) -> Path:
|
||
root = tmp_path / "project"
|
||
root.mkdir()
|
||
wall = StructureInstance.model_validate(
|
||
{
|
||
"type_id": "masonry_wet",
|
||
"placement": "interval",
|
||
"start_m": 100.0,
|
||
"end_m": 110.0,
|
||
"options": {"height_m": 2.5, "back_len_cm": 45},
|
||
}
|
||
)
|
||
save_structures(str(root), [wall], base_revision=0)
|
||
return root
|
||
|
||
|
||
@pytest.fixture()
|
||
def client(project: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient:
|
||
async def fake_root(project_id):
|
||
return str(project)
|
||
|
||
async def no_route(project_id):
|
||
return {}
|
||
|
||
monkeypatch.setattr(router_module, "_project_root", fake_root)
|
||
monkeypatch.setattr(material_module, "_section_modes", no_route)
|
||
monkeypatch.setattr(material_module, "_ground_types", no_route)
|
||
app = FastAPI()
|
||
app.include_router(router_module.router)
|
||
return TestClient(app)
|
||
|
||
|
||
def _sheet(client: TestClient) -> dict:
|
||
response = client.get(SHEETS)
|
||
assert response.status_code == 200, response.text
|
||
sheets = response.json()["sheets"]
|
||
assert len(sheets) == 1, sheets
|
||
return sheets[0]
|
||
|
||
|
||
def _unit_amount(sheet: dict, name: str) -> float:
|
||
return next(row["unit_amount"] for row in sheet["rows"] if row["name"] == name)
|
||
|
||
|
||
def test_조회가_장을_낸다(client: TestClient) -> None:
|
||
sheet = _sheet(client)
|
||
assert sheet["type_id"] == "masonry_wet"
|
||
assert sheet["member_count"] == 1
|
||
assert sheet["billing_unit"] == "m" and sheet["billing_total"] == pytest.approx(10.0)
|
||
assert all("spec" in row for row in sheet["rows"])
|
||
|
||
|
||
def test_양식이_있는_종류는_줄마다_식을_싣는다(client: TestClient) -> None:
|
||
"""PLAN 3장 ④ · 명세 13장 — 찰쌓기는 양식으로 줄이 서고 식·설명·갈 곳·출처가 실림."""
|
||
sheet = _sheet(client)
|
||
assert sheet["library_item"]["type_id"] == "masonry_wet"
|
||
rows = {row["name"]: row for row in sheet["rows"]}
|
||
돌쌓기 = rows["돌쌓기"]
|
||
assert 돌쌓기["formula"] == "H*L*SQRT(1+N^2)"
|
||
assert "비탈면적" in 돌쌓기["basis"]
|
||
assert 돌쌓기["source"] == "library" and 돌쌓기["destination"] == "unit_price"
|
||
assert all(row["destination"] for row in sheet["rows"]) # 갈 곳 빈 줄 없음
|
||
# 돌 줄은 이름 고정 — 종류를 안 골라 규격이 빔(매칭 성공 아님, 명세 13장 Ⓒ).
|
||
assert rows["돌"]["spec"] == ""
|
||
# m당 값 — 제원(H=2.5·뒷길이 45·1:0.3)으로 다시 셈한 값.
|
||
assert 돌쌓기["unit_amount"] == pytest.approx(2.5 * (1 + 0.3**2) ** 0.5)
|
||
|
||
|
||
def test_안_선_줄은_안_섬으로_실린다(client: TestClient) -> None:
|
||
"""버림을 「안 넣음」으로 저장하면 버림·기초잡석이 0 이 아니라 「안 섬」으로 옴."""
|
||
sheet = _sheet(client)
|
||
saved = client.put(
|
||
f"{SHEETS}/spec",
|
||
json={"sheet_key": sheet["key"], "base_revision": 1, "blinding_concrete": "안 넣음"},
|
||
)
|
||
assert saved.status_code == 200, saved.text
|
||
rows = {row["name"]: row for row in _sheet(client)["rows"]}
|
||
for name in ("버림콘크리트", "기초잡석"):
|
||
assert rows[name]["skipped"] is True and rows[name]["unit_amount"] is None, rows[name]
|
||
assert rows[name]["reason"], rows[name]
|
||
|
||
|
||
def test_식을_고쳐_저장하면_서버가_다시_풀고_되돌릴_수_있다(
|
||
client: TestClient, project: Path
|
||
) -> None:
|
||
"""PLAN 3장 ⑤ — 프로젝트 장 단위 저장 · 값은 서버가 다시 냄 · 고친 줄은 출처 user."""
|
||
sheet = _sheet(client)
|
||
rows = {row["name"]: row for row in sheet["rows"]}
|
||
원래 = rows["모르터"]
|
||
saved = client.put(
|
||
f"{SHEETS}/formulas",
|
||
json={"sheet_key": sheet["key"], "rows": [{"seq": 원래["no"], "formula": "A*0.018"}]},
|
||
)
|
||
assert saved.status_code == 200, saved.text
|
||
assert saved.json()["changed"] == 1 and saved.json()["errors"] == []
|
||
|
||
after = {row["name"]: row for row in _sheet(client)["rows"]}
|
||
assert after["모르터"]["unit_amount"] == pytest.approx(원래["unit_amount"] * 2)
|
||
assert after["모르터"]["source"] == "user"
|
||
assert after["모르터"]["default_formula"] == 원래["formula"]
|
||
assert after["돌쌓기"]["source"] == "library" # 안 고친 줄은 그대로 양식
|
||
stored = json.loads((project / "project_settings.json").read_text(encoding="utf-8"))
|
||
# 장 이름이 아니라 양식(type_id)에 묶임 — 브레인 판정(PLAN 3장 ⑤ ⓒ).
|
||
assert stored["quantity"]["structure_formula_overrides"]["masonry_wet"] == {
|
||
str(원래["no"]): {"formula": "A*0.018"}
|
||
}
|
||
|
||
# 원단위·자재총괄(build_table)도 같은 고친 식을 봄 — 구조물도와 안 갈림.
|
||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table
|
||
from B08_Quantity.B08_Quantity_Router_Material import _collect_structures
|
||
|
||
structures, names, _ = _collect_structures(str(project))
|
||
table = build_table(
|
||
structures,
|
||
names,
|
||
None,
|
||
None,
|
||
None,
|
||
structure_formulas=stored["quantity"]["structure_formula_overrides"],
|
||
)
|
||
mortar = next(c for c in table["structures"][0]["components"] if c["name"] == "모르터")
|
||
assert mortar["amount"] == pytest.approx(after["모르터"]["unit_amount"] * 10.0)
|
||
assert mortar["source"] == "user" and "사용자 식" in mortar["basis"]
|
||
|
||
# 되돌리기 — 빈 식이면 양식 식으로, 저장 칸에서도 지워짐.
|
||
reverted = client.put(
|
||
f"{SHEETS}/formulas",
|
||
json={"sheet_key": sheet["key"], "rows": [{"seq": 원래["no"], "formula": None}]},
|
||
)
|
||
assert reverted.status_code == 200 and reverted.json()["changed"] == 1
|
||
back = {row["name"]: row for row in _sheet(client)["rows"]}
|
||
assert back["모르터"]["unit_amount"] == pytest.approx(원래["unit_amount"])
|
||
assert back["모르터"]["source"] == "library"
|
||
stored = json.loads((project / "project_settings.json").read_text(encoding="utf-8"))
|
||
assert stored["quantity"]["structure_formula_overrides"] == {}
|
||
|
||
|
||
def test_줄마다_반올림을_고치면_m당에서_반올림하고_뒷줄이_그_값을_본다(
|
||
client: TestClient, project: Path
|
||
) -> None:
|
||
"""PLAN 3장 반올림 칸 — 실무 구조물도처럼 m당 값을 반올림 · 원단위는 m당 × 연장."""
|
||
sheet = _sheet(client)
|
||
rows = {row["name"]: row for row in sheet["rows"]}
|
||
area = rows["돌쌓기"]["unit_amount"] # 2.5 × √1.09 ≈ 2.61
|
||
saved = client.put(
|
||
f"{SHEETS}/formulas",
|
||
json={
|
||
"sheet_key": sheet["key"],
|
||
"rows": [
|
||
{
|
||
"seq": rows["돌쌓기"]["no"],
|
||
"formula": None,
|
||
"rounding": {"mode": "floor", "digits": 0},
|
||
}
|
||
],
|
||
},
|
||
)
|
||
assert saved.status_code == 200 and saved.json()["changed"] == 1, saved.text
|
||
after = {row["name"]: row for row in _sheet(client)["rows"]}
|
||
assert after["돌쌓기"]["unit_amount"] == pytest.approx(2.0) and area > 2.5
|
||
assert after["돌쌓기"]["source"] == "user"
|
||
assert after["돌쌓기"]["formula"] == rows["돌쌓기"]["formula"] # 식은 그대로
|
||
assert after["돌쌓기"]["default_rounding"] == {"mode": "none", "digits": 0}
|
||
# 뒷줄(모르터 = A × 0.009)이 반올림한 A 를 봄.
|
||
assert after["모르터"]["unit_amount"] == pytest.approx(2.0 * 0.009)
|
||
stored = json.loads((project / "project_settings.json").read_text(encoding="utf-8"))
|
||
assert stored["quantity"]["structure_formula_overrides"]["masonry_wet"] == {
|
||
str(rows["돌쌓기"]["no"]): {"rounding": {"mode": "floor", "digits": 0}}
|
||
}
|
||
|
||
# 원단위(build_table) = m당 반올림 값 × 연장 10m — 합계를 반올림하지 않음.
|
||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table
|
||
from B08_Quantity.B08_Quantity_Router_Material import _collect_structures
|
||
|
||
structures, names, _ = _collect_structures(str(project))
|
||
table = build_table(
|
||
structures, names, structure_formulas=stored["quantity"]["structure_formula_overrides"]
|
||
)
|
||
parts = {c["name"]: c for c in table["structures"][0]["components"]}
|
||
assert parts["돌쌓기"]["amount"] == pytest.approx(20.0)
|
||
assert parts["모르터"]["amount"] == pytest.approx(0.18)
|
||
assert parts["돌쌓기"]["basis"] == "사용자 반올림 = INT 0자리 (양식 안 함)"
|
||
|
||
# 되돌리기 — 반올림 null 이면 양식대로, 저장 칸에서도 지워짐.
|
||
client.put(
|
||
f"{SHEETS}/formulas",
|
||
json={"sheet_key": sheet["key"], "rows": [{"seq": rows["돌쌓기"]["no"], "rounding": None}]},
|
||
)
|
||
stored = json.loads((project / "project_settings.json").read_text(encoding="utf-8"))
|
||
assert stored["quantity"]["structure_formula_overrides"] == {}
|
||
# 모르는 갈래는 받지 않음.
|
||
bad = client.put(
|
||
f"{SHEETS}/formulas",
|
||
json={
|
||
"sheet_key": sheet["key"],
|
||
"rows": [{"seq": 1, "rounding": {"mode": "bankers", "digits": 0}}],
|
||
},
|
||
)
|
||
assert bad.status_code == 422
|
||
|
||
|
||
def test_대안_후보를_싣는다(client: TestClient) -> None:
|
||
candidates = {item["name"]: item for item in _sheet(client)["var_candidates"]}
|
||
assert candidates["HOLE_AREA"]["value"] == 2
|
||
assert candidates["HOLE_AREA"]["candidates"][0]["value"] == 2.5
|
||
|
||
|
||
def test_제원을_고쳐도_고친_식이_따라간다(client: TestClient) -> None:
|
||
"""고친 식은 양식 + 프로젝트에 묶임 — 뒷길이를 고쳐 장 이름이 바뀌어도 사용자 식이 남음."""
|
||
sheet = _sheet(client)
|
||
seq = next(row["no"] for row in sheet["rows"] if row["name"] == "모르터")
|
||
client.put(
|
||
f"{SHEETS}/formulas",
|
||
json={"sheet_key": sheet["key"], "rows": [{"seq": seq, "formula": "A*0.018"}]},
|
||
)
|
||
spec = client.put(
|
||
f"{SHEETS}/spec",
|
||
json={"sheet_key": sheet["key"], "base_revision": 1, "back_len_cm": "55"},
|
||
)
|
||
assert spec.status_code == 200, spec.text
|
||
after = _sheet(client)
|
||
assert after["key"] != sheet["key"]
|
||
mortar = next(row for row in after["rows"] if row["name"] == "모르터")
|
||
assert mortar["source"] == "user" and mortar["formula"] == "A*0.018"
|
||
|
||
|
||
def test_틀린_식도_막지_않고_저장하되_오류를_돌려준다(client: TestClient) -> None:
|
||
sheet = _sheet(client)
|
||
seq = next(row["no"] for row in sheet["rows"] if row["name"] == "모르터")
|
||
saved = client.put(
|
||
f"{SHEETS}/formulas",
|
||
json={"sheet_key": sheet["key"], "rows": [{"seq": seq, "formula": "A*없는칸"}]},
|
||
)
|
||
assert saved.status_code == 200, saved.text
|
||
assert any("모르터" in error and "모르는 이름" in error for error in saved.json()["errors"])
|
||
bad = client.put(
|
||
f"{SHEETS}/formulas",
|
||
json={"sheet_key": sheet["key"], "rows": [{"seq": 999, "formula": "1"}]},
|
||
)
|
||
assert bad.status_code == 400
|
||
|
||
|
||
def test_기초잡석_두께가_산출_조건을_따른다(client: TestClient, project: Path) -> None:
|
||
기본 = _unit_amount(_sheet(client), "기초잡석")
|
||
(project / "project_settings.json").write_text(
|
||
json.dumps({"quantity": {"rubble_base_thickness_m": 0.3}}), encoding="utf-8"
|
||
)
|
||
바꿈 = _unit_amount(_sheet(client), "기초잡석")
|
||
# 기본 0.2 → 0.3 — 버림 폭에 두께 비를 곱하는 식이라 1.5 배.
|
||
assert 바꿈 == pytest.approx(기본 * 1.5)
|
||
|
||
|
||
def test_제원_저장이_그_장에_걸린다(client: TestClient) -> None:
|
||
sheet = _sheet(client)
|
||
saved = client.put(
|
||
f"{SHEETS}/spec",
|
||
json={"sheet_key": sheet["key"], "base_revision": 1, "back_len_cm": "55"},
|
||
)
|
||
assert saved.status_code == 200, saved.text
|
||
assert saved.json()["changed"] == 1
|
||
assert _sheet(client)["options"]["back_len_cm"] == 55
|
||
|
||
|
||
def test_없는_장이면_404(client: TestClient) -> None:
|
||
response = client.put(f"{SHEETS}/spec", json={"sheet_key": "없는 장", "base_revision": 1})
|
||
assert response.status_code == 404
|