Files
Aislo/resources/tester/test_b08_material_summary.py
T
eomsangdonandClaude Opus 5 89effeca07 feat(B09): 산출기초 — 줄에 달린 근거를 한 장으로 접음
별표2 (5)(가) 열셋째. 근거는 줄마다 이미 있었고 묶는 자리만 없었음.
원가계산에 「산출기초」 탭으로 세움. 넷으로 접음 —
① 어느 판으로 계산했나(데이터 기준일·지문) ② 무엇을 골랐나(고른 값만)
③ 공종마다 무엇을 근거로 했나(줄 문구 그대로) ④ 못 채운 자리(0 으로 안 때운 자리).

- 판 목록은 장부(_manifest.json)를 그대로 읽음 — 목록을 따로 적으면 한쪽만 고쳐짐.
- ⚠ 여기서 값을 다시 계산하지 않음 — 금액 칸이 아예 없음(두 벌 방지, 시험으로 못 박음).

⇒ 「설계서 구성」의 산출기초가 반쪽 → 있음. 법이 정한 13 중 9 가 서고,
  우리가 더 낼 것은 공사설명서 하나(서식·설계하중 표기는 사용자에게 받아야 함).

곁들여: 자재총괄의 「미분류」를 「미정(발주기관 결정)」으로 고침 — 발주기관이 정할
자리인데 우리가 못 만든 것처럼 읽히던 문구(V-14). 값·판정은 그대로.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 00:06:22 +09:00

294 lines
12 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"] == 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)