331 lines
14 KiB
Python
331 lines
14 KiB
Python
"""구조물도 하단 일위대가 표 틀 (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
|