Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
1416 lines
64 KiB
Python
1416 lines
64 KiB
Python
"""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)
|