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