Files
Aislo/resources/tester/test_b09_material_prices.py
T

190 lines
9.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""자재 수동 단가(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" # 말뚝 직경46㎝ — 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