- get_bill · _project_root_of · _build_for 는 Router.py 에 그대로(랩탑_메인 CostSheet import 그대로) - 새 파일은 본 라우터의 _project_root_of·_build_for 를 부를 때마다 빌려 씀 — 시험이 본 라우터 것을 바꿔 끼움 - 검증: 시험 1666 통과 · 골든셋 초록 · 재시작 뒤 B09 문 열하나(factors·base-data·price-sources·basis-sheet·machine-expense· design-doc-index·bill·unit-prices·edits·cost-sheet·PUT factors) 전부 200 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
163 lines
6.9 KiB
Python
163 lines
6.9 KiB
Python
"""공구손료·잡재료 칸 — 「지금은 안 넣되 숫자 넣으면 되게」 (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
|
||
from B09_Estimation import B09_Estimation_Router_Factors as factors
|
||
|
||
project_id = uuid4()
|
||
monkeypatch.setattr(router, "_project_root_of", _fake_root(str(tmp_path)))
|
||
|
||
def _put(value: str):
|
||
body = factors.FactorChoiceBody(misc_material_percent=value)
|
||
got = asyncio.run(factors.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
|