Files
Aislo/resources/tester/test_b09_misc_material.py
eomsangdonandClaude Opus 5 83c842e37a feat(B09): 공구손료·잡재료 칸 — 기본은 빔, 넣으면 주재료비의 %로 붙음
사용자 확정 5차 작은 것 1 「지금은 안 넣되 숫자 넣으면 되게 열어 둘 것」.
근거는 산림품셈 1-2-6 — 주재료비(할증수량 제외)의 2~5%까지, 산정 근거 명시.

- 기초자료 탭 「산출 조건」에 칸 하나 + [적용]. 비면 줄 자체가 안 섬(지금 상태 그대로).
- 밑수는 **자재 줄만** — 노무·경비, 하위 일위대가 재료비는 안 듦(층마다 거듭 세지 않음).
- 상한 5% 초과는 거절(400) — 조용히 깎아 넣지 않음.
- ⚠ 지금은 일위대가에 주재료비가 선 공종이 0개라 붙을 밑수가 없음 — 그 사실을 칸 밑에 띄움.
- 곁다리: 비율 줄(제잡비·공구손료)이 저장했다 읽으면 사라지던 것을 고침.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 20:30:10 +09:00

162 lines
6.8 KiB
Python
Raw Permalink 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-09 확정 5차 작은 것 1).
근거는 산림품셈 1-2-6 — 「명시되어 있지 않는 잡재료 및 소모재료 등을 계상하고자 할 때에는
**주재료비(재료비의 할증수량 제외)의 2~5%까지** 별도 계상하되 산정 근거를 명시하여야 한다」.
⚠ 겨누는 것 다섯
① **비면 줄이 아예 안 선다** — 지금 금액이 한 원도 안 움직여야 함
② 넣으면 **재료비**로 붙는다(경비 아님) — 1-2-6 은 잡재료·소모재료 자리임
③ 밑수는 **주재료비만** — 노무·경비는 안 들고, 하위 일위대가 재료비도 안 듦
(그쪽에서 이미 한 번 셌음 — 두 번 세면 층이 깊을수록 부풀어 오름)
④ **상한 5% 를 넘는 값은 안 받는다** — 조용히 깎아 넣지도 않음
⑤ 저장했다 다시 읽어도 그 줄이 살아 있다 (비율 줄이 직렬화에서 빠지면 조용히 싸짐)
"""
from __future__ import annotations
import asyncio
import json
import sys
from decimal import Decimal
from pathlib import Path
from uuid import uuid4
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from B09_Estimation.B09_Estimation_PriceBook import ( # noqa: E402
PriceBook,
PriceDetail,
PriceKind,
PriceTitle,
)
from B09_Estimation.B09_Estimation_Storage import ( # noqa: E402
_detail_from_dict,
_detail_to_dict,
)
from B09_Estimation.B09_Estimation_UnitPrice import ( # noqa: E402
MISC_MATERIAL_MAX_PERCENT,
parse_misc_material_percent,
)
def _book(percent: Decimal | None) -> PriceBook:
"""자재 100,000 + 노임 50,000 짜리 일위대가 한 줄."""
book = PriceBook()
book.add_title(
PriceTitle(code="M-1", kind=PriceKind.MATERIAL, name="자재", slots=[Decimal(100_000)] * 6)
)
book.add_title(
PriceTitle(code="L-1", kind=PriceKind.LABOR, name="보통인부", slots=[Decimal(50_000)] * 6)
)
book.add_title(PriceTitle(code="B-1", kind=PriceKind.UNIT_PRICE, name="시험공종"))
book.add_detail(PriceDetail("B-1", "M-1", Decimal(1)))
book.add_detail(PriceDetail("B-1", "L-1", Decimal(1)))
if percent is not None:
book.add_detail(PriceDetail("B-1", "B-1", Decimal(0), percent_of_material=percent))
return book
def test_비면_한_원도_안_움직인다() -> None:
"""① 「지금은 안 넣음」 — 칸이 비면 줄 자체가 안 서야 함."""
money = _book(None).resolve("B-1")
assert money.material == Decimal(100_000)
assert money.total == Decimal(150_000)
def test_넣으면_재료비로_붙는다() -> None:
"""② 경비가 아니라 재료비 — 1-2-6 은 잡재료·소모재료 자리."""
money = _book(Decimal(3)).resolve("B-1")
assert money.material == Decimal(103_000)
assert money.labor == Decimal(50_000)
assert money.expense == Decimal(0)
def test_밑수는_주재료비만이다() -> None:
"""③ 노무비가 밑수에 들면 3% 가 4.5% 처럼 서게 됨."""
money = _book(Decimal(3)).resolve("B-1")
붙은값 = money.material - Decimal(100_000)
assert 붙은값 == Decimal(100_000) * Decimal(3) / Decimal(100)
def test_하위_일위대가_재료비는_밑수에_안_든다() -> None:
"""③ 층이 깊어질수록 같은 재료비를 거듭 세면 안 됨."""
book = _book(Decimal(3))
book.add_title(PriceTitle(code="B-2", kind=PriceKind.UNIT_PRICE, name="윗공종"))
book.add_detail(PriceDetail("B-2", "B-1", Decimal(1)))
book.add_detail(PriceDetail("B-2", "B-2", Decimal(0), percent_of_material=Decimal(3)))
# B-1 이 품고 온 재료비(103,000)는 B-2 의 밑수가 아니다 — B-2 엔 제 자재 줄이 없다.
assert book.resolve("B-2").material == book.resolve("B-1").material
def test_상한을_넘으면_안_받는다() -> None:
"""④ 「25%까지」 — 넘는 값을 조용히 깎아 넣지 않고 거절함."""
with pytest.raises(ValueError) as caught:
parse_misc_material_percent("6")
assert str(MISC_MATERIAL_MAX_PERCENT) in str(caught.value)
assert parse_misc_material_percent("") is None
assert parse_misc_material_percent(" ") is None
assert parse_misc_material_percent("3.5%") == Decimal("3.5")
def test_밑수가_있는지_미리_물어볼_수_있다() -> None:
"""화면이 「넣을 데가 있는가」를 물어보는 자리 — 없으면 0 이라고 말해야 함."""
book = _book(None)
assert book.material_base("B-1") == Decimal(100_000)
book.add_title(PriceTitle(code="B-9", kind=PriceKind.UNIT_PRICE, name="자재 없는 공종"))
book.add_detail(PriceDetail("B-9", "L-1", Decimal(1)))
assert book.material_base("B-9") == Decimal(0)
def test_저장했다_읽어도_비율_줄이_산다() -> None:
"""⑤ 직렬화에서 빠지면 다시 읽은 단가가 조용히 싸짐."""
original = PriceDetail(
"B-1", "B-1", Decimal(0), note="공구손료", percent_of_material=Decimal(3)
)
again = _detail_from_dict(_detail_to_dict(original))
assert again.percent_of_material == Decimal(3)
# 제잡비(노무비의 %)도 같은 자리에서 빠져 있었다 — 함께 살린다.
labor_row = PriceDetail("B-1", "B-1", Decimal(0), percent_of_labor=Decimal(5))
assert _detail_from_dict(_detail_to_dict(labor_row)).percent_of_labor == Decimal(5)
def test_칸을_저장했다_지웠다_할_수_있다(tmp_path, monkeypatch) -> None:
"""화면 [적용] 이 지나는 길 — 넣기·되비우기·상한 거절 셋을 한자리에서 본다.
⚠ **되비우는 길이 있어야 한다** — 한번 넣으면 못 지우는 칸이면 「지금은 안 넣음」으로
돌아갈 수 없다.
"""
from B09_Estimation import B09_Estimation_Router as router
project_id = uuid4()
monkeypatch.setattr(router, "_project_root_of", _fake_root(str(tmp_path)))
def _put(value: str):
body = router.FactorChoiceBody(misc_material_percent=value)
got = asyncio.run(router.put_factor_choices(project_id, body))
return got.status_code, json.loads(got.body.decode())
from common_util.common_util_project_settings import estimation_settings
status, _ = _put("3")
assert status == 200
assert estimation_settings(str(tmp_path)).get("misc_material_percent") == "3"
status, payload = _put("6")
assert status == 400 and "1-2-6" in payload["message"]
# ⚠ 거절된 값이 저장을 건드리면 안 된다 — 3% 가 그대로 살아 있어야 한다.
assert estimation_settings(str(tmp_path)).get("misc_material_percent") == "3"
status, _ = _put("")
assert status == 200
assert estimation_settings(str(tmp_path)).get("misc_material_percent") == ""
def _fake_root(path: str):
async def _root(_project_id):
return path
return _root