"""자재 수동 단가(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