- 기초단가 적용: 자재·노임·중기 취득가 채택 단가에 적용률 — 복사본만, 설계 단가표 불변 - 조립값과 다른 줄(할증·수동 단가)·단가표 밖 코드는 성분 곱셈 + 비고에 까닭 - 계약 호표: 설계 본표와 같은 꼴(detail_of)로 W-B·W-D 따로 — 기초단가 적용과 함께만 - 0% 는 0 원(브레인 판정) · 공내역 생성은 그 줄 이름만 가름 - 시험 11건 · 전체 1699 통과(골든셋 포함) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
212 lines
8.4 KiB
Python
212 lines
8.4 KiB
Python
"""계약 단계 — 설계 → 계약내역 **구조**가 서는가 (PLAN 12장 · 2026-09-14 브레인 배정).
|
||
|
||
⚠ 값이 맞는지는 못 잰다 — 계약 표본 0건(STmate 27번 §9). 여기서 재는 것:
|
||
① 적용률 100% 면 설계와 같음 · 설계 줄(입력)은 안 바뀜
|
||
② 노·재·경 따로 곱해짐 · 절사는 설계 내역 규칙(성분 단가 원 미만 · 줄 성분마다)
|
||
③ 적용 제외 줄은 설계 단가 · W 코드 안 붙음
|
||
④ 0% = 0 원(브레인 판정) · 「공내역 생성」 켜면 그 줄에 공내역 이름
|
||
⑤ 동일코드 개별생성 켜면 줄마다 다른 W 코드 · 끄면 같은 코드 한 벌
|
||
⑥ 묶음 줄 합 · 틀린 적용률 거름
|
||
⑦ 2벌 — 기초단가 적용은 단가표 **복사본**만(설계 단가표 불변) · 못 푸는 줄은 까닭 ·
|
||
계약 호표(W-B·W-D)는 기초단가 적용과 함께만 섬
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import copy
|
||
import sys
|
||
from decimal import Decimal
|
||
from pathlib import Path
|
||
|
||
ROOT = Path(__file__).resolve().parents[2]
|
||
sys.path.insert(0, str(ROOT))
|
||
|
||
from B09_Estimation.B09_Estimation_Contract import clean_settings, contract_bill # noqa: E402
|
||
from B09_Estimation.B09_Estimation_PriceBook import ( # noqa: E402
|
||
PriceBook,
|
||
PriceDetail,
|
||
PriceKind,
|
||
PriceTitle,
|
||
)
|
||
from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild # noqa: E402
|
||
|
||
ROWS = [
|
||
{"item_no": "1", "is_group": True, "in_bill": True},
|
||
{
|
||
"item_no": "1.1",
|
||
"is_group": False,
|
||
"in_bill": True,
|
||
"quantity": "12.5",
|
||
"price_code": "B-FP-09-03-02",
|
||
"unit_material_krw": "1001",
|
||
"unit_labor_krw": "2003",
|
||
"unit_expense_krw": "3005",
|
||
},
|
||
{
|
||
"item_no": "1.2",
|
||
"is_group": False,
|
||
"in_bill": True,
|
||
"quantity": "3",
|
||
"price_code": "B-FP-09-03-02",
|
||
"unit_material_krw": "1001",
|
||
"unit_labor_krw": "2003",
|
||
"unit_expense_krw": "3005",
|
||
},
|
||
{
|
||
"item_no": "1.3",
|
||
"is_group": False,
|
||
"in_bill": True,
|
||
"quantity": "2",
|
||
"price_code": "M-관급자재",
|
||
"unit_material_krw": "50000",
|
||
"unit_labor_krw": "0",
|
||
"unit_expense_krw": "0",
|
||
},
|
||
]
|
||
|
||
|
||
def _rows(settings: dict) -> dict[str, dict]:
|
||
return {row["item_no"]: row for row in contract_bill(ROWS, settings)["rows"]}
|
||
|
||
|
||
def test_100퍼센트면_설계와_같고_입력은_안_바뀐다() -> None:
|
||
before = copy.deepcopy(ROWS)
|
||
result = contract_bill(ROWS, clean_settings({})[0])
|
||
assert ROWS == before
|
||
assert result["totals"]["design"] == result["totals"]["contract"]
|
||
|
||
|
||
def test_노재경을_따로_곱하고_성분_단가를_원_미만_절사() -> None:
|
||
row = _rows({"labor_pct": "80", "material_pct": "90", "expense_pct": "70"})["1.1"]
|
||
assert (
|
||
row["contract_unit_material_krw"],
|
||
row["contract_unit_labor_krw"],
|
||
row["contract_unit_expense_krw"],
|
||
) == ("900", "1602", "2103") # 900.9→900 · 1602.4→1602 · 2103.5→2103
|
||
assert row["contract_material_krw"] == "11250" # 12.5 × 900
|
||
assert row["contract_code"] == "W-B-FP-09-03-02"
|
||
|
||
|
||
def test_적용_제외_줄은_설계_단가_W코드_없음() -> None:
|
||
row = _rows({"material_pct": "50", "excluded": ["1.3"]})["1.3"]
|
||
assert row["contract_unit_material_krw"] == "50000" and row["contract_excluded"] is True
|
||
assert not row["contract_code"].startswith("W-")
|
||
|
||
|
||
def test_0퍼센트_공내역_옵션() -> None:
|
||
zero = {"labor_pct": "0", "material_pct": "0", "expense_pct": "0"}
|
||
empty = _rows({**zero, "zero_makes_empty": True})["1.1"]
|
||
assert empty["contract_amount_krw"] == "0" and "공내역" in empty["contract_note"]
|
||
plain = _rows(zero)["1.1"]
|
||
assert plain["contract_amount_krw"] == "0" and plain["contract_note"] == "" # 그냥 0 원 줄
|
||
|
||
|
||
def test_동일코드_개별생성() -> None:
|
||
shared = _rows({"material_pct": "90"})
|
||
assert shared["1.1"]["contract_code"] == shared["1.2"]["contract_code"]
|
||
separate = _rows({"material_pct": "90", "separate_same_code": True})
|
||
assert separate["1.1"]["contract_code"] != separate["1.2"]["contract_code"]
|
||
|
||
|
||
def test_묶음_줄_합과_합계_비율() -> None:
|
||
result = contract_bill(ROWS, {"material_pct": "90", "labor_pct": "90", "expense_pct": "90"})
|
||
group = result["rows"][0]
|
||
children = [r for r in result["rows"] if not r.get("is_group")]
|
||
assert Decimal(group["contract_amount_krw"]) == sum(
|
||
Decimal(r["contract_amount_krw"]) for r in children
|
||
)
|
||
assert result["totals"]["ratio_pct"]["labor"] is not None
|
||
|
||
|
||
def test_틀린_적용률은_거른다() -> None:
|
||
cleaned, errors = clean_settings({"labor_pct": "-3", "material_pct": "abc", "expense_pct": ""})
|
||
assert len(errors) == 2 and cleaned["expense_pct"] == "100"
|
||
|
||
|
||
# ── 2벌 — 「기초단가에 적용」 · 「적용율 적용된 일위대가/산출근거 생성」 ──────────────
|
||
|
||
|
||
def _build():
|
||
"""자재 1,001 · 노임 2,003 · 산출근거 D-T(노임 0.5) · 일위대가 B-T(자재 2 + D-T 1)."""
|
||
book = PriceBook()
|
||
for code, kind, price in (
|
||
("M-A", PriceKind.MATERIAL, "1001"),
|
||
("L-B", PriceKind.LABOR, "2003"),
|
||
):
|
||
slots = [None] * 6
|
||
slots[5] = Decimal(price)
|
||
book.add_title(PriceTitle(code=code, kind=kind, name=code, slots=slots))
|
||
book.add_title(PriceTitle(code="D-T", kind=PriceKind.PRICE_BASIS, name="산출근거"))
|
||
book.add_title(PriceTitle(code="B-T", kind=PriceKind.UNIT_PRICE, name="일위대가"))
|
||
book.add_detail(PriceDetail("D-T", "L-B", Decimal("0.5")))
|
||
book.add_detail(PriceDetail("B-T", "M-A", Decimal(2)))
|
||
book.add_detail(PriceDetail("B-T", "D-T", Decimal(1)))
|
||
return UnitPriceBuild(book=book)
|
||
|
||
|
||
def _line(item_no: str, code: str, material: str, labor: str) -> dict:
|
||
return {
|
||
"item_no": item_no,
|
||
"is_group": False,
|
||
"in_bill": True,
|
||
"quantity": "3",
|
||
"price_code": code,
|
||
"unit_material_krw": material,
|
||
"unit_labor_krw": labor,
|
||
"unit_expense_krw": "0",
|
||
}
|
||
|
||
|
||
BASE_ROWS = [
|
||
_line("2.1", "B-T", "2002", "1001"), # 설계 단가 = 단가표 조립값
|
||
_line("2.2", "B-T", "2500", "1001"), # 수동·할증으로 조립값과 다름
|
||
_line("2.3", "Z-없음", "100", "0"), # 단가표 밖
|
||
]
|
||
BASE_ON = {
|
||
"material_pct": "90",
|
||
"labor_pct": "80",
|
||
"apply_to_base_prices": True,
|
||
"generate_unit_prices": True,
|
||
}
|
||
|
||
|
||
def test_기초단가_적용은_복사본만_고치고_다시_조립() -> None:
|
||
build = _build()
|
||
before = (build.book.resolve("B-T"), copy.deepcopy(build.book.titles["M-A"].slots))
|
||
result = contract_bill(BASE_ROWS, BASE_ON, build=build)
|
||
assert (build.book.resolve("B-T"), build.book.titles["M-A"].slots) == before # 설계 단가표 불변
|
||
rows = {row["item_no"]: row for row in result["rows"]}
|
||
# 자재 900.9 × 2 = 1801.8 → 1801 · 노임 1602.4 × 0.5 = 801.2 → D 801 → B 801
|
||
# (성분에 곱했으면 노무 1001 × 0.8 = 800.8 → 800 — 층을 다시 조립해야 801)
|
||
assert (rows["2.1"]["contract_unit_material_krw"], rows["2.1"]["contract_unit_labor_krw"]) == (
|
||
"1801",
|
||
"801",
|
||
)
|
||
assert rows["2.1"]["contract_note"].startswith("기초단가 적용")
|
||
|
||
|
||
def test_기초단가로_못_푸는_줄은_성분에_곱하고_까닭을_적는다() -> None:
|
||
rows = {r["item_no"]: r for r in contract_bill(BASE_ROWS, BASE_ON, build=_build())["rows"]}
|
||
assert (
|
||
rows["2.2"]["contract_unit_material_krw"] == "2250"
|
||
and "조립값과 다름" in (rows["2.2"]["contract_note"])
|
||
)
|
||
assert "단가표 밖" in rows["2.3"]["contract_note"]
|
||
|
||
|
||
def test_계약_호표는_W코드로_따로_서고_산출근거도_따라_선다() -> None:
|
||
sheets = contract_bill(BASE_ROWS, BASE_ON, build=_build())["unit_price_sheets"]
|
||
by_code = {sheet["code"]: sheet for sheet in sheets}
|
||
assert set(by_code) == {"W-B-T", "W-D-T"}
|
||
assert (by_code["W-B-T"]["design_code"], by_code["W-B-T"]["total"]) == ("B-T", "2602")
|
||
assert by_code["W-D-T"]["labor"] == "801"
|
||
|
||
|
||
def test_호표_생성은_기초단가_적용이_함께_켜져야_선다() -> None:
|
||
only_sheets = {**BASE_ON, "apply_to_base_prices": False}
|
||
result = contract_bill(BASE_ROWS, only_sheets, build=_build())
|
||
assert result["unit_price_sheets"] == []
|
||
assert "generate_unit_prices" in result["options_not_used"]
|
||
off = contract_bill(BASE_ROWS, {**BASE_ON, "generate_unit_prices": False}, build=_build())
|
||
assert off["unit_price_sheets"] == []
|