- 양식 갈음의 종류 이름 치환(_downstream_name) 삭제 · 돌쌓기·골막이·바닥막이(메) 전개도 「돌」+규격 - 자재총괄 줄에 spec·supply_key — 관급구분 열쇠는 이름+규격, 옛 열쇠(이름만·종류 이름)도 받음 - 화면 자재총괄 이름 칸·원단위 성분 칸에 규격 표시, 관급 선택은 supply_key 로 저장 - 원단위 합계 열쇠에 규격 포함 · 종류 이름 DESTINATION 항목 정리 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
331 lines
14 KiB
Python
331 lines
14 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:
|
|
"""돌 줄은 이름 「돌」 + 규격에 종류(명세 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"] == 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)
|