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:
@@ -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:
|
||||
"""④ 「2~5%까지」 — 넘는 값을 조용히 깎아 넣지 않고 거절함."""
|
||||
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
|
||||
Reference in New Issue
Block a user