feat(B09): 공구손료·잡재료 칸 — 기본은 빔, 넣으면 주재료비의 %로 붙음

사용자 확정 5차 작은 것 1 「지금은 안 넣되 숫자 넣으면 되게 열어 둘 것」.
근거는 산림품셈 1-2-6 — 주재료비(할증수량 제외)의 2~5%까지, 산정 근거 명시.

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-09 20:30:10 +09:00
co-authored by Claude Opus 5
parent 404ec16e65
commit 83c842e37a
8 changed files with 445 additions and 5 deletions
+161
View File
@@ -0,0 +1,161 @@
"""공구손료·잡재료 칸 — 「지금은 안 넣되 숫자 넣으면 되게」 (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