Files
Aislo/resources/tester/test_b08_handoff.py
T

1416 lines
64 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.
"""B08 → B09 인계 검사 — PLAN 8-2 · 2026-09-07 3자 조율 계약.
이 일감의 위험은 **축을 섞는 것**이다.
· 자재 줄에 공종코드가 붙으면 자재가 내역 줄로 오해된다 — 그게 곧 이중계상이다.
· `in_bill` 을 안 실으면 무대(㉡)가 ④예산내역서에 서서 운반비가 두 번 붙는다.
· 못 이은 줄을 빈 코드로 두면 「없어진 줄」과 구별이 안 된다.
"""
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_Handoff import ( # noqa: E402
WorkItemMapping,
build_handoff,
iter_bill_rows,
load_mapping,
summarize,
verify_bill_flags,
verify_no_code_on_materials,
)
from B08_Quantity.B08_Quantity_Engine_MaterialSummary import ( # noqa: E402
SUPPLY_OWNER,
build_table as build_material_table,
)
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( # noqa: E402
build_table as build_unit_table,
)
def 집계표(*rows: dict) -> dict:
return {"rows": list(rows)}
def 집계줄(group: str, item: str = "", amount: float = 1.0, **extra) -> dict:
return {"group": group, "item": item, "spec": "", "unit": "㎥", "amount": amount, **extra}
def 구조물() -> dict:
return {
"structure_id": "s1",
"type_id": "masonry_wet",
"start_m": 35.0,
"end_m": 45.0,
"options": {"height_m": 1.5, "length_m": 10.0},
}
def (handoff: dict, name: str) -> dict:
return next(row for row in handoff["work_items"] if row["name"] == name)
# ── 축 둘을 섞지 않는다 ─────────────────────────────────────────────
def test_자재_줄에는_공종코드가_없음() -> None:
"""자재에 공종코드가 붙으면 내역 줄로 오해될 자리가 생긴다(8-2 이중계상 함정)."""
unit = build_unit_table([구조물()])
handoff = build_handoff(unit_quantity_table=unit, material_table=build_material_table(unit))
assert handoff["materials"]
assert all("work_item_code" not in row for row in handoff["materials"])
assert verify_no_code_on_materials(handoff) == []
def test_섞이면_검사가_잡을것() -> None:
"""규칙이 주석에만 있으면 지켜지지 않는다 — 오염된 모양을 만들어 검사를 시험한다."""
tainted = {"materials": [{"material_name": "야면석", "work_item_code": "FP-13-04-05"}]}
assert verify_no_code_on_materials(tainted) == ["야면석"]
def test_구조물은_한_줄로_서고_전개_성분은_안_옴() -> None:
"""내역 줄의 실체는 `돌쌓기(찰) H=1.5·10m` 이지 그 전개인 야면석이 아니다."""
handoff = build_handoff(unit_quantity_table=build_unit_table([구조물()]))
names = [row["name"] for row in handoff["work_items"]]
# ⚠ 2026-09-09 감사 — 종전에는 레지스트리 이름이 없으면 **코드값(`masonry_wet`)이
# 그대로 내역에 떴다.** `_Wording.type_label` 이 이미 대비표를 들고 있어 그것을 쓰게
# 고쳤다(옹벽이 `retaining_wall` 로 뜨던 자리에서 잡음).
assert "돌쌓기(찰)" in names
assert not any(name.isascii() and "_" in name for name in names), names
assert "야면석" not in names
assert "터파기" not in names
# ⚠ 돌쌓기 뒤채움(채움콘크리트)은 **타설 줄로 안 선다** — 그 공종의 품에 이미
# 들어 있을 수 있어 세우면 이중계상이다(2026-09-08 ㉙).
# ⚠ **버림 콘크리트는 다름**(2026-09-09 확정 ⑭) — 기초 바닥에 따로 치는 것이라
# 실무 내역도 「레미콘타설(장비) 무근,**버림**」을 별도 줄로 세운다. 그래서 여기서는
# 타설 줄이 **하나 선다**. 그 줄이 무엇 때문에 섰는지까지 확인한다.
placing = [row for row in handoff["work_items"] if row["name"] == "콘크리트 타설"]
assert len(placing) == 1
# 그 줄의 물량은 **버림 콘크리트 몫뿐**이어야 한다 — 채움이 섞이면 이중계상이다.
blinding = next(
c
for c in build_unit_table([구조물()])["structures"][0]["components"]
if c["name"] == "버림콘크리트"
)
assert placing[0]["quantity"] == pytest.approx(blinding["amount"], abs=1e-6)
def test_구조물_줄에_측점과_규격이_실림() -> None:
handoff = build_handoff(unit_quantity_table=build_unit_table([구조물()]))
row = handoff["work_items"][0]
assert row["station_from"] == 35.0
assert row["station_to"] == 45.0
assert row["spec_detail"] == "H=1.5·L=10m"
# ⚠ 여기서 「10.0 m」를 계약으로 못 박고 있었다 — 그것이 곧 **금액 2.6배 사고**였다.
# 품셈 13-4-5 밑수는 **㎡** 이고, 받는 쪽은 ㎡ 단가를 이 수량에 그대로 곱한다.
# (오늘 다섯 번째로 옛 시험이 틀린 계약을 지키고 있던 자리다 — 2026-09-08.)
assert row["unit"] == "㎡"
# ⚠ 2026-09-09 — 기울기는 **성토/절토**까지 알아야 표에서 고를 수 있다(확정 ⑨).
# 이 시험은 단면유형을 안 주므로 판정이 「가를 근거 없음」이 되고, 그때는 **종전값
# 1:0.3** 으로 서고 사유가 근거에 적힌다. 10m × 1.5m × √(1+0.3²) = 15.660.
# ⚠ 성토로 눅여 표를 고르면 임의값이 금액으로 굳는다 — 그래서 이 값이 맞다.
assert row["quantity"] == pytest.approx(15.660, abs=0.001)
assert row["origin"] == "structure"
# ── 할증은 자재 쪽에만 ──────────────────────────────────────────────
def test_할증_전후가_자재에만_실림() -> None:
unit = build_unit_table([구조물()])
# ⚠ 타설 방식을 **비빔**으로 둔다 — 2026-09-09 부터 콘크리트가 자재 축에 서고(확정 3차 ⑥)
# 레미콘일 때는 율이 붙으므로, 「율이 없는 자재만 있는 상태」를 만들려면 비빔이라야 한다.
material = build_material_table(unit, concrete_placing_method="machine_mixed")
handoff = build_handoff(
summary_table=집계표(집계줄("흙깎기", "토사", 100.0)),
unit_quantity_table=unit,
material_table=material,
)
# ⚠ 이 자재들은 율이 표에 없어 **실제로는 안 붙었다** — 깃발이 실제와 어긋나면 안 된다
# (2026-09-07 계약 정정). 상태는 세 갈래로 말한다.
assert handoff["surcharge_status"] == "rate_unavailable"
assert handoff["surcharge_applied_to_materials"] is False
for row in handoff["materials"]:
assert "net_amount" in row and "total_amount" in row
# 작업 공종에는 할증 칸 자체가 없다.
for row in handoff["work_items"]:
assert "surcharge_pct" not in row
def test_관급구분과_설치주체가_함께_감() -> None:
unit = build_unit_table([구조물()])
# ⚠ 돌 종류를 안 고른 구조물의 자재 이름이 「야면석」 → 「돌」로 바뀜(2026-09-09
# 확정 5차 큰 것 7) — 관측표가 아니라 계산식으로 서고 이름도 정본 계산표대로다.
material = build_material_table(
unit, supply_map={"돌": {"supply": SUPPLY_OWNER, "install_by": "contractor"}}
)
handoff = build_handoff(unit_quantity_table=unit, material_table=material)
row = next(r for r in handoff["materials"] if r["material_name"] == "돌")
assert row["supply_type"] == "owner_supplied"
assert row["install_by"] == "contractor"
# ── in_bill — 무대가 내역에 서면 안 된다 (㉡) ──────────────────────
def test_무대는_넘기되_내역에는_안_섬() -> None:
"""빼고 넘기면 「빠진 줄」과 「제외된 줄」을 나중에 구별할 수 없다."""
haul = {
"rows": [
{
"equipment": "free_haul",
"ground": "토사",
"volume_m3": 800.0,
"average_distance_m": 11.9,
"in_bill": False,
},
{
"equipment": "dump_truck",
"ground": "토사",
"volume_m3": 500.0,
"average_distance_m": 1200.0,
"in_bill": True,
},
]
}
handoff = build_handoff(haul_table=haul)
free = (handoff, "free_haul 운반")
dump = (handoff, "dump_truck 운반")
assert free["in_bill"] is False
assert free["in_bill_reason"] # 왜 빠지는지 함께 간다
assert free["quantity"] == pytest.approx(800.0) # 값은 그대로 넘어간다
assert dump["in_bill"] is True
assert dump["work_item_code"] == "FP-10-12"
assert dump["haul_distance_m"] == pytest.approx(1200.0)
# 2026-09-14 — 덤프 운반마다 「덤프 적재」 짝 줄(10-12 「1. 적재」) · 거리 없음 · 수량 같음
loading = (handoff, "덤프 적재")
assert loading["haul_equipment"] == "dump_loading" and loading["haul_distance_m"] is None
assert loading["quantity"] == dump["quantity"] and loading["variant_value"] == "토사"
assert not any(r["name"] == "덤프 적재" and r["spec"] != "토사" for r in handoff["work_items"])
def test_합계_줄은_내역에_안_섬() -> None:
"""「보정량계」는 검산용이다 — 빼지 않고 깃발로 가른다."""
handoff = build_handoff(summary_table=집계표(집계줄("보정량계", amount=6511.06)))
row = handoff["work_items"][0]
assert row["in_bill"] is False
assert handoff["unmatched_work_items"] == [] # 합계는 못 이은 것이 아니다
def test_내역_줄만_뽑는_입구() -> None:
handoff = build_handoff(
summary_table=집계표(집계줄("흙깎기", "토사", 100.0), 집계줄("보정량계", amount=100.0))
)
assert [row["name"] for row in iter_bill_rows(handoff)] == ["흙깎기"]
assert handoff["bill_row_count"] == 1
assert handoff["excluded_row_count"] == 1
# ── 매핑 — 데이터에서 오고, 못 찾으면 드러낸다 ─────────────────────
def test_실제_매핑판이_읽힘() -> None:
mapping = load_mapping()
assert mapping.effective_date
assert mapping.for_earthwork("흙깎기", "토사")["work_item_code"] == "FP-09-03-02"
def test_지반유형별로_다른_코드() -> None:
"""측구터파기는 토사·암절취·발파암이 품셈에서 갈린다."""
mapping = load_mapping()
assert mapping.for_earthwork("측구터파기", "토사")["work_item_code"] == "FP-09-12-01"
assert mapping.for_earthwork("측구터파기", "발파암")["work_item_code"] == "FP-09-12-03"
def test_지반을_안_가르는_공종은_ground_없이_맞음() -> None:
mapping = load_mapping()
assert mapping.for_earthwork("층따기", None)["work_item_code"] == "FP-09-18"
assert mapping.for_earthwork("층따기", "토사")["work_item_code"] == "FP-09-18"
def test_못_이은_줄은_빈_코드로_두지_않고_목록으로() -> None:
"""빈칸이면 「코드 없는 줄」과 「매핑을 못 찾은 줄」이 구별되지 않는다."""
handoff = build_handoff(summary_table=집계표(집계줄("듣도보도못한공종", amount=5.0)))
assert handoff["work_items"][0]["work_item_code"] is None
assert handoff["unmatched_work_items"] == ["듣도보도못한공종"]
def test_코드가_없는데_내역에_서면_검사가_잡음() -> None:
"""빈 코드로 내역에 세우면 B09 가 단가를 못 붙인 0원 줄을 만든다."""
handoff = build_handoff(summary_table=집계표(집계줄("듣도보도못한공종", amount=5.0)))
assert verify_bill_flags(handoff) == ["듣도보도못한공종"]
def test_매핑_파일이_없으면_전부_드러남() -> None:
handoff = build_handoff(
summary_table=집계표(집계줄("흙깎기", "토사", 100.0)), mapping=WorkItemMapping()
)
assert handoff["unmatched_work_items"] == ["흙깎기(토사)"]
def test_정할_근거가_없는_자리는_지어내지_않고_알림() -> None:
"""암 갈래는 고를 근거가 없다 — 임의 확정 금지(3장).
⭐ 2026-09-13 판정으로 지장목제거는 작업 갈래(뿌리뽑기 9-21 · 잡관목제거 4-2-2)로 이어졌다 —
**작업 갈래가 없는** 지장목제거 줄만 여전히 못 잇는다.
"""
handoff = build_handoff(summary_table=집계표(집계줄("지장목제거", amount=100.0)))
assert handoff["work_items"][0]["work_item_code"] is None
assert "지장목제거" in handoff["unmatched_work_items"]
# ⚠ 미결 항목은 **공종(`group`)** 으로도 **구조물 종류(`type_id`)** 로도 들어온다 —
# 둘 다 「고를 근거가 없는 자리」라 한 목록에 담는다. 키가 하나뿐이라고 보면 깨진다.
keys = [
item.get("group") or item.get("type_id")
for item in handoff["mapping_pending_user"]["items"]
]
assert "지장목제거" not in keys # 판정으로 닫힘(2026-09-13)
# ⚠ 큰돌쌓기는 **닫혔다** — 랩탑이 `bond`(메쌓기/찰쌓기) 칸을 만들어 자동으로 갈린다.
# 미결 목록에서 빠졌는지도 함께 본다(고쳐졌는데 목록만 남는 것 방지).
assert "boulder_masonry" not in keys
# ── 사면 계열은 출처가 다르다 ──────────────────────────────────────
def test_사면_계열은_origin_이_slope() -> None:
handoff = build_handoff(
summary_table=집계표(집계줄("흙깎기", "토사", 100.0), 집계줄("성토면다짐", amount=50.0))
)
assert (handoff, "흙깎기")["origin"] == "earthwork"
assert (handoff, "성토면다짐")["origin"] == "slope"
def test_초류종자살포가_씨앗뿜어붙이기로_이어짐() -> None:
handoff = build_handoff(summary_table=집계표(집계줄("초류종자살포", amount=100.0)))
assert handoff["work_items"][0]["work_item_code"] == "FP-05-24"
# ── 요약 ────────────────────────────────────────────────────────────
def test_요약이_못_이은_것을_숨기지_않음() -> None:
handoff = build_handoff(
summary_table=집계표(
집계줄("흙깎기", "토사", 100.0), 집계줄("듣도보도못한공종", amount=1.0)
)
)
brief = summarize(handoff)
assert brief["bill_rows"] == 2
assert brief["unmatched"] == ["듣도보도못한공종"]
def test_판_번호가_응답에_남음() -> None:
assert build_handoff(summary_table=집계표(집계줄("흙깎기", "토사", 1.0)))["mapping_edition"]
# ── 갈래 세트 (2026-09-07 서브 이견 채택) ──────────────────────────
def test_갈래_세트가_인계본에_실림() -> None:
"""값이 「연암」이어도 몇 갈래 중 하나인지 알아야 ④에서 줄을 세운다(울진 2·거창 5)."""
handoff = build_handoff(
summary_table=집계표(집계줄("흙깎기", "연암", 100.0)),
ground_class_set="geochang5",
ground_classes=["토사", "풍화암", "연암", "보통암", "경암"],
)
assert handoff["ground_class_set"] == "geochang5"
assert handoff["ground_classes"][2] == "연암"
assert handoff["work_items"][0]["ground_class"] == "연암"
def test_무대_줄에_수량이_그대로_실림() -> None:
"""무대+도자+덤프 = 총 운반토량 검산에 쓰이는 값이다 — 빼면 검산이 죽는다."""
haul = {
"rows": [
{
"equipment": "free_haul",
"ground": "토사",
"volume_m3": 871.0,
"average_distance_m": 11.94,
"in_bill": False,
},
]
}
row = build_handoff(haul_table=haul)["work_items"][0]
assert row["in_bill"] is False
assert row["quantity"] == pytest.approx(871.0)
# ── 암 시공법 (2026-09-07 일감 9 실서버에서 드러난 자리) ───────────
def test_시공법을_정하면_공종이_갈림() -> None:
"""품셈은 긁어내기(암절취)와 터뜨리기(발파암)를 다른 공종으로 둔다."""
ripping = build_handoff(
summary_table=집계표(집계줄("흙깎기", "연암", 100.0)),
ground_methods={"연암": "ripping"},
)
blasting = build_handoff(
summary_table=집계표(집계줄("흙깎기", "연암", 100.0)),
ground_methods={"연암": "blasting"},
)
assert ripping["work_items"][0]["work_item_code"] == "FP-09-04"
assert blasting["work_items"][0]["work_item_code"] == "FP-09-05"
# 갈래 이름은 그대로 남는다 — 바꿔치기하지 않는다.
assert ripping["work_items"][0]["ground_class"] == "연암"
assert ripping["work_items"][0]["excavation_method"] == "ripping"
def test_시공법을_안_정하면_찍지_않고_드러냄() -> None:
"""잘못 찍으면 공종이 조용히 틀린다 — 기본값으로 때우지 않는다."""
handoff = build_handoff(summary_table=집계표(집계줄("흙깎기", "보통암", 100.0)))
row = handoff["work_items"][0]
assert row["work_item_code"] is None
assert row["excavation_method"] is None
assert handoff["missing_method_classes"] == ["보통암"]
assert any("시공법" in item for item in handoff["unmatched_work_items"])
def test_갈래로_안_나뉜_암은_구성비가_빠졌다고_말함() -> None:
"""2026-09-14 — 한 줄 「암」은 시공법만 골라서는 안 풀림. B09 내역도 이 까닭을 그대로 실음."""
handoff = build_handoff(summary_table=집계표(집계줄("흙깎기", "암", 100.0)))
row = handoff["work_items"][0]
assert row["work_item_code"] is None and row["blocked_kind"] == "input_missing"
assert "암 갈래 구성비" in row["blocked_reason"]
assert handoff["missing_method_classes"] == [] # 시공법 안내에 「암」을 안 띄움
from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill
bill = build_bill(handoff)
assert any("입력이 필요합니다 — 암 갈래 구성비" in m["reason"] for m in bill.missing)
def test_토사는_시공법이_필요없음() -> None:
handoff = build_handoff(summary_table=집계표(집계줄("흙깎기", "토사", 100.0)))
assert handoff["work_items"][0]["work_item_code"] == "FP-09-03-02"
assert handoff["missing_method_classes"] == []
def test_측구터파기도_시공법을_따라감() -> None:
handoff = build_handoff(
summary_table=집계표(집계줄("측구터파기", "경암", 50.0)),
ground_methods={"경암": "blasting"},
)
assert handoff["work_items"][0]["work_item_code"] == "FP-09-12-03"
# ── 고른 값을 되돌릴 수 있어야 한다 (2026-09-07 화면에서 걸린 자리) ──
def test_시공법을_안_정함으로_되돌릴_수_있을것(tmp_path) -> None:
"""병합 저장이면 한 번 고른 값이 영영 남는다 — 지울 수 있는 칸은 통째로 갈아 끼운다."""
from common_util.common_util_project_settings import quantity_settings, save_section
save_section(
tmp_path, "quantity", {"rock_methods": {"연암": "ripping"}}, replace_keys=("rock_methods",)
)
assert quantity_settings(tmp_path)["rock_methods"] == {"연암": "ripping"}
# 「안 정함」으로 되돌리면 빈 칸이 되어야 한다.
save_section(tmp_path, "quantity", {"rock_methods": {}}, replace_keys=("rock_methods",))
assert quantity_settings(tmp_path)["rock_methods"] == {}
def test_되돌리기_대상이_아닌_칸은_그대로_병합될것(tmp_path) -> None:
"""`replace_keys` 를 안 준 칸까지 통째로 덮으면 남의 값이 사라진다."""
from common_util.common_util_project_settings import quantity_settings, save_section
save_section(tmp_path, "quantity", {"rock_ratios_pct": {"연암": 40}})
save_section(tmp_path, "quantity", {"rock_ratios_pct": {"경암": 60}})
assert quantity_settings(tmp_path)["rock_ratios_pct"] == {"연암": 40, "경암": 60}
# ── 반영률 — 곱하기는 B08 한 곳에서만 (2026-09-07 3자 계약) ────────
def test_반영률_세_칸이_함께_감() -> None:
"""율만 보내면 B09 가 또 곱해 값이 두 배가 된다 — 곱한 값·곱하기 전 값·율을 함께 싣는다."""
handoff = build_handoff(
summary_table=집계표(
{
"group": "성토면다짐",
"item": "",
"spec": "",
"unit": "㎡",
"amount": 10814.9,
"amount_gross": 13518.62,
"application_ratio_pct": 80.0,
}
)
)
row = handoff["work_items"][0]
assert row["quantity"] == pytest.approx(10814.9)
assert row["quantity_gross"] == pytest.approx(13518.62)
assert row["application_ratio_pct"] == 80.0
def test_세_칸이_어긋나면_검사가_잡음() -> None:
"""세 값을 실어 두고 서로 안 맞으면 받는 쪽이 어느 값을 믿을지 모른다."""
handoff = build_handoff(
summary_table=집계표(
{
"group": "성토면다짐",
"unit": "㎡",
"amount": 13518.62, # 곱하기를 빠뜨린 모양
"amount_gross": 13518.62,
"application_ratio_pct": 80.0,
}
)
)
assert handoff["ratio_math_warnings"]
assert "성토면다짐" in handoff["ratio_math_warnings"][0]
def test_정상이면_경고가_없음() -> None:
"""검사를 만들어 두고 안 부르면 없는 것과 같다 — 표가 실제로 부르는지 본다."""
handoff = build_handoff(summary_table=집계표(집계줄("흙깎기", "토사", 100.0)))
assert handoff["ratio_math_warnings"] == []
def test_반영률_개념이_없는_줄은_None() -> None:
"""운반·구조물에는 반영률이 없다 — 0 이 아니라 `None` 이다."""
handoff = build_handoff(unit_quantity_table=build_unit_table([구조물()]))
assert handoff["work_items"][0]["application_ratio_pct"] is None
assert handoff["work_items"][0]["quantity_gross"] is None
def test_집계_엔진이_세_칸을_실제로_냄() -> None:
"""엔진 출력이 계약대로인지 — 손으로 만든 모양이 아니라 진짜 산출물로 본다."""
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import SummaryInput
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import build_table as build_summary
table = build_summary(
SummaryInput(
earthwork_totals={},
slope_totals={
"face_dressing_fill": 1000.0,
"face_dressing_cut": 500.0,
"tree_removal_fill": 1000.0,
"tree_removal_cut": 500.0,
"bench_cut_fill": 1000.0,
},
rock_classes=["토사", "암"],
application_ratios={
"fill_slope_compaction": 0.8,
"seed_spray_fill": 0.5,
"seed_spray_cut": 0.5,
"obstacle_removal": 0.8,
},
)
)
packed = build_handoff(summary_table=table)
compaction = (packed, "성토면다짐")
assert compaction["quantity"] == pytest.approx(800.0)
assert compaction["quantity_gross"] == pytest.approx(1000.0)
assert compaction["application_ratio_pct"] == pytest.approx(80.0)
# 100 % 인 줄도 율을 적는다 — 비어 있으면 「적용됐는지」를 받는 쪽이 못 정한다.
assert packed["ratio_math_warnings"] == []
# ── 할증 상태는 세 갈래 ─────────────────────────────────────────────
def test_율을_못_찾았으면_붙였다고_말하지_않음() -> None:
"""두 갈래로 두면 나중에 진짜 율이 들어왔을 때 B09 가 한 번 더 붙인다."""
unit = build_unit_table([구조물()])
# 야면석·막자갈 — 율이 표에 없다. 콘크리트는 비빔이라 레미콘 율이 안 붙는다(확정 3차 ⑥).
material = build_material_table(unit, concrete_placing_method="machine_mixed")
handoff = build_handoff(unit_quantity_table=unit, material_table=material)
assert handoff["surcharge_status"] == "rate_unavailable"
assert handoff["surcharge_applied_to_materials"] is False
def test_실제로_붙었으면_applied() -> None:
from B08_Quantity.B08_Quantity_Engine_MaterialSummary import SurchargeTable
unit = {
"structures": [
{
"name": "A",
"components": [
{"name": "모래", "unit": "㎥", "amount": 10.0, "destination": "material"}
],
}
],
"surcharge_applied": False,
}
material = build_material_table(
unit, surcharge_table=SurchargeTable(rates={"모래": {"material": "모래", "rate": 6}})
)
handoff = build_handoff(unit_quantity_table=unit, material_table=material)
assert handoff["surcharge_status"] == "applied"
assert handoff["surcharge_applied_to_materials"] is True
def test_자재가_없으면_not_applied() -> None:
handoff = build_handoff(material_table=build_material_table({"structures": []}))
assert handoff["surcharge_status"] == "not_applied"
def test_관급구분_안_정하면_기본_사급() -> None:
"""2026-09-14 브레인 판정 ⓐ — 안 정한 자재는 사급으로 인계(관급은 산출 조건이 정함)."""
unit = build_unit_table([구조물()])
handoff = build_handoff(unit_quantity_table=unit, material_table=build_material_table(unit))
assert handoff["materials"]
assert all(row["supply_type"] == "contractor_supplied" for row in handoff["materials"])
# ── 운반 줄 (2026-09-07 조율 창 요청) ───────────────────────────────
#
# 실물 프로젝트에서 운반 칸이 전부 `null` 로 나온 일이 있었다. 원인은 **종단 [확정] 전이라
# 운반계획이 아직 없는 것**이지 결함이 아니었다(운반 표 자체가 빈 표였다). 다만 그 상태에서는
# 서브의 ㉡ 가드가 **걸 것이 없어 놀고 있으므로**, 계획이 있을 때 실제로 실리는지를 못 박는다.
def 운반표() -> dict:
"""운반계획이 있는 프로젝트가 내는 모양 — 수단 × 지반유형."""
return {
"rows": [
{
"equipment": "free_haul",
"ground": "토사",
"volume_m3": 871.0,
"average_distance_m": 11.94,
"in_bill": False,
},
{
"equipment": "dozer",
"ground": "토사",
"volume_m3": 1240.0,
"average_distance_m": 42.0,
"in_bill": True,
},
{
"equipment": "dump_truck",
"ground": "토사",
"volume_m3": 2100.0,
"average_distance_m": 1350.0,
"in_bill": True,
},
{
"equipment": "dump_truck",
"ground": "발파암",
"volume_m3": 980.0,
"average_distance_m": 1350.0,
"in_bill": True,
},
]
}
def test_운반계획이_있으면_네_줄이_실림() -> None:
handoff = build_handoff(haul_table=운반표())
haul_rows = [row for row in handoff["work_items"] if row["origin"] == "haul"]
# 운반 넷 + 덤프 운반 둘의 적재 짝 줄 둘(2026-09-14 · 10-12 「1. 적재」 — 거리 없음)
moves = [row for row in haul_rows if row["haul_equipment"] != "dump_loading"]
assert len(moves) == 4 and len(haul_rows) == 6
assert all(row["haul_equipment"] for row in haul_rows)
assert all(row["haul_distance_m"] for row in moves)
def test_무대가_함께_와야_운반_검산이_걸림() -> None:
"""무대를 빼고 넘기면 `무대+도자+덤프 = 총 운반토량` 검산이 죽는다."""
handoff = build_handoff(haul_table=운반표())
free = (handoff, "free_haul 운반")
assert free["in_bill"] is False
assert free["quantity"] == pytest.approx(871.0)
assert handoff["excluded_row_count"] == 1
assert handoff["bill_row_count"] == 5 # 도자 · 덤프 운반 둘 · 덤프 적재 짝 줄 둘
def test_운반계획이_없으면_줄이_없음() -> None:
"""빈 표는 결함이 아니라 **종단 [확정] 전** 상태다 — 0 줄이 정상이다."""
handoff = build_handoff(haul_table={"rows": []})
assert [row for row in handoff["work_items"] if row["origin"] == "haul"] == []
def test_율이_부분마다_다르면_칸으로_갈라_실림() -> None:
"""비고 문장에 적으면 받는 쪽이 문자열을 뜯어야 한다 — 칸으로 준다(2026-09-07 계약)."""
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import SummaryInput
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import build_table as build_summary
table = build_summary(
SummaryInput(
slope_totals={"face_dressing_fill": 1000.0, "face_dressing_cut": 500.0},
rock_classes=["토사", "암"],
application_ratios={"seed_spray_fill": 0.5, "seed_spray_cut": 1.0},
)
)
row = (build_handoff(summary_table=table), "초류종자살포")
assert row["application_ratio_pct"] is None # 하나의 율로 못 적는다
assert row["application_ratio_breakdown"] == {"fill": 50.0, "cut": 100.0}
assert row["quantity_breakdown"] == {"fill": pytest.approx(500.0), "cut": pytest.approx(500.0)}
assert row["quantity"] == pytest.approx(1000.0 * 0.5 + 500.0 * 1.0)
assert row["quantity_gross"] == pytest.approx(1500.0)
# 갈래 물량의 합이 총량과 맞아야 한다 — 안 맞으면 받는 쪽이 어느 값을 믿을지 모른다.
assert sum(row["quantity_breakdown"].values()) == pytest.approx(row["quantity"])
def test_율이_같아도_breakdown_은_늘_실림() -> None:
"""⚠ 계약 확정(2026-09-07) — **늘 breakdown**, `pct` 는 편의값이다.
「같을 때만 pct, 다를 때만 breakdown」으로 두면 받는 쪽에 갈래가 둘 생기고
그게 나중에 **한쪽만 고쳐지는** 자리가 된다."""
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import SummaryInput
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import build_table as build_summary
table = build_summary(
SummaryInput(
slope_totals={"face_dressing_fill": 1000.0, "face_dressing_cut": 500.0},
rock_classes=["토사", "암"],
application_ratios={"seed_spray_fill": 0.8, "seed_spray_cut": 0.8},
)
)
row = (build_handoff(summary_table=table), "초류종자살포")
assert row["application_ratio_pct"] == pytest.approx(80.0) # 편의값은 채워진다
assert row["application_ratio_breakdown"] == {"fill": 80.0, "cut": 80.0}
assert row["quantity_breakdown"] == {"fill": pytest.approx(800.0), "cut": pytest.approx(400.0)}
def test_갈래별_율일_때_거울_검사가_헛경고를_안_냄() -> None:
"""`quantity == gross × 율` 이 성립 안 하는 줄이므로 검사가 건너뛰어야 한다."""
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import SummaryInput
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import build_table as build_summary
table = build_summary(
SummaryInput(
slope_totals={"face_dressing_fill": 1000.0, "face_dressing_cut": 500.0},
rock_classes=["토사", "암"],
application_ratios={"seed_spray_fill": 0.5, "seed_spray_cut": 1.0},
)
)
assert build_handoff(summary_table=table)["ratio_math_warnings"] == []
# ── 묶음으로 서는 줄 (2026-09-07 ⑩) ────────────────────────────────
def test_품셈에_없는_공종은_묶음으로_적힘() -> None:
"""품셈 12장에 「옹벽」 공종이 없다 — 빈 코드로 두면 「매핑을 못 찾은 줄」과 구별이 안 된다."""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit
unit = build_unit(
[
{
"structure_id": "w1",
"type_id": "retaining_wall",
"start_m": 100.0,
"end_m": 110.0,
"options": {"form": "반중력식", "height_m": 2.0, "length_m": 10.0},
}
],
{"retaining_wall": "옹벽"},
)
handoff = build_handoff(unit_quantity_table=unit)
row = handoff["work_items"][0]
assert row["work_item_code"] is None
assert row["composite_parts"] # 무엇으로 묶이는지 적혀 있다
assert "옹벽" in row["in_bill_reason"]
# 묶음 줄은 「코드 없이 내역에 선 줄」 경고에 걸리지 않는다.
assert verify_bill_flags(handoff) == []
assert handoff["unmatched_work_items"] == []
def test_집수정은_품셈_공종이_있어_바로_이어짐() -> None:
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit
unit = build_unit(
[
{
"structure_id": "p1",
"type_id": "pipe",
"start_m": 50.0,
"end_m": 50.0,
"options": {"inlet_basin_form": "돌집수정 ㄷ형"},
}
],
{"pipe": "배수관"},
)
handoff = build_handoff(unit_quantity_table=unit)
basin = next(r for r in handoff["work_items"] if "집수정" in r["name"])
assert basin["work_item_code"] == "FP-12-15"
# ── 콘크리트 타설 갈래 (2026-09-07 3자 확정) ───────────────────────
def test_철근_유무로_구조물_종류가_자동_판정될것() -> None:
"""사람이 고르는 값이 아니다 — 옹벽 관측 원단위의 D13·D16 에서 그대로 나온다."""
from B08_Quantity.B08_Quantity_Engine_Handoff import structure_kind
with_rebar = {"components": [{"name": "이형철근 D13"}, {"name": "콘크리트"}]}
without = {"components": [{"name": "콘크리트"}, {"name": "합판거푸집"}]}
assert structure_kind(with_rebar) == "철근구조물"
assert structure_kind(without) == "무근구조물"
def test_옹벽이_철근구조물로_섬() -> None:
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit
unit = build_unit(
[
{
"structure_id": "w1",
"type_id": "retaining_wall",
"start_m": 100.0,
"end_m": 110.0,
"options": {"form": "반중력식", "height_m": 2.0, "length_m": 10.0},
}
],
{"retaining_wall": "옹벽"},
)
row = build_handoff(unit_quantity_table=unit)["work_items"][0]
assert row["structure_kind"] == "철근구조물"
# ⚠ 계약이 바뀐 자리 — 처음엔 「일위대가가 아직 안 선 공종」 목록이었으나, B09 가 넷을
# 다 세운 뒤로는 **「물량을 못 채운 조각」**을 사유와 함께 낸다. 시험도 함께 옮긴다
# (「옛 시험이 틀린 계약을 못 박고 있었다」를 오늘 이미 한 번 겪었다).
# ⚠ 사유를 **구조로** 낸다 — 받는 쪽이 「단가 없음」과 「물량 없음」을 갈라야 한다.
# ⭐ 2026-09-09 채워진 자리 — 기초잡석이 **버림 폭에서** 나온다(확정 3차 ②).
# 관측 원단위에 그 줄이 없어도 버림 0.15㎥/m ÷ 0.1 = 폭 1.5m 이고 두께 0.2m 이라
# 0.30㎥/m 로 선다. ⇒ **묶음 다섯 조각이 다 찼다.**
assert row["composite_not_ready"] is None
def test_타설_방식은_설정이_고르고_기본은_레디믹스트() -> None:
from B08_Quantity.B08_Quantity_Engine_Handoff import load_mapping, placing_code
mapping = load_mapping()
assert placing_code(mapping, "ready_mixed") == ("FP-12-01-01", False)
assert placing_code(mapping, "hand_mixed") == ("FP-12-01-03", False)
# 모르는 방식이면 기본으로 떨어지되 **그 사실을 알린다**.
code, used_default = placing_code(mapping, None)
assert code == "FP-12-01-01" and used_default is True
# ── 묶음 조각의 부위별 수량 · 철근 갈래 (2026-09-07 3자) ────────────
def 옹벽단위(form: str = "반중력식"):
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit
return build_unit(
[
{
"structure_id": "w1",
"type_id": "retaining_wall",
"start_m": 100.0,
"end_m": 110.0,
"options": {"form": form, "height_m": 2.0, "length_m": 10.0},
}
],
{"retaining_wall": "옹벽"},
)
def 조각(row: dict, code_fragment: str) -> dict:
return next(p for p in row["composite_parts"] if code_fragment in str(p.get("code")))
def test_조각마다_수량과_단위가_실릴것() -> None:
"""코드만 보내면 받는 쪽이 상세 줄을 못 세운다."""
row = build_handoff(unit_quantity_table=옹벽단위())["work_items"][0]
# 콘크리트 1.345 + 버림 0.145, ×10m — 2026-09-14 원문 값(전 라이브러리 1.35 + 0.15 → 15.0)
assert 조각(row, "12-01-01")["quantity"] == pytest.approx(14.9)
assert 조각(row, "12-04")["quantity"] == pytest.approx(6.0)
assert 조각(row, "12-38")["quantity"] == pytest.approx(32.05)
def test_철근은_ton_으로_환산될것() -> None:
"""⚠ 단가가 원/ton 인데 원단위는 ㎏ 이다 — 안 맞추면 **1000배** 틀린다."""
row = build_handoff(unit_quantity_table=옹벽단위())["work_items"][0]
rebar = 조각(row, "12-03")
assert rebar["unit"] == "ton"
# D13 13.45 + D16 30.42 = 43.87 ㎏/m × 10m = 438.7 ㎏ = 0.4387 ton
assert rebar["quantity"] == pytest.approx(0.4387)
def test_조각마다_근거가_실릴것() -> None:
"""치수 전개와 관측값이 한 묶음에 섞이므로 조각별로 보여야 한다."""
row = build_handoff(unit_quantity_table=옹벽단위())["work_items"][0]
assert 조각(row, "12-04")["basis_kind"] == "observed"
def test_철근_갈래가_원문으로_자동_판정될것() -> None:
"""⚠ 거푸집 사용횟수와 같은 자리 — 품셈 12-3 [주]① 이 구조물 예시로 갈라 둔다.
판정할 수 있는 것을 사람에게 물으면 그것이 곧 미결이 된다.
⚠ 판정은 **형식만 보는 순수 함수**라 직접 시험한다. 조각으로만 보면 관측 원단위가 있는
형식(반중력식)밖에 못 봐, **판정되는 것만 확인하고 나머지는 한 번도 안 도는** 자리가 된다.
"""
from B08_Quantity.B08_Quantity_Engine_Handoff import rebar_complexity
for form, expected in (("중력식", "간단"), ("반중력식", "보통"), ("부벽식", "복잡")):
kind, why = rebar_complexity("retaining_wall", {"form": form})
assert kind == expected
assert "12-3" in why
def test_원문에_없는_형식은_지어내지_않을것() -> None:
"""캔틸레버식 옹벽은 [주]① 예시에 없다 — 「보통」으로 때우지 않는다."""
from B08_Quantity.B08_Quantity_Engine_Handoff import rebar_complexity
kind, why = rebar_complexity("retaining_wall", {"form": "캔틸레버식"})
assert kind is None
assert "캔틸레버식" in why
def test_판정된_갈래가_조각_코드에_붙을것() -> None:
"""원단위가 있는 형식에서 코드가 실제로 갈리는지 — 함수 판정과 조각을 잇는 자리."""
row = build_handoff(unit_quantity_table=옹벽단위("반중력식"))["work_items"][0]
rebar = 조각(row, "12-03")
assert rebar["code"] == "FP-12-03#보통"
assert rebar["kind"] == "보통"
def test_원단위가_통째로_없으면_한_줄로_말할것() -> None:
"""⚠ 같은 사유를 조각마다 다섯 번 반복하면 **진짜 사유가 묻힌다**
(화면에서 실제로 그렇게 보였다). 캔틸레버식은 관측 원단위 자체가 없다."""
row = build_handoff(unit_quantity_table=옹벽단위("캔틸레버식"))["work_items"][0]
assert row["composite_parts"] == []
assert len(row["composite_not_ready"]) == 1
reason = row["composite_not_ready"][0]["reason"]
assert "자료에 없습니다" in reason
# 「없다」만 말하지 않고 **표에 있는 규격**을 함께 알린다.
assert "반중력식" in reason
def test_기초잡석은_버림_폭에서_선다() -> None:
"""⭐ 옛 이름은 「물량을 못 채운 조각은 0 이 아니라 사유와 함께」였다.
그 조각이 **채워졌다**(2026-09-09 확정 3차 ②). 버림 폭이 곧 잡석다짐 폭이라
(KCS 34 50 05) 두께 비만 곱하면 관측 원단위 구조물에도 값이 선다 —
버림 1.45㎥ × (0.2 ÷ 0.1) = 2.9㎥(= 0.29㎥/m × 10m · 2026-09-14 원문 버림 0.145, 전 3.0).
「못 채운 조각은 0 이 아니라 사유와 함께」라는 규칙 자체는 다른 조각 시험이 지킨다.
"""
row = build_handoff(unit_quantity_table=옹벽단위())["work_items"][0]
gravel = 조각(row, "12-25")
assert gravel["quantity"] == pytest.approx(2.9)
assert not gravel.get("not_ready")
def test_유로폼_갈래도_원문이_정할것() -> None:
"""⚠ 「원문이 이미 정해 둠」 **네 번째** — 품셈 12-38-3 [주]④ 「보통: … 옹벽 …」.
(앞선 셋: 거푸집 사용횟수 1-7-1 · 철근 갈래 12-3 [주]① · 밑수는 표 위 본문.)"""
from B08_Quantity.B08_Quantity_Engine_Handoff import euroform_type
kind, why = euroform_type("retaining_wall")
assert kind == "보통"
assert "12-38-3" in why and "옹벽" in why
assert euroform_type("듣도보도못한시설")[0] is None
def test_유로폼_조각_코드에_갈래가_붙을것() -> None:
row = build_handoff(unit_quantity_table=옹벽단위("반중력식"))["work_items"][0]
assert 조각(row, "12-38")["code"] == "FP-12-38#보통"
def test_유로폼_사용횟수는_1_7_1_과_다른_자리() -> None:
"""⚠ 12-38-1 은 유로폼(강재)의 **잔존율**이고 1-7-1 은 소모성 거푸집 **전용 횟수**다.
이름이 같아 하나로 이으면 조용히 틀린다."""
from B08_Quantity.B08_Quantity_Engine_Formwork import load_formwork_table
note = load_formwork_table().euroform_type["reuse_note"]
assert "다른 자리" in note and "잔존율" in note
def test_같은_코드가_지반만_달리해_여러_줄_올_것() -> None:
"""⚠ 실물에서 잡힌 모양 — 도자운반 토사·리핑암이 **같은 코드로 두 줄** 온다.
받는 쪽이 코드로만 묶으면 한 줄이 사라진다."""
haul = {
"rows": [
{
"equipment": "dozer",
"ground": "토사",
"volume_m3": 100.0,
"average_distance_m": 40.0,
"in_bill": True,
},
{
"equipment": "dozer",
"ground": "리핑암",
"volume_m3": 200.0,
"average_distance_m": 40.0,
"in_bill": True,
},
]
}
rows = [r for r in build_handoff(haul_table=haul)["work_items"] if r["origin"] == "haul"]
assert len(rows) == 2
assert {r["work_item_code"] for r in rows} == {"FP-10-11"} # 코드는 같고
assert {r["ground_class"] for r in rows} == {"토사", "리핑암"} # 지반만 다르다
def test_갈래_키는_내부_공백만_지울것() -> None:
"""⚠ 두 창이 같은 규칙을 써야 맞는다 — 한쪽만 고치면 그날로 안 맞는다.
**다른 글자는 손대지 않는다**: 정규화를 넓히면 오늘 아홉 번 겪은 병을 새로 만든다."""
from B08_Quantity.B08_Quantity_Engine_Handoff import normalize_kind_key
assert normalize_kind_key("보 통") == "보통"
assert normalize_kind_key("매우 복잡") == "매우복잡"
assert normalize_kind_key("보통") == "보통"
# 다른 글자는 그대로 — 괄호·기호를 지우지 않는다.
assert normalize_kind_key("간단(기초)") == "간단(기초)"
def test_원문_문구를_라벨로_함께_실을것() -> None:
"""키는 공백을 지우되 **산출근거에는 원문이 보여야** 한다."""
row = build_handoff(unit_quantity_table=옹벽단위("반중력식"))["work_items"][0]
rebar = 조각(row, "12-03")
assert rebar["code"] == "FP-12-03#보통"
assert rebar["kind_label"] == "보통"
assert "12-3" in rebar["kind_basis"]
def test_build_handoff_가_검사_셋을_실제로_부른다():
"""⚠ 「만들어 두고 안 부르면 없는 것과 같다」 — 2026-09-08 ㉘ 에서 둘이 놀고 있었다.
호출 여부를 **결과 칸으로** 잰다. 칸이 없으면 안 부른 것이다.
"""
handoff = build_handoff(unit_quantity_table=build_unit_table([구조물()]))
for key in ("ratio_math_warnings", "material_code_warnings", "bill_flag_warnings"):
assert key in handoff, f"{key} 가 없다 — 검사를 안 불렀다"
def test_검사가_실제로_잡는다_일부러_깨뜨려_봄():
"""⚠ 부르기만 하고 못 잡으면 소용없다 — 일부러 깨뜨려 잡히는지 본다."""
handoff = build_handoff(unit_quantity_table=build_unit_table([구조물()]))
handoff["materials"].append({"material_name": "가짜자재", "work_item_code": "FP-99-99"})
assert verify_no_code_on_materials(handoff) == ["가짜자재"]
handoff["work_items"].append(
{"name": "코드없이내역에선줄", "in_bill": True, "work_item_code": None}
)
assert "코드없이내역에선줄" in verify_bill_flags(handoff)
# ── 콘크리트 타설 줄 (2026-09-08 ㉙) ─────────────────────────────────────────
# 품은 이 줄, 재료는 자재 쪽. 품셈 12-1-1 표가 직종·품만 주므로 겹치지 않는다.
def _콘크리트구조물(components: list[dict]) -> dict:
"""⚠ 옹벽은 **묶음으로 서는 종류**라 타설 줄이 안 생긴다(묶음 조각이 셈).
그래서 타설 줄 시험은 **묶음이 없는** 콘크리트 구조물로 한다.
"""
return {
"structure_id": "b1",
"type_id": "pipe_inlet_basin",
"name": "집수정",
"length_m": 1.0,
"components": components,
}
def test_타설_줄이_실제로_선다() -> None:
"""⚠ `placing_code()` 가 시험에서만 불리던 자리 — 이제 `build_handoff` 가 부른다."""
unit = {
"structures": [
_콘크리트구조물(
[
{"name": "콘크리트", "unit": "㎥", "amount": 13.5, "destination": "unit_price"},
{
"name": "이형철근 D13",
"unit": "kg",
"amount": 134.5,
"destination": "material",
},
]
)
]
}
handoff = build_handoff(unit_quantity_table=unit, concrete_placing_method="ready_mixed")
타설 = [r for r in handoff["work_items"] if r["name"] == "콘크리트 타설"]
assert len(타설) == 1
assert 타설[0]["work_item_code"] == "FP-12-01-01"
assert 타설[0]["quantity"] == 13.5
# 철근이 있으므로 철근구조물 — 사람이 고르는 값이 아니다.
assert 타설[0]["structure_kind"] == "철근구조물"
assert handoff["placing_notes"] == []
def test_방식을_안_정하면_기본값으로_서되_알린다() -> None:
unit = {
"structures": [
_콘크리트구조물(
[{"name": "콘크리트", "unit": "㎥", "amount": 2.0, "destination": "unit_price"}]
)
]
}
handoff = build_handoff(unit_quantity_table=unit)
타설 = next(r for r in handoff["work_items"] if r["name"] == "콘크리트 타설")
assert 타설["work_item_code"] == "FP-12-01-01" # 기본값 = 레디믹스트
assert 타설["structure_kind"] == "무근구조물"
assert handoff["placing_notes"], "기본값으로 선 사실을 안 알린다"
def test_방식을_바꾸면_공종이_갈린다() -> None:
unit = {
"structures": [
_콘크리트구조물(
[{"name": "콘크리트", "unit": "㎥", "amount": 2.0, "destination": "unit_price"}]
)
]
}
코드 = {}
for method in ("ready_mixed", "machine_mixed", "hand_mixed"):
handoff = build_handoff(unit_quantity_table=unit, concrete_placing_method=method)
코드[method] = next(
r["work_item_code"] for r in handoff["work_items"] if r["name"] == "콘크리트 타설"
)
assert 코드 == {
"ready_mixed": "FP-12-01-01",
"machine_mixed": "FP-12-01-02",
"hand_mixed": "FP-12-01-03",
}
def test_콘크리트가_없으면_타설_줄도_없다() -> None:
"""0 ㎥ 짜리 빈 줄을 만들지 않는다."""
unit = {
"structures": [
_콘크리트구조물(
[{"name": "야면석", "unit": "ton", "amount": 3.0, "destination": "material"}]
)
]
}
handoff = build_handoff(unit_quantity_table=unit, concrete_placing_method="ready_mixed")
assert not [r for r in handoff["work_items"] if r["name"] == "콘크리트 타설"]
# ── 내역 줄의 축 — 관측 원단위가 「개소당」인 종류 (2026-09-08 ㉕ 실증) ────────
def _집수정(length_m: float) -> dict:
return {
"structure_id": "b1",
"type_id": "pipe_inlet_basin",
"name": "집수정",
"start_m": 50.0,
"end_m": 50.0 + length_m,
"options": {
"inlet_basin_form": "□형(기본형)",
"inlet_basin_material": "콘크리트",
"pipe_diameter_mm": "800",
"length_m": length_m,
},
}
def test_개소_구조물은_연장으로_안_센다() -> None:
"""⚠ 연장 2m 짜리 집수정이 「2.000 m」로 나가 **값이 두 배**로 실리던 자리.
성분은 개소 기준으로 맞게 서는데 **줄의 축만** 어긋나 있어 아무 시험도 안 잡았다.
"""
handoff = build_handoff(
unit_quantity_table=build_unit_table([_집수정(2.0)], {"pipe_inlet_basin": "집수정"})
)
= next(r for r in handoff["work_items"] if r["name"] == "집수정")
assert (["unit"], ["quantity"]) == ("개소", 1.0)
def test_개소_구조물은_연장이_길어도_한_개소() -> None:
한개 = build_unit_table([_집수정(1.0)], {"pipe_inlet_basin": "집수정"})
긴것 = build_unit_table([_집수정(5.0)], {"pipe_inlet_basin": "집수정"})
for table in (한개, 긴것):
= next(
r
for r in build_handoff(unit_quantity_table=table)["work_items"]
if r["name"] == "집수정"
)
assert (["unit"], ["quantity"]) == ("개소", 1.0)
def test_m당_구조물은_그대로_연장으로_센다() -> None:
"""⚠ 좁게 고친다 — 「개소」 때문에 정상 「m」 줄까지 바꾸면 그것이 더 나쁘다."""
옹벽 = {
"structure_id": "w1",
"type_id": "retaining_wall",
"name": "옹벽",
"start_m": 100.0,
"end_m": 120.0,
"options": {"form": "반중력식", "height_m": 2.0, "length_m": 20.0},
}
= next(
r
for r in build_handoff(
unit_quantity_table=build_unit_table([옹벽], {"retaining_wall": "옹벽"})
)["work_items"]
if r["name"] == "옹벽"
)
assert (["unit"], ["quantity"]) == ("m", 20.0)
# ── 준비공·사방공 인계 (2026-09-08, 보조 창 제보) ────────────────────────────
# ⚠ 줄을 빼면 「빠졌다는 사실조차 안 보인다」 — 받는 쪽에서 「원래 없는 것」과
# 「우리가 아직 못 내는 것」이 구별되지 않는다.
def _준비공표() -> dict:
from B08_Quantity.B08_Quantity_Engine_Preparation import build_table as build_prep
slope = {
"face_dressing_fill": 9000.0,
"face_dressing_cut": 6726.9,
"tree_removal_fill": 9000.0,
"tree_removal_cut": 6726.9,
"bench_cut_fill": 9000.0,
}
return build_prep(slope, [], [], 0.15, road_surface_area_m2=8000.0)
def test_준비공_값이_서는_줄은_내역에_선다() -> None:
handoff = build_handoff(preparation_table=_준비공표())
표토 = next(r for r in handoff["work_items"] if r["name"] == "표토제거")
assert 표토["work_item_code"] == "FP-09-15-02"
assert 표토["in_bill"] is True
assert 표토["quantity"] > 0
assert 표토["blocked_kind"] is None
def test_못_내는_줄도_사유와_함께_간다() -> None:
"""⚠ 여기가 요청의 핵심 — 줄이 아예 안 가면 「우리가 만들 것」에 안 얹힌다."""
handoff = build_handoff(preparation_table=_준비공표())
제근 = next(r for r in handoff["work_items"] if r["name"] == "제근·뿌리다듬기")
# ⭐ 2026-09-09 확정 5차 6번으로 **밑수가 면적 축으로 정해져 값이 선다** — 그래서 이 줄은
# 더 이상 「못 내는 줄」의 표본이 아니다. 「못 내는 줄도 사유와 함께 간다」는 계약 자체는
# 아래 표토·부대시설 줄이 지킨다.
# ⭐ 2026-09-13 판정 Ⓑ — 셈은 토공집계 뿌리뽑기(FP-09-21)가 하고 이 줄은 **보이되 안 실린다**.
assert 제근["in_bill"] is False and 제근["quantity"] > 0
assert 제근["work_item_code"] is None and 제근["blocked_kind"] is None
assert "토공집계" in 제근["in_bill_reason"]
assert 제근["unit"] == "㎡" # 밑수가 면적 축으로 확정됨(확정 5차 6번)
# ⚠ 사유가 **두 번 바뀐 자리**다 — ㉠ 「입목 본수가 없다」(틀린 말: 본수 축이 아니었다)
# ㉡ 「밑수 단위가 원문에 없다」(사실이었으나 확정으로 닫힘) ㉢ 지금은 **값이 서고**
# 남은 것은 **단가 갈래(임목축적 등급)**뿐이다. 사유는 그 자리에 남는다.
사유 = 제근["spec_detail"] + " " + str(제근.get("blocked_reason") or "")
assert "임목축적" in 사유
assert "교차 참조" in 사유 # 건설품셈 3-9-2 를 빌려 쓴다는 표시
def test_다른_표에서_이미_선_줄은_막힌_것이_아니다() -> None:
"""벌목은 토공집계에 이미 서 있다 — **막힌 것이 아니라 여기서 세면 안 되는 것**."""
handoff = build_handoff(preparation_table=_준비공표())
벌목 = next(r for r in handoff["work_items"] if r["name"] == "벌목·지장목제거")
assert 벌목["in_bill"] is False
assert 벌목["blocked_kind"] is None, "이중계상 방지 줄을 「막힘」으로 보내면 오해된다"
assert "이미 섬" in 벌목["blocked_reason"]
def test_준비공_표를_안_주면_줄도_없다() -> None:
"""⚠ 좁게 — 표가 없을 때 빈 줄을 지어내지 않는다."""
handoff = build_handoff(unit_quantity_table=build_unit_table([구조물()]))
assert not [r for r in handoff["work_items"] if r["origin"] == "preparation"]
def test_묶음으로_서는_구조물은_타설_줄을_따로_안_만든다() -> None:
"""⚠⚠ 이중계상 — 옹벽 묶음에 `FP-12-01-01 콘크리트 타설` 조각이 이미 있다.
2026-09-08 B09 가 「철근이 겹치나」를 묻다 드러난 자리. 철근은 안 겹치고
(자재는 재료 · 묶음 조각은 품이며 그 일위대가 재료가 0원) **타설이 겹쳤다.**
묶음이 아직 미확보라 값이 안 걸렸을 뿐, 묶음이 서는 날 두 번이 된다.
"""
옹벽 = {
"structure_id": "w1",
"type_id": "retaining_wall",
"name": "옹벽",
"length_m": 10.0,
"components": [
{"name": "콘크리트", "unit": "㎥", "amount": 13.5, "destination": "unit_price"},
],
}
handoff = build_handoff(
unit_quantity_table={"structures": [옹벽]}, concrete_placing_method="ready_mixed"
)
assert not [r for r in handoff["work_items"] if r["name"] == "콘크리트 타설"]
def test_묶음이_없는_종류는_그대로_타설_줄이_선다() -> None:
"""⚠ 좁게 — 묶음 하나 때문에 정상 타설 줄까지 지우면 그것이 더 나쁘다."""
handoff = build_handoff(
unit_quantity_table={
"structures": [
_콘크리트구조물(
[
{
"name": "콘크리트",
"unit": "㎥",
"amount": 2.84,
"destination": "unit_price",
}
]
)
]
},
concrete_placing_method="ready_mixed",
)
타설 = [r for r in handoff["work_items"] if r["name"] == "콘크리트 타설"]
assert len(타설) == 1 and 타설[0]["quantity"] == 2.84
# ── B군 종단배수 — 연장이 곧 수량인 공종 (2026-09-08 V-3) ────────────────────
def _B군(type_id: str, name: str) -> dict:
return {
"structure_id": "d1",
"type_id": type_id,
"name": name,
"length_m": 40.0,
"components": [],
"notes": [f"{name}의 수량 산출식이 아직 없습니다 — 물량이 서지 않습니다"],
}
def test_연장으로_서는_공종은_전개식이_없어도_안_막힌다() -> None:
"""⚠⚠ 품셈 밑수가 **1 m** 라 연장이 곧 수량이다 — 원단위 전개가 필요 없다.
앞서 「성분이 없으면 전개식 없음」으로 단정해 B09 가 「우리가 만들 것」으로 빼
**금액이 0** 이었다(실측: 맹암거 40m 이 0원 → 고친 뒤 1,019,685원).
"""
handoff = build_handoff(unit_quantity_table={"structures": [_B군("underdrain", "맹암거")]})
= next(r for r in handoff["work_items"] if r["name"] == "맹암거")
assert ["work_item_code"] == "FP-12-10"
assert ["blocked_kind"] is None, ["blocked_reason"]
assert ["in_bill"] is True
def test_공종코드가_없으면_여전히_막힌다() -> None:
"""⚠ 좁게 — 코드도 없는데 안 막힌 것으로 두면 **0원 줄**이 조용히 선다."""
handoff = build_handoff(
unit_quantity_table={"structures": [_B군("chute", "도수로·산비탈수로")]}
)
= next(r for r in handoff["work_items"] if r["name"] == "도수로·산비탈수로")
assert ["work_item_code"] is None
assert ["blocked_kind"] is not None
assert "도수로·산비탈수로" in handoff["bill_flag_warnings"]
def test_연장이_0_이면_코드가_있어도_막힌다() -> None:
"""⚠ 좁게 — 수량이 없는데 안 막힌 것으로 두면 0원 줄이 된다."""
빈것 = _B군("underdrain", "맹암거")
빈것["length_m"] = 0.0
handoff = build_handoff(unit_quantity_table={"structures": [빈것]})
= next(r for r in handoff["work_items"] if r["name"] == "맹암거")
assert ["blocked_kind"] is not None
# ── B군은 연장표로 — 겹친 구간을 두 번 세지 않기 (2026-09-08 두 창 합의) ──────
def _연장표(length: float, raw: float, count: int = 2) -> list[dict]:
return [
{
"type_id": "ditch_ridge",
"group": "B",
"name": "산마루측구",
"count": count,
"length_m": length,
"raw_length_m": raw,
}
]
def test_B군은_종류별_한_줄로_선다() -> None:
handoff = build_handoff(length_table=_연장표(60.0, 80.0))
= [r for r in handoff["work_items"] if r["name"] == "산마루측구"]
assert len() == 1
assert ([0]["work_item_code"], [0]["quantity"], [0]["unit"]) == ("FP-12-09-02", 60.0, "m")
assert [0]["spec"] == "2개소"
def test_겹친_구간은_합쳐진_값이_수량이_된다() -> None:
"""⚠ 구조물별로 세면 겹친 구간을 **두 번** 센다 — 80m 가 아니라 60m 다."""
= build_handoff(length_table=_연장표(60.0, 80.0))["work_items"][0]
assert ["quantity"] == 60.0
assert ["quantity_gross"] == 80.0
assert "겹친 20m" in ["spec_class_basis"]
def test_겹침_설명은_막힌_사유가_아니다() -> None:
"""⚠ `blocked_reason` 에 넣으면 받는 쪽이 「막힌 줄」로 읽어 금액을 안 붙인다.
2026-09-08 실측에서 실제로 그랬다 — B09 가 「막힘 — 입력 구간 합 80m…」으로 뺐다.
"""
= build_handoff(length_table=_연장표(60.0, 80.0))["work_items"][0]
assert ["blocked_kind"] is None
assert ["blocked_reason"] == ""
assert ["in_bill"] is True
def test_겹치지_않으면_원합을_안_싣는다() -> None:
"""⚠ 좁게 — 안 겹쳤는데 `quantity_gross` 를 실으면 「반영률이 곱해진 값」으로 오해된다."""
= build_handoff(length_table=_연장표(40.0, 40.0, count=1))["work_items"][0]
assert ["quantity_gross"] is None
assert ["spec_class_basis"] == ""
def test_공종을_못_이은_B군은_사유와_함께_막힌다() -> None:
= [
{
"type_id": "chute",
"group": "B",
"name": "도수로·산비탈수로",
"count": 1,
"length_m": 40.0,
"raw_length_m": 40.0,
}
]
= build_handoff(length_table=)["work_items"][0]
assert ["work_item_code"] is None
assert ["in_bill"] is False
assert "품셈 공종을 아직 못 이었습니다" in ["blocked_reason"]
# ── B군 산출근거 — 어디부터 어디까지 (2026-09-08 `spans` 받음) ────────────────
def test_구간이_비고에_적히고_측점이_실린다() -> None:
= _연장표(60.0, 80.0)
[0]["spans"] = [{"start_m": 80.0, "end_m": 140.0}]
= build_handoff(length_table=)["work_items"][0]
assert (["station_from"], ["station_to"]) == (80.0, 140.0)
assert "구간 80~140m" in ["spec_class_basis"]
assert "겹친 20m" in ["spec_class_basis"]
def test_구간이_여럿이면_처음과_끝을_싣고_전부는_비고에() -> None:
= [
{
"type_id": "ditch_berm",
"group": "B",
"name": "소단측구",
"count": 3,
"length_m": 30.0,
"raw_length_m": 30.0,
"spans": [
{"start_m": 10.0, "end_m": 20.0},
{"start_m": 50.0, "end_m": 60.0},
{"start_m": 90.0, "end_m": 100.0},
],
}
]
= build_handoff(length_table=)["work_items"][0]
assert (["station_from"], ["station_to"]) == (10.0, 100.0)
# 사이가 빈 것을 숨기지 않는다 — 셋이 다 적혀야 10~100 을 통으로 오해하지 않는다.
assert ["spec_class_basis"].count("m") >= 3
assert "50~60m" in ["spec_class_basis"]
def test_구간_합이_수량과_같다() -> None:
"""⚠ 겹침을 지운 뒤의 목록이라 **그 합이 곧 수량**이다 — 둘이 갈리면 어딘가 틀렸다."""
= _연장표(60.0, 80.0)
[0]["spans"] = [{"start_m": 80.0, "end_m": 140.0}]
= build_handoff(length_table=)["work_items"][0]
= sum(s["end_m"] - s["start_m"] for s in [0]["spans"])
assert == ["quantity"]
def test_구간이_없어도_줄은_선다() -> None:
"""⚠ 좁게 — `spans` 가 없다고 줄을 빼면 옛 자료에서 수량이 통째로 사라진다."""
= build_handoff(length_table=_연장표(40.0, 40.0, count=1))["work_items"][0]
assert ["quantity"] == 40.0 and ["in_bill"] is True
assert (["station_from"], ["station_to"]) == (None, None)