Files
Aislo/resources/tester/test_b08_structure_unit_price.py
T

331 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""구조물도 하단 일위대가 표 틀 (2026-09-13, PLAN 3장 하단 ①).
겨누는 것
① 줄 금액 = 원단위 줄 수량 × B09 단가(3분할) · 금액란 0.1원 버림 · 계금 1원 버림
② 못 푼 줄은 0 이 아니라 막힘 + 까닭 · 막힌 줄이 있으면 미완
③ 원단위 줄이 안 선 장(버림 「안 넣음」)은 일위대가 줄도 안 섬 — 막힘이 아님
④ 단위가 다르거나 하위 양식을 못 찾으면 막힘
⑤ 창구가 실제 단가표로 찰쌓기 갈래를 찾아 값을 냄
⑥ 재귀 — 하위 구조물 일위대가(B-AX-ST)를 제원으로 풀어 넣음 · 돌면 막힘 · 5단까지
"""
from __future__ import annotations
import sys
from decimal import Decimal
from pathlib import Path
from types import SimpleNamespace
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
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import unit_price_table # noqa: E402
from B09_Estimation.B09_Estimation_PriceBook import Money3, PriceBookError # noqa: E402
PROJECT_ID = "44444444-4444-4444-4444-444444444444"
SHEETS = f"/api/projects/{PROJECT_ID}/quantity/structure-sheets"
class FakeBook:
def __init__(self, titles: dict[str, tuple[str, str, Money3]]):
self.titles = titles
def title(self, code: str) -> SimpleNamespace:
if code not in self.titles:
raise PriceBookError(f"단가표에 없는 코드입니다: {code}")
name, unit, _money = self.titles[code]
return SimpleNamespace(name=name, spec="", unit=unit)
def resolve(self, code: str) -> Money3:
return self.titles[code][2] if code in self.titles else self.title(code)
BOOK = FakeBook(
{
"B-FP-13-04-05#55cm이하": (
"찰쌓기(장비)",
"㎡",
Money3(Decimal("100.05"), Decimal("200.07"), Decimal("30")),
),
"B-FP-12-25": ("기초잡석", "㎥", Money3(Decimal("1000"), Decimal("500"), Decimal("0"))),
"B-FP-99-01": ("다른 단위", "m", Money3(Decimal("1"))),
}
)
TEMPLATE = {
"code": "AX-ST-56e81a2c",
"name": "돌쌓기(찰)",
"unit_price": {
"rows": [
{
"seq": 1,
"name": "돌쌓기",
"from_row": 1,
"work_item_code": "FP-13-04-05",
"variant_from": "L3",
},
{"seq": 2, "name": "모르터", "from_row": 8},
{"seq": 3, "name": "기초잡석", "from_row": 15, "work_item_code": "FP-12-25"},
]
},
}
def _variant(code: str, value: str) -> str | None:
return "B-FP-13-04-05#55cm이하" if (code, value) == ("FP-13-04-05", "45") else None
def _sheet(**rows: dict) -> dict:
base = {
1: {"no": 1, "unit": "㎡", "unit_amount": 2.61},
8: {"no": 8, "unit": "㎥", "unit_amount": 0.02349},
15: {"no": 15, "unit": "㎥", "unit_amount": 0.4},
}
for key, value in rows.items():
base[int(key[1:])] = {**base[int(key[1:])], **value}
return {"rows": list(base.values()), "formula_sheet": {"vars": {"L3": 45}}, "billing_unit": "m"}
def test_줄_금액은_수량_곱하기_단가이고_못_푼_줄은_막힘() -> None:
table = unit_price_table(TEMPLATE, _sheet(), BOOK, _variant)
rows = {row["seq"]: row for row in table["rows"]}
stone = rows[1]
assert stone["ref_code"] == "B-FP-13-04-05#55cm이하" and stone["name"] == "찰쌓기(장비)"
# 2.61 × 100.05 = 261.1305 → 0.1원 미만 버림 261.1
assert stone["material"] == pytest.approx(261.1)
assert stone["labor"] == pytest.approx(522.1) # 522.1827
assert stone["expense"] == pytest.approx(78.3) # 78.3
assert rows[2]["reason"] == "공종 코드 미정" and "total" not in rows[2]
assert rows[3]["total"] == pytest.approx(600.0) # 0.4 × 1500
assert table["blocked"] == 1 and table["complete"] is False
# 계금 — 줄 금액 합(261.1+522.1+78.3+600) 1원 미만 버림
assert table["total"] == pytest.approx(1461.0)
assert table["code"] == "B-AX-ST-56e81a2c"
def test_원단위_줄이_안_선_장은_일위대가_줄도_안_섬() -> None:
sheet = _sheet(r15={"skipped": True, "unit_amount": None, "reason": "조건이 거짓: BLIND"})
rows = {row["seq"]: row for row in unit_price_table(TEMPLATE, sheet, BOOK, _variant)["rows"]}
assert rows[3]["skipped"] is True and rows[3]["reason"].startswith("조건이 거짓")
def test_갈래를_못_찾거나_단위가_다르거나_하위_구조물이면_막힘() -> None:
sheet = _sheet()
sheet["formula_sheet"]["vars"]["L3"] = 60
template = {
"code": "AX-ST-00000000",
"unit_price": {
"rows": [
{"seq": 1, "from_row": 1, "work_item_code": "FP-13-04-05", "variant_from": "L3"},
{"seq": 2, "from_row": 1, "ref_code": "B-FP-99-01"},
{"seq": 3, "quantity": 1, "ref_code": "B-AX-ST-12345678"},
]
},
}
rows = {row["seq"]: row for row in unit_price_table(template, sheet, BOOK, _variant)["rows"]}
assert "갈래를 못 찾음" in rows[1]["reason"]
assert rows[2]["reason"].startswith("단위가 다름")
assert "못 찾음" in rows[3]["reason"] # 하위 양식이 프로젝트·기본에 없음
def _leaf(code: str, next_code: str | None = None) -> dict:
"""하위 양식 — 면적 = H×L×2(㎥) 한 줄. 다음 코드가 있으면 그것을 1m 부름."""
unit_rows = [{"seq": 1, "name": "잡석", "from_row": 1, "work_item_code": "FP-12-25"}]
if next_code:
unit_rows = [
{
"seq": 1,
"quantity": 1,
"unit": "m",
"ref_code": f"B-{next_code}",
"sub_vars": {"H": 1},
}
]
return {
"code": code,
"name": f"하위 {code[-2:]}",
"vars": {"H": {"source": "height_m"}, "L": {"source": "length_m"}},
"tables": {},
"rows": [
{
"seq": 1,
"name": "면적",
"formula": "H*L*2",
"unit": "㎥",
"destination": "unit_price",
"rounding": {"mode": "none", "digits": 0},
}
],
"unit_price": {"unit": "m", "rows": unit_rows},
}
def _parent(ref: str, sub_vars: dict | None = None) -> dict:
row = {"seq": 1, "name": "하위 부름", "quantity": 3, "unit": "m", "ref_code": ref}
if sub_vars is not None:
row["sub_vars"] = sub_vars
return {"code": "AX-ST-a0000000", "unit_price": {"rows": [row]}}
def test_하위_구조물_일위대가를_제원으로_풀어_윗_표에_넣는다() -> None:
"""PLAN 3장 재귀 — 하위 양식 1m = 면적 3㎥ × 잡석(1000·500) · 윗 표 3m."""
library = [_leaf("AX-ST-c0000001")]
table = unit_price_table(
_parent("B-AX-ST-c0000001", {"H": 1.5}), _sheet(), BOOK, _variant, library
)
row = table["rows"][0]
assert row["name"] == "하위 01" and row["reason"] == ""
assert row["material"] == pytest.approx(9000.0) and row["labor"] == pytest.approx(4500.0)
assert table["complete"] is True and table["total"] == pytest.approx(13500.0)
# 제원을 안 주면 막힘 — 0 으로 풀지 않음.
missing = unit_price_table(_parent("B-AX-ST-c0000001"), _sheet(), BOOK, _variant, library)
assert "제원이 비어 있음: H" in missing["rows"][0]["reason"]
def test_하위_일위대가가_돌면_막힘() -> None:
library = [_leaf("AX-ST-c0000002", next_code="AX-ST-c0000002")]
table = unit_price_table(
_parent("B-AX-ST-c0000002", {"H": 1}), _sheet(), BOOK, _variant, library
)
assert "미완" in table["rows"][0]["reason"] and table["complete"] is False
def test_재귀는_5단까지() -> None:
"""맨 윗 표 1단 + 하위 넷 = 5단은 섬 · 하위 다섯이면 6단이라 막힘."""
codes = [f"AX-ST-d000000{i}" for i in range(1, 6)]
library = [
_leaf(code, codes[i + 1] if i + 1 < len(codes) else None) for i, code in enumerate(codes)
]
five = unit_price_table(_parent(f"B-{codes[1]}", {"H": 1}), _sheet(), BOOK, _variant, library)
assert five["complete"] is True # 윗 표 → d2 → d3 → d4 → d5(잎) = 5단
six = unit_price_table(_parent(f"B-{codes[0]}", {"H": 1}), _sheet(), BOOK, _variant, library)
assert six["complete"] is False # 윗 표 → d1 → … → d5 = 6단
def test_수동_단가는_막힌_줄을_세우고_미확정으로_센다() -> None:
"""PLAN 3장 ③ — 모르터(코드 미정)에 수동 단가 · 0.02349㎥ × 재료 80,000 = 1,879.2."""
manual = {
"2": {
"material": 80000,
"labor": 0,
"expense": 0,
"source": "견적",
"entered_at": "2026-09-13",
}
}
table = unit_price_table(TEMPLATE, _sheet(), BOOK, _variant, (), manual)
mortar = next(row for row in table["rows"] if row["seq"] == 2)
assert mortar["manual"] is True and mortar["manual_source"] == "견적"
assert mortar["material"] == pytest.approx(1879.2) and mortar["reason"] == ""
assert table["blocked"] == 0 and table["complete"] is True and table["unconfirmed"] == 1
# 단가표에 값이 있는 줄도 수동 단가가 이김 — 그래도 미확정.
manual["1"] = {"material": 1, "labor": 0, "expense": 0}
again = unit_price_table(TEMPLATE, _sheet(), BOOK, _variant, (), manual)
assert again["rows"][0]["total"] == pytest.approx(2.6) and again["unconfirmed"] == 2
def test_줄_조합_저장본은_양식과_같으면_지우고_수동_단가는_남은_줄만() -> None:
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import (
save_manual,
save_rows,
with_rows,
)
template = {"type_id": "masonry_wet", **TEMPLATE}
rows = TEMPLATE["unit_price"]["rows"]
edited = [*rows[:2], {"seq": 4, "name": "추가", "quantity": 1.0, "ref_code": "B-FP-12-25"}]
merged, changed = save_rows({"other": []}, "masonry_wet", template, edited)
assert changed and merged["masonry_wet"] == edited and "other" in merged
back, changed = save_rows(merged, "masonry_wet", template, [dict(row) for row in rows])
assert changed and back == {"other": []}
assert with_rows(template, edited)["unit_price"]["rows"] == edited
assert with_rows({"code": "x"}, None)["unit_price"]["rows"] == []
price = {"material": 10.0, "labor": 0.0, "expense": 0.0, "source": "견적"}
first = save_manual({}, "masonry_wet", edited, {"2": price, "3": price}, "2026-09-01")
assert list(first["masonry_wet"]) == ["2"] # 뺀 줄(3)의 값은 안 남음
same = save_manual(first, "masonry_wet", edited, {"2": price}, "2026-09-13")
assert same["masonry_wet"]["2"]["entered_at"] == "2026-09-01" # 값이 같으면 옛 날짜
moved = save_manual(
first, "masonry_wet", edited, {"2": {**price, "material": 11.0}}, "2026-09-13"
)
assert moved["masonry_wet"]["2"]["entered_at"] == "2026-09-13"
assert save_manual(first, "masonry_wet", edited, {}, "2026-09-13") == {}
def test_고르개는_갈래별로_낱말이_모두_든_항목과_단가를_낸다() -> None:
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import search_titles
from B09_Estimation.B09_Estimation_PriceBook import PriceBook, PriceKind, PriceTitle
book = PriceBook()
slots = [None] * 5
book.add_title(
PriceTitle("M-001", PriceKind.MATERIAL, "시멘트", "40kg", "포", [*slots, Decimal(5000)])
)
book.add_title(PriceTitle("M-002", PriceKind.MATERIAL, "시멘트 모르터", "1:3", "m3"))
book.add_title(PriceTitle("B-FP-01", PriceKind.UNIT_PRICE, "모르터 비비기", "", "m3"))
found = search_titles(book, "시멘트", "resource")
assert [item["code"] for item in found] == ["M-001", "M-002"]
assert found[0]["price"] == pytest.approx(5000.0) and found[1]["price"] is None
assert found[1]["unit"] == "㎥"
assert [item["code"] for item in search_titles(book, "모르터 1:3", "resource")] == ["M-002"]
assert [item["code"] for item in search_titles(book, "모르터", "work")] == ["B-FP-01"]
assert search_titles(book, " ", "work") == []
@pytest.fixture()
def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient:
root = tmp_path / "project"
root.mkdir()
wall = StructureInstance.model_validate(
{
"type_id": "masonry_wet",
"placement": "interval",
"start_m": 0.0,
"end_m": 10.0,
"options": {"height_m": 2.5, "back_len_cm": 45},
}
)
save_structures(str(root), [wall], base_revision=0)
async def fake_root(project_id):
return str(root)
async def no_route(project_id):
return {}
async def real_build(project_id):
from B09_Estimation.B09_Estimation_UnitPrice import cached_build
return cached_build()
monkeypatch.setattr(router_module, "_project_root", fake_root)
monkeypatch.setattr(router_module, "_price_build", real_build)
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 test_창구가_실제_단가표로_찰쌓기_갈래를_찾는다(client: TestClient) -> None:
sheet = client.get(SHEETS).json()["sheets"][0]
response = client.get(f"{SHEETS}/unit-price", params={"sheet_key": sheet["key"]})
assert response.status_code == 200, response.text
table = response.json()["unit_price"]
rows = {row["seq"]: row for row in table["rows"]}
assert rows[1]["ref_code"] == "B-FP-13-04-05#55cm이하"
assert rows[1]["labor"] > 0 and rows[1]["quantity"] == pytest.approx(2.5 * 1.09**0.5)
assert rows[3]["ref_code"] == "B-FP-12-25"
# 모르터 — 산림 품셈에 배합 절이 없어 AX-WK 모르타르 배합 1:3(2026-09-13)
assert rows[2]["ref_code"] == "B-AX-WK-c0842a0d" and rows[2]["labor"] > 0
assert table["complete"] is True