Files
Aislo/resources/tester/test_b08_material_summary.py
T

358 lines
16 KiB
Python

"""자재 총괄표 검사 — 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"].split(" · ")[0] == 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:
"""돌 줄은 이름 「돌」 + 규격에 종류(명세 13장 Ⓒ · 2026-09-13 과도기 부채 걷음).
⚠ 옛 설정 열쇠 — 종류 이름(「야면석·호박돌」)·이름만(「채움콘크리트」)도 받아야
사용자가 정한 값이 안 사라짐.
"""
table = build_table(
원단위표(
성분("돌", "ton", 3.0, spec="야면석·호박돌"),
성분("돌", "ton", 2.0, spec="깬잡석"),
성분("채움콘크리트", "㎥", 1.0, spec="180"),
성분("채움콘크리트", "㎥", 4.0, spec="210"),
),
supply_map={
"야면석·호박돌": SUPPLY_CONTRACTOR, # 옛 열쇠(종류 이름)
"돌 깬잡석": SUPPLY_OWNER, # 새 열쇠(이름 + 규격)
"채움콘크리트": SUPPLY_CONTRACTOR, # 옛 열쇠(이름만)
},
)
by = {(r["name"], r["spec"]): r for r in table["rows"]}
assert table["row_count"] == 4
assert by[("돌", "야면석·호박돌")]["net_amount"] == pytest.approx(3.0)
assert by[("돌", "야면석·호박돌")]["supply"] == SUPPLY_CONTRACTOR
assert by[("돌", "깬잡석")]["supply"] == SUPPLY_OWNER
assert by[("돌", "깬잡석")]["supply_key"] == "돌 깬잡석"
assert by[("채움콘크리트", "210")]["supply"] == SUPPLY_CONTRACTOR
assert table["missing_install_by_materials"] == ["돌 깬잡석"]
def test_바닥막이_메붙임_돌은_규격에_야면석() -> None:
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import bed_sill
components, _notes = bed_sill(10.0, {"form": "돌붙임(메)"})
stone = next(c for c in components if c.unit == "ton")
assert (stone.name, stone.spec, stone.destination) == ("돌", "야면석", "material")
# ── 할증률은 데이터에서, 없으면 드러낸다 ────────────────────────────
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"].split(" · ")[0] == 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-14 브레인 판정 ⓐ — 기본 사급 · 관급은 산출 조건이 정함(종전 `unknown` 은 어느
합계에도 안 들어 자재비가 통째로 빠짐). 명시적으로 미정을 고른 줄은 그대로 미정."""
table = build_table(원단위표(성분("야면석", "㎥", 5.0)))
assert (table, "야면석")["supply"] == "contractor_supplied"
assert "기본 사급" in (table, "야면석")["note"]
assert table["missing_supply_materials"] == []
picked = build_table(원단위표(성분("야면석", "㎥", 5.0)), supply_map={"야면석": "unknown"})
assert (picked, "야면석")["supply"] == SUPPLY_UNKNOWN
assert (picked, "야면석")["supply_label"] == "미정(발주기관 결정)"
assert picked["missing_supply_materials"] == ["야면석"]
def test_율이_없는_까닭이_줄에_뜸() -> None:
"""보류(조건이 다름)와 이름 없음을 가름 — 「미확보」만으로는 할 일이 안 보임."""
table = build_table(원단위표(성분("막자갈", "㎥", 10.0), 성분("못", "㎏", 1.0)))
assert (table, "막자갈")["surcharge_pct"] is None
assert "조건이 다름 — 사용자 확정 후 옮길 것" in (table, "막자갈")["note"]
assert "할증률표에 이름 없음" in (table, "못")["note"]
def test_할증률은_표값_이내에서만_고름() -> None:
"""1-3-1 「표의 값 이내」 — 0~표값만 받고 넘거나 표에 없는 자재는 안 씀."""
fake = SurchargeTable(rates={"판재": {"material": "판재", "rate": 10}})
unit = 원단위표(성분("판재", "㎥", 1.0), 성분("낯선자재", "㎥", 1.0))
lower = build_table(unit, surcharge_table=fake, surcharge_overrides={"판재": 6, "낯선자재": 3})
assert (lower, "판재")["surcharge_pct"] == 6 and (lower, "판재")["surcharge_cap"] == 10
assert "표 상한 10% 이내" in (lower, "판재")["note"]
assert (lower, "낯선자재")["surcharge_pct"] is None # 표에 없으면 고른 값도 안 씀
over = build_table(unit, surcharge_table=fake, surcharge_overrides={"판재": 12})
assert (
(over, "판재")["surcharge_pct"] == 10
and "이내가 아니라 안 씀" in (over, "판재")["note"]
)
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)