Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
190 lines
9.0 KiB
Python
190 lines
9.0 KiB
Python
"""자재 수동 단가(PLAN 1장 Ⓐ · 2026-09-14 브레인 판정).
|
||
|
||
지키는 것
|
||
① 키 = 코드(`AR-M-…`) · 코드 없는 자재만 「이름 규격」 — 조립엔 코드 키만 얹음
|
||
② 0 이하·수 아님은 안 받음 · 비우면 지움 · 값·출처가 같으면 넣은 날 그대로
|
||
③ 넣으면 자재 제목(6번 슬롯)이 서고 자원 축 자재 줄이 붙어 재료비가 섬 — 안 넣으면 종전 벌 그대로
|
||
④ 수동 단가가 닿은 내역 줄 = 금액은 서되 「미확정 N건」
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import sys
|
||
from decimal import Decimal
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
ROOT = Path(__file__).resolve().parents[2]
|
||
sys.path.insert(0, str(ROOT))
|
||
|
||
from B09_Estimation.B09_Estimation_MaterialPrices import ( # noqa: E402
|
||
MaterialPriceError,
|
||
build_key,
|
||
listing,
|
||
merge,
|
||
)
|
||
from B09_Estimation.B09_Estimation_UnitPrice import cached_build # noqa: E402
|
||
|
||
STAKE = "AR-M-dce99e46" # 말뚝 직경4~6㎝ — FP-05-15 에 2개/단위
|
||
|
||
|
||
def test_키와_넣은_날() -> None:
|
||
first = merge({}, [{"key": STAKE, "price_krw": "1,500", "source": "견적 A"}], "2026-09-14")
|
||
assert first[STAKE]["price_krw"] == "1500" and first[STAKE]["entered_at"] == "2026-09-14"
|
||
same = merge(first, [{"key": STAKE, "price_krw": "1500", "source": "견적 A"}], "2026-09-20")
|
||
assert same[STAKE]["entered_at"] == "2026-09-14" # 다시 저장해도 안 바뀜
|
||
moved = merge(first, [{"key": STAKE, "price_krw": "1600", "source": "견적 A"}], "2026-09-20")
|
||
assert moved[STAKE]["entered_at"] == "2026-09-20"
|
||
assert merge(first, [{"key": STAKE, "price_krw": ""}], "2026-09-20") == {}
|
||
for bad in ("0", "-3", "abc"):
|
||
with pytest.raises(MaterialPriceError):
|
||
merge({}, [{"key": STAKE, "price_krw": bad}], "2026-09-14")
|
||
named = merge(first, [{"key": "각재 50×50", "price_krw": "900000"}], "2026-09-14")
|
||
assert build_key(named) == ((STAKE, "1500", "견적 A"),) # 이름 키는 조립에 안 얹음
|
||
|
||
|
||
def test_넣으면_재료비가_서고_안_넣으면_종전_그대로() -> None:
|
||
base = cached_build()
|
||
assert base.book.resolve("B-FP-05-15").material == 0
|
||
assert STAKE not in base.book.titles and not base.manual_materials
|
||
built = cached_build(material_prices=((STAKE, "1500", "견적 A"),))
|
||
title = built.book.titles[STAKE]
|
||
assert (title.slots[5], title.slot_pages[5], title.unit) == (Decimal("1500"), "견적 A", "개")
|
||
assert built.book.resolve("B-FP-05-15").material == Decimal("3000") # 2개 × 1,500
|
||
assert built.book.resolve("B-FP-05-15").labor == base.book.resolve("B-FP-05-15").labor
|
||
assert "FP-05-15" in built.material_uses[STAKE]
|
||
|
||
|
||
def test_내역_줄은_미확정으로_선다() -> None:
|
||
from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill
|
||
|
||
built = cached_build(material_prices=((STAKE, "1500", "견적 A"),))
|
||
payload = {
|
||
"work_items": [
|
||
{
|
||
"work_item_code": "FP-05-15",
|
||
"name": "말뚝박기",
|
||
"unit": built.book.titles["B-FP-05-15"].unit,
|
||
"quantity": 10,
|
||
"in_bill": True,
|
||
}
|
||
],
|
||
"materials": [],
|
||
}
|
||
row = next(r for r in build_bill(payload, build=built).rows if r.name == "말뚝박기")
|
||
assert row.material_krw == Decimal("30000") and row.unconfirmed == 1
|
||
result = build_bill(payload, build=built)
|
||
assert result.unconfirmed == [{"name": "말뚝박기", "code": "B-FP-05-15", "count": 1}]
|
||
plain = build_bill(payload, build=cached_build())
|
||
assert not plain.unconfirmed
|
||
|
||
|
||
def _자재(name: str, spec: str, total: str, supply: str, source: list[str]) -> dict:
|
||
return {
|
||
"material_name": name,
|
||
"spec": spec,
|
||
"unit": "㎥",
|
||
"net_amount": total,
|
||
"total_amount": total,
|
||
"supply_type": supply,
|
||
"source_structure": source,
|
||
}
|
||
|
||
|
||
def test_사급_자재총괄_줄은_본체_자재_줄로_서고_관급은_수량만() -> None:
|
||
"""Ⓐ-2 — 셈(㉠ 0 · ㉡ 9 · ㉢ 0) 뒤 브레인 판정. 도급 재료비에 들고 미확정으로 셈."""
|
||
from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill
|
||
|
||
payload = {
|
||
"work_items": [],
|
||
"materials": [
|
||
_자재("각재", "50×50", "0.2684", "contractor_supplied", ["비탈 규준틀"]),
|
||
_자재("판재", "T12", "0.1769", "contractor_supplied", ["비탈 규준틀"]),
|
||
_자재("채움콘크리트", "180", "3.4", "owner_supplied", ["돌쌓기(찰)"]),
|
||
],
|
||
}
|
||
prices = {"각재 50×50": {"price_krw": "650000"}, "채움콘크리트 180": {"price_krw": "90000"}}
|
||
result = build_bill(payload, build=cached_build(), material_prices=prices)
|
||
group = next(r for r in result.rows if r.name == "자재(사급)")
|
||
rows = [r for r in result.rows if r.item_no.startswith(f"{group.item_no}-")]
|
||
assert [(r.name, r.spec) for r in rows] == [("각재", "50×50")] # 판재 단가 없음 · 관급 안 올림
|
||
# 수량을 1-2-2 자리(㎥ 2자리)로 반올림해 확정한 뒤 곱함 — 0.2684 → 0.27 × 650,000
|
||
assert rows[0].quantity == Decimal("0.27") and rows[0].material_krw == Decimal("175500")
|
||
assert rows[0].unconfirmed == 1 and result.direct_material_krw == Decimal("175500")
|
||
assert any(m["name"] == "판재 T12" and "단가 없음" in m["reason"] for m in result.missing)
|
||
sheet = result.material_sheet
|
||
assert (
|
||
sheet.contractor_rows[0].manual and not sheet.owner_rows[0].manual
|
||
) # 관급엔 수동 단가 안 씀
|
||
plain = build_bill(payload, build=cached_build())
|
||
assert not any(r.name == "자재(사급)" for r in plain.rows) and plain.direct_material_krw == 0
|
||
|
||
|
||
def test_같은_구조물_일위대가_자재와_겹치면_안_올리고_사유() -> None:
|
||
"""이중계상 가드 — 코드가 있으면 코드로, 없을 때만 이름으로(막는 쪽이라 틀려도 과소)."""
|
||
from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill
|
||
from B09_Estimation.B09_Estimation_BillOfQuantities_Materials import overlap
|
||
|
||
built = cached_build(material_prices=((STAKE, "1500", ""),))
|
||
unit = built.book.titles["B-FP-05-15"].unit
|
||
payload = {
|
||
"work_items": [
|
||
{"work_item_code": "FP-05-15", "name": "말뚝박기", "unit": unit, "quantity": 10}
|
||
],
|
||
"materials": [_자재("말뚝", "", "20", "contractor_supplied", ["말뚝박기"])],
|
||
}
|
||
result = build_bill(payload, build=built, material_prices={"말뚝": {"price_krw": "1500"}})
|
||
assert not any(r.name == "자재(사급)" for r in result.rows)
|
||
hit = next(m for m in result.missing if m.get("blocked_kind") == "double_count_suspect")
|
||
assert "말뚝" in hit["reason"]
|
||
inside = [(STAKE, "말뚝"), ("", "시멘트 510 kg — 자재 단가 층 없음")]
|
||
assert overlap("시멘트", "", inside).startswith("시멘트")
|
||
assert overlap("말뚝", "AR-M-00000000", inside) == "" # 코드가 있으면 코드로만
|
||
|
||
|
||
def test_목록은_자원_축_자재와_사라진_저장_줄() -> None:
|
||
built = cached_build()
|
||
stored = {
|
||
STAKE: {"price_krw": "1500"},
|
||
"AR-M-00000000": {"price_krw": "7", "name": "옛 자재"},
|
||
}
|
||
materials = [_자재("각재", "50×50", "0.2684", "contractor_supplied", ["비탈 규준틀"])]
|
||
rows = {
|
||
row["key"]: row for row in listing(built, {**stored, "못": {"price_krw": "3"}}, materials)
|
||
}
|
||
assert rows[STAKE]["name"] == "말뚝" and rows[STAKE]["price_krw"] == "1500"
|
||
assert rows["AR-M-00000000"]["missing"] is True # 조용히 안 버림
|
||
assert rows["AR-M-b0853497"]["price_krw"] is None
|
||
sheet_row = rows["각재 50×50"]
|
||
assert (sheet_row["name"], sheet_row["spec"], sheet_row["origin"]) == (
|
||
"각재",
|
||
"50×50",
|
||
"material_sheet",
|
||
)
|
||
assert sheet_row["supply_type"] == "contractor_supplied" and rows["못"]["missing"] is True
|
||
|
||
|
||
def test_내역_수량은_자리로_반올림해_확정한_뒤_금액은_버림() -> None:
|
||
"""1-2-2 [주]① 수량 반올림 → 금액 버림 — 둘을 섞지 않음(2026-09-14 브레인 판정).
|
||
|
||
인계 전정밀 0.28181999…96 로 곱하면 183,182 원(1원 샘) — 확정 수량 0.28 × 650,000 = 182,000.
|
||
"""
|
||
from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill
|
||
from B09_Estimation.B09_Estimation_QuantityDigits import digits_for
|
||
|
||
payload = {
|
||
"work_items": [],
|
||
"materials": [
|
||
_자재("각재", "50×50", "0.28181999999999996", "contractor_supplied", ["비탈 규준틀"])
|
||
],
|
||
}
|
||
result = build_bill(
|
||
payload, build=cached_build(), material_prices={"각재 50×50": {"price_krw": "650000"}}
|
||
)
|
||
row = next(r for r in result.rows if r.name == "각재")
|
||
assert (row.quantity, row.amount_krw) == (Decimal("0.28"), Decimal("182000"))
|
||
assert row.as_dict()["quantity"] == "0.28" # 계약·기성이 읽는 수량도 확정값
|
||
assert digits_for("낯선종목", "개") == 2 # 표에 없는 종목은 우리 기본 2자리
|
||
assert digits_for("돌쌓기(찰)", "㎡") == 1 and digits_for("철근", "kg") == 0
|