"""자재 총괄표 검사 — PLAN 8-2·8-3·8-7. 이 일감의 위험은 계산이 아니라 **할증을 두 번 붙이는 것**과 **모르는 값을 0 으로 넘기는 것**이다. ㉠ 할증은 여기 한 번뿐 — 앞 단계가 붙였으면 경고가 떠야 한다. · 표에 없는 자재를 0 % 로 조용히 넘기면 빠뜨린 것과 구별이 안 된다. · 관급/사급은 발주 결정이라 지어내지 않는다 — 안 정하면 「미분류」로 드러난다. · 할증 전·후를 둘 다 남긴다 — 하나만 넘기면 B09 가 역산한다. """ from __future__ import annotations import sys from pathlib import Path import pytest ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT)) from B08_Quantity.B08_Quantity_Engine_MaterialSummary import ( # noqa: E402 INSTALL_BY_CONTRACTOR, INSTALL_BY_OWNER, NOTE_INCLUDED, NOTE_INSTALL_BY_MISSING, NOTE_RATE_MISSING, SUPPLY_CONTRACTOR, SUPPLY_OWNER, SUPPLY_UNKNOWN, SurchargeTable, build_table, load_surcharge_table, verify_single_surcharge, ) from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit_table # noqa: E402 def 성분(name: str, unit: str, amount: float, destination: str = "material", **extra) -> dict: return {"name": name, "unit": unit, "amount": amount, "destination": destination, **extra} def 원단위표(*components: dict, surcharge_applied: bool = False) -> dict: """원단위 엔진이 내는 모양 그대로.""" return { "structures": [ { "structure_id": "s1", "type_id": "masonry_wet", "name": "돌쌓기(찰)", "components": list(components), } ], "surcharge_applied": surcharge_applied, } def 줄(table: dict, name: str) -> dict: return next(row for row in table["rows"] if row["name"] == name) # ── ㉠ 할증은 여기 한 번뿐 ────────────────────────────────────────── def test_앞단계가_붙였으면_경고() -> None: assert verify_single_surcharge({"surcharge_applied": True}) assert not verify_single_surcharge({"surcharge_applied": False}) def test_경고가_표에_실릴것() -> None: """검사를 만들어 두고 안 부르면 없는 것과 같다 — 표가 실제로 부르는지 본다.""" table = build_table(원단위표(성분("시멘트", "㎏", 100.0), surcharge_applied=True)) assert table["double_count_warnings"] def test_원단위_엔진_출력을_그대로_받음() -> None: """두 엔진이 실제로 맞물리는지 — 모양이 어긋나면 여기서 깨진다.""" unit = build_unit_table( [ { "structure_id": "s1", "type_id": "masonry_wet", "start_m": 35.0, "end_m": 45.0, "options": {"height_m": 1.5, "length_m": 10.0}, } ] ) table = build_table(unit) assert table["row_count"] > 0 assert not table["double_count_warnings"] # 돌은 자재, 터파기는 토공 — 표에는 자재만 온다. # ⚠ 종류를 안 고른 구조물의 돌 줄 이름이 「야면석」 → 「돌」로 바뀜(2026-09-09). assert any(row["name"] == "돌" for row in table["rows"]) assert not any(row["name"] == "터파기" for row in table["rows"]) def test_이_표가_할증을_붙인_곳임을_못박음() -> None: """B09 가 다시 붙이지 않도록 깃발을 남긴다.""" assert build_table(원단위표(성분("모래", "㎥", 1.0)))["surcharge_applied"] is True def test_품셈에_포함된_항목은_또_붙이지_않음() -> None: """품셈 1-3-1 단서 — 「할증이 포함ㆍ표시된 경우 중복 적용 금지」.""" table = build_table(원단위표(성분("모래", "㎥", 10.0, surcharge_included=True))) row = 줄(table, "모래") assert row["total_amount"] == pytest.approx(10.0) assert row["note"] == NOTE_INCLUDED # ── destination 가르기 ────────────────────────────────────────────── def test_material_만_모음() -> None: table = build_table( 원단위표( 성분("야면석", "㎥", 5.0), 성분("터파기", "㎥", 3.0, destination="earthwork"), 성분("모르터", "㎥", 0.1, destination="unit_price"), ) ) assert [row["name"] for row in table["rows"]] == ["야면석"] def test_거른_것을_버리지_않고_세어_보임() -> None: table = build_table( 원단위표( 성분("터파기", "㎥", 3.0, destination="earthwork"), 성분("되메우기", "㎥", 1.0, destination="earthwork"), 성분("모르터", "㎥", 0.1, destination="unit_price"), ) ) assert table["skipped_by_destination"] == {"earthwork": 2, "unit_price": 1} def test_같은_자재는_구조물을_넘어_합쳐짐() -> None: table = build_table( { "structures": [ {"name": "A", "components": [성분("야면석", "㎥", 2.0)]}, {"name": "B", "components": [성분("야면석", "㎥", 3.0)]}, ], "surcharge_applied": False, } ) row = 줄(table, "야면석") assert row["net_amount"] == pytest.approx(5.0) assert row["sources"] == ["A", "B"] def test_단위가_다르면_다른_줄() -> None: table = build_table( 원단위표(성분("떼", "㎡", 100.0), 성분("떼", "매", 50.0)), ) assert table["row_count"] == 2 # ── 할증률은 데이터에서, 없으면 드러낸다 ──────────────────────────── def test_실제_데이터판이_읽힘() -> None: table = load_surcharge_table() assert table.effective_date assert "시멘트" in table.material_names def test_할증률이_코드에_없고_표에서_옴() -> None: """표를 갈아 끼우면 결과가 따라간다 — 값이 코드에 박혀 있으면 안 바뀐다.""" fake = SurchargeTable( effective_date="9999-01-01", rates={"모래": {"material": "모래", "rate": 50}} ) row = 줄(build_table(원단위표(성분("모래", "㎥", 10.0)), surcharge_table=fake), "모래") assert row["surcharge_pct"] == 50 assert row["total_amount"] == pytest.approx(15.0) def test_표에_없는_자재는_0퍼센트로_넘기지_않음() -> None: """0 % 로 조용히 넘기면 「할증 없음」과 「값을 못 찾음」이 구별되지 않는다.""" table = build_table(원단위표(성분("낯선자재", "㎥", 10.0))) row = 줄(table, "낯선자재") assert row["surcharge_pct"] is None assert row["note"] == NOTE_RATE_MISSING assert "낯선자재" in table["missing_rate_materials"] # 값은 잃지 않는다 — 순수량 그대로 둔다. assert row["total_amount"] == pytest.approx(10.0) def test_데이터_파일이_없으면_전부_미확보로_드러남() -> None: empty = SurchargeTable() table = build_table(원단위표(성분("모래", "㎥", 1.0)), surcharge_table=empty) assert table["missing_rate_materials"] == ["모래"] def test_출처판을_응답에_남김() -> None: """어느 판으로 계산했는지 표가 스스로 말한다.""" assert build_table(원단위표(성분("모래", "㎥", 1.0)))["surcharge_dataset"]["effective_date"] # ── 할증 전·후 둘 다 ──────────────────────────────────────────────── def test_전후_값이_둘_다_남음() -> None: """하나만 넘기면 B09 가 어느 쪽인지 몰라 역산하다 사고가 난다(8-2).""" fake = SurchargeTable(rates={"자갈": {"material": "자갈", "rate": 4}}) row = 줄(build_table(원단위표(성분("자갈", "㎥", 100.0)), surcharge_table=fake), "자갈") assert row["net_amount"] == pytest.approx(100.0) assert row["total_amount"] == pytest.approx(104.0) def test_합계는_반올림하지_않음() -> None: """표기 자리와 계산 자리를 가른다(PLAN 8-16) — 반올림은 화면에서만.""" fake = SurchargeTable(rates={"모래": {"material": "모래", "rate": 6}}) row = 줄(build_table(원단위표(성분("모래", "㎥", 1.0)), surcharge_table=fake), "모래") assert row["total_amount"] == pytest.approx(1.06) # ── 관급/사급 ─────────────────────────────────────────────────────── def test_안_정한_자재는_미정으로_드러남() -> None: """⚠ 표기에 **누가 정하는지**가 있어야 한다 — 「미분류」로만 적으면 우리가 못 만든 것처럼 읽힌다(2026-09-09 화면 확인). 값(`unknown`)과 판정은 그대로다.""" table = build_table(원단위표(성분("야면석", "㎥", 5.0))) assert 줄(table, "야면석")["supply"] == SUPPLY_UNKNOWN assert 줄(table, "야면석")["supply_label"] == "미정(발주기관 결정)" assert table["missing_supply_materials"] == ["야면석"] def test_설정이_정한_구분을_따름() -> None: table = build_table( 원단위표(성분("시멘트", "㎏", 100.0), 성분("야면석", "㎥", 5.0)), supply_map={ "시멘트": {"supply": SUPPLY_OWNER, "install_by": INSTALL_BY_CONTRACTOR}, "야면석": SUPPLY_CONTRACTOR, }, ) assert 줄(table, "시멘트")["supply"] == "owner_supplied" assert 줄(table, "시멘트")["supply_label"] == "관급" assert 줄(table, "시멘트")["install_by_label"] == "도급자설치" assert 줄(table, "야면석")["supply_label"] == "사급" assert table["missing_supply_materials"] == [] def test_사급_줄에는_설치주체가_안_붙음() -> None: """안전관리비 대상액 밖이라 비워 두는 것이 맞다 — 잘못 적힌 값은 무시한다.""" table = build_table( 원단위표(성분("야면석", "㎥", 5.0)), supply_map={"야면석": {"supply": SUPPLY_CONTRACTOR, "install_by": INSTALL_BY_OWNER}}, ) assert 줄(table, "야면석")["install_by"] is None assert table["missing_install_by_materials"] == [] def test_관급인데_설치주체를_안_정하면_드러남() -> None: """기본값으로 때우면 안전관리비가 조용히 틀린다 — 「도급자설치 관급금액」이 대상액이다.""" table = build_table( 원단위표(성분("시멘트", "㎏", 100.0)), supply_map={"시멘트": SUPPLY_OWNER}, ) row = 줄(table, "시멘트") assert row["install_by"] is None assert NOTE_INSTALL_BY_MISSING in row["note"] assert table["missing_install_by_materials"] == ["시멘트"] def test_이름은_정확히_일치로만_찾음() -> None: """부분일치면 `막자갈`(뒤채움)이 `자갈` 할증을 문다 — 원단위에서 이미 겪은 자리다.""" fake = SurchargeTable(rates={"자갈": {"material": "자갈", "rate": 4}}) table = build_table(원단위표(성분("막자갈", "㎥", 100.0)), surcharge_table=fake) row = 줄(table, "막자갈") assert row["surcharge_pct"] is None assert row["total_amount"] == pytest.approx(100.0) # ── 구조물 밖에서 오는 자재 ──────────────────────────────────────── def test_사면_계열_자재도_받음() -> None: """떼·초류종자는 구조물 전개가 아니라 사면적에서 온다(8-4).""" table = build_table( 원단위표(성분("야면석", "㎥", 5.0)), extra_materials=[ {"name": "떼", "unit": "㎡", "amount": 200.0, "source": "성토면 떼붙임"}, ], ) row = 줄(table, "떼") assert row["net_amount"] == pytest.approx(200.0) assert row["surcharge_pct"] == 10 # 품셈 1-3-1 「떼ㆍ초화류 10 %」 assert row["total_amount"] == pytest.approx(220.0) def test_열_이름에_금액이_없음() -> None: """8-2 경계 — 금액은 B09 몫이다.""" columns = build_table(원단위표(성분("모래", "㎥", 1.0)))["columns"] assert not any("금액" in name or "단가" in name for name in columns)