fix(b08): 준비공 상태 칸 「입력이 필요함」과 「값을 낼 근거가 없음」을 가름 · 인계 막힘 갈래도 상태를 따름(㉴)

- 입력이면 서는 줄(표토 두께·거리 · 임목폐기물 조사값 · 부대시설 개소 · 임목파쇄 부피 · 앞 단계 횡단·사면표) → 「입력이 필요함」 · 인계 input_missing
- 자료·산식이 없는 줄(뿌리 운반 부피 · 사방 원단위 · 사면표에 성토고 칸 없음) → 「근거 없음」 · 인계 unit_data_missing(종전 input_missing = 반대 방향 거짓 사유)
- 사면표가 섰는데 비탈 규준틀 0 개소 → 「해당 없음」
- 품셈 공종이 없는 부대시설은 개소를 넣어도 인계 input_missing 그대로(별도 단가)
- 표 머리 「입력이 필요한 줄 N · 근거 없는 줄 M」 · 700줄 — 규준틀 재료를 `_Preparation_FrameMaterial` 로 뗌(719 → 651)
- 936be972: 15줄 중 입력 7 · 근거 없음 1(뿌리 운반) · 내역 「원단위가 없습니다(우리가 만들 것)」 · 금액 불변 155,207,971

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
This commit is contained in:
2026-09-15 00:44:33 +09:00
co-authored by Claude Opus 5
parent cd6c7f3fed
commit 364c28a700
10 changed files with 246 additions and 95 deletions
@@ -21,6 +21,9 @@ from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import (
from B08_Quantity.B08_Quantity_Engine_Preparation_Status import (
STATUS_COUNTED_ELSEWHERE as PREP_COUNTED_ELSEWHERE,
)
from B08_Quantity.B08_Quantity_Engine_Preparation_Status import (
STATUS_NEEDS_INPUT as PREP_NEEDS_INPUT,
)
from B08_Quantity.B08_Quantity_Engine_Preparation_Status import (
STATUS_NOT_APPLICABLE as PREP_NOT_APPLICABLE,
)
@@ -79,7 +82,9 @@ def _preparation_rows(preparation_table: dict[str, Any]) -> list[dict[str, Any]]
"composite_parts": None,
"structure_kind": None,
# ⚠ 못 서는 까닭을 그대로 넘긴다 — 받는 쪽이 「만들어야 할 것」 목록에 얹는다.
"blocked_kind": None if ready else _prep_blocked_kind(status),
"blocked_kind": None
if ready
else row.get("blocked_kind") or _prep_blocked_kind(status),
"blocked_reason": "" if ready else str(row.get("reason") or status),
# ⚠ 준비공 줄도 갈래를 실어 보낸다(2026-09-09) — 종전에는 늘 `None` 이라
# 표토 운반처럼 **부모 공종코드**로 가는 줄이 B09 에서 「후보 N건」에 머물렀다.
@@ -105,9 +110,15 @@ def _preparation_rows(preparation_table: dict[str, Any]) -> list[dict[str, Any]]
def _prep_blocked_kind(status: str) -> str | None:
"""준비공 줄의 상태를 **막힌 갈래 셋** 중 하나로 옮긴다. 모르는 상태는 `None`."""
if status == PREP_PENDING:
"""준비공 줄의 상태를 **막힌 갈래 셋** 중 하나로 옮긴다. 줄이 갈래를 적어 오면 그것이 이긴다.
⚠ 「근거 없음」을 `input_missing` 으로 보내면 사방 원단위처럼 **우리가 만들 줄**이 B09 에
「입력이 필요합니다」로 뜬다 — 사용자가 넣을 칸을 찾아 헤맨다(2026-09-14 브레인 ㉴).
"""
if status == PREP_NEEDS_INPUT:
return BLOCKED_INPUT_MISSING
if status == PREP_PENDING:
return BLOCKED_UNIT_DATA_MISSING
if status == PREP_COUNTED_ELSEWHERE:
# 다른 표에서 이미 선 줄 — 막힌 것이 아니라 **여기서 세면 안 되는** 줄이다.
return None
+25 -84
View File
@@ -50,15 +50,21 @@ from B08_Quantity.B08_Quantity_Engine_Preparation_Ancillary import ( # noqa: E4
ANCILLARY_ITEMS,
ancillary_rows,
)
from B08_Quantity.B08_Quantity_Engine_Preparation_FrameMaterial import ( # noqa: E402
FRAME_MATERIAL_SUGGESTED,
frame_material_rows,
)
from B08_Quantity.B08_Quantity_Engine_Preparation_Status import ( # noqa: E402
STATUS_COUNTED_ELSEWHERE,
STATUS_NEEDS_INPUT,
STATUS_NOT_APPLICABLE,
STATUS_PENDING,
STATUS_READY,
)
from B08_Quantity.B08_Quantity_Engine_Preparation_TreeWaste import tree_waste_rows # noqa: E402
__all__ = ["ANCILLARY_ITEMS", "ancillary_rows"] # 갈라 나간 뒤에도 여기서 읽을 수 있게
# 갈라 나간 뒤에도 여기서 읽을 수 있게(부대시설 2026-09-09 · 규준틀 재료 2026-09-14).
__all__ = ["ANCILLARY_ITEMS", "FRAME_MATERIAL_SUGGESTED", "ancillary_rows", "frame_material_rows"]
def batter_frame_count(slope_rows: Iterable[dict[str, Any]]) -> tuple[int, list[str]]:
@@ -170,7 +176,7 @@ def _topsoil_row(
return {
**base,
"amount": None,
"status": STATUS_PENDING,
"status": STATUS_NEEDS_INPUT,
"reason": (
"노면 면적을 못 셉니다 — 횡단 설계에 노체 끝(노면 폭)이 없는 측점이 있음. "
f"대상은 노면 + 절토대상지(별표2) · 절토 사면 {cut:,.1f}㎡ 만으로는 세우지 않음"
@@ -184,7 +190,7 @@ def _topsoil_row(
return {
**base,
"amount": None,
"status": STATUS_PENDING,
"status": STATUS_NEEDS_INPUT,
"reason": (
"대상 면적이 0 ㎡ 입니다 — 횡단·사면표가 아직 서지 않았습니다. "
"0 ㎡ 로 내면 「표토가 없는 노선」으로 읽히므로 값을 세우지 않습니다"
@@ -309,7 +315,7 @@ def _root_removal_row(slope: dict[str, float], stand_volume_class: str | None) -
"amount": area if area > 0 else None,
# ⭐ 2026-09-13 판정 Ⓑ — 셈은 토공집계 「지장목제거 · 뿌리뽑기」(FP-09-21)가 한다.
# 여기는 **보이되 안 실린다**(같은 면적 · 같은 작업 — 또 세면 이중계상).
"status": STATUS_COUNTED_ELSEWHERE if area > 0 else STATUS_PENDING,
"status": STATUS_COUNTED_ELSEWHERE if area > 0 else STATUS_NEEDS_INPUT,
"reason": " · ".join(reasons),
"reference_amount": area,
"work_item_code": None,
@@ -364,7 +370,7 @@ def chipping_rows(enabled: Any, volume_m3: Any) -> list[dict[str, Any]]:
"item": CHIPPING_ITEM,
"unit": "",
"amount": amount,
"status": STATUS_READY if amount and amount > 0 else STATUS_PENDING,
"status": STATUS_READY if amount and amount > 0 else STATUS_NEEDS_INPUT,
"reason": CHIPPING_ON_NOTE if not amount else "산출 조건에서 넣은 부피 (확정 5차 5번)",
"work_item_code": CHIPPING_CODE,
}
@@ -382,7 +388,7 @@ def _root_steps_rows(
"item": "뿌리 적재",
"unit": "",
"amount": area if area > 0 else None,
"status": STATUS_READY if area > 0 else STATUS_PENDING,
"status": STATUS_READY if area > 0 else STATUS_NEEDS_INPUT,
"reason": (
f"{ROOT_STEPS_NOTE} · {ROOT_REMOVAL_BASIS}"
" · ⚠ **품셈 9-20-2 는 「10주당」이라 밑수 축이 다름** — 면적 축으로 내고"
@@ -434,7 +440,7 @@ def _topsoil_haul_row(
"item": "표토 운반·적치",
"unit": "",
"amount": None,
"status": STATUS_PENDING,
"status": STATUS_NEEDS_INPUT,
"reason": f"{TOPSOIL_HAUL_LAW} · 제거 면적이 아직 안 서서 운반도 못 셈",
"work_item_code": None,
}
@@ -444,7 +450,7 @@ def _topsoil_haul_row(
"item": "표토 운반·적치",
"unit": "",
"amount": None,
"status": STATUS_PENDING,
"status": STATUS_NEEDS_INPUT,
"reason": (
f"{TOPSOIL_HAUL_LAW} · 운반 부피 = 제거 면적 × 표토 두께 — 두께가 아직 입력되지"
f" 않았습니다. {TOPSOIL_ORIGINAL_APPLIED} (제거 면적 {float(area):,.1f}㎡)"
@@ -472,7 +478,7 @@ def _topsoil_haul_row(
"item": "표토 운반·적치",
"unit": "",
"amount": None,
"status": STATUS_PENDING,
"status": STATUS_NEEDS_INPUT,
"reason": (
f"{TOPSOIL_HAUL_LAW} · 운반거리가 아직 입력되지 않았습니다 — 「최고 홍수위보다"
f" 높은 장소」는 현장에서 정하는 자리라 품셈이 거리를 주지 않습니다"
@@ -505,88 +511,17 @@ def _topsoil_haul_row(
}
#: 규준틀 재료 — **세는 것은 확정이고 수량만 몰랐던 자리**(2026-09-09).
#: 품셈 11-2·11-3 [주]④ 「재료량은 **설계수량에 따른다**」 ⇒ 품셈이 값을 안 주는 것이지
#: 「안 센다」가 아니다. 그래서 **제안값을 보이고 사용자가 고치는** 모양으로 둔다
#: (확정 ⑨·⑩ 과 같은 틀 — 「가는 기본값이고 나 선택처럼 동작할 수 있어야 함」).
#: ⚠ **제안값은 실무 관측값이지 법정 기준이 아니다** — 울진 소광 원단위 라이브러리 §8
#: 「규준틀 수평 | 개소 | 각재 50×50 0.0044㎥ · 판재 T12 0.0029㎥ · 못 0.03㎏」.
#: ⚠ **비탈 규준틀 값은 그 시트에 없다** — 수평 값을 준용하고 그 사실을 사유에 적는다.
#: ⚠ **손율은 원문에 있다** — 품셈 11-2 [주]③ 비탈 **50%** · 11-3 [주]③ 수평 **80%**.
FRAME_MATERIAL_SUGGESTED = {
"각재 50×50": (0.0044, ""),
"판재 T12": (0.0029, ""),
"": (0.03, ""),
}
FRAME_MATERIAL_SOURCE = (
"⚠ 실무 관측값(울진 소광 원단위 라이브러리 §8 규준틀 수평) — **법정 기준 아님**."
" 품셈 11-2·11-3 [주]④ 는 「재료량은 설계수량에 따른다」로만 둠. 산출 조건에서 고칠 수 있음"
)
FRAME_LOSS_RATE = {"비탈 규준틀": 50, "수평 규준틀": 80}
#: 자재 줄의 **이름 · 규격** — 이름 칸에 규격을 섞으면 할증률표(「각재」·「판재」)와 안 맞음
#: (2026-09-14 · 돌 줄 8210c2b7 과 같은 병). 산출 조건 키·관급구분 키(이름+규격)는 글자 그대로.
FRAME_MATERIAL_NAME_SPEC = {"각재 50×50": ("각재", "50×50"), "판재 T12": ("판재", "T12")}
def frame_material_rows(
frame_rows: list[dict[str, Any]], overrides: dict[str, Any] | None = None
) -> list[dict[str, Any]]:
"""규준틀 재료 — 개소 × 개소당 수량. **자재 축으로 보낸다.**
⚠ 개소가 안 서면 재료도 안 선다(밑수가 그 줄이다).
⚠ 값은 **제안값**이고 산출 조건에서 덮어쓸 수 있다 — 그 사실이 사유에 적힌다.
"""
given = overrides or {}
rows: list[dict[str, Any]] = []
for frame in frame_rows:
count = frame.get("amount")
if not count:
continue
loss = FRAME_LOSS_RATE.get(str(frame.get("item")), None)
for name, (default, unit) in FRAME_MATERIAL_SUGGESTED.items():
raw = given.get(name)
try:
per_ea = (
float(raw) if raw is not None and str(raw).strip() != "" else float(default)
)
except (TypeError, ValueError):
per_ea = float(default)
picked = "산출 조건에서 고른 값" if raw not in (None, "") else "제안값(기본)"
material, spec = FRAME_MATERIAL_NAME_SPEC.get(name, (name, ""))
rows.append(
{
"name": material,
"spec": spec,
"unit": unit,
"amount": float(count) * per_ea,
"destination": "material",
"source": str(frame.get("item") or "규준틀"),
"basis": (
f"{frame.get('item')} {float(count):g}개소 × {per_ea:g}{unit}/개소"
f" ({picked}) · {FRAME_MATERIAL_SOURCE}"
# ⚠ 준용이라는 사실이 상수 주석에만 있고 **화면 근거에는 없던**
# 자리다 — 값이 서면 어디서 온 값인지 안 보인다(2026-09-09 감사).
+ (
" · ⚠ 비탈 규준틀 재료량은 그 시트에 없어 **수평 값을 준용**함"
if str(frame.get("item")) == "비탈 규준틀"
else ""
)
+ (f" · 손율 {loss}%(품셈 [주]③)" if loss else "")
),
}
)
return rows
def _batter_frame_row(slope_rows: list[dict[str, Any]]) -> dict[str, Any]:
"""비탈 규준틀 한 줄. **개소는 원문 기준으로 서고 재료는 미확보**다."""
count, notes = batter_frame_count(slope_rows)
# 사면표가 있는데 0 개소면 **이 노선엔 필요 없는 것**이다 — 「근거 없음」이 아니다(㉴).
idle = STATUS_NOT_APPLICABLE if slope_rows else STATUS_NEEDS_INPUT
return {
"group": "준비공",
"item": "비탈 규준틀",
"unit": "개소",
"amount": float(count) if count else None,
"status": STATUS_READY if count else STATUS_PENDING,
"status": STATUS_READY if count else idle,
"reason": (
"; ".join(notes)
+ " · 재료량은 품셈 11-2 [주]④ 「설계수량에 따른다」 —"
@@ -604,7 +539,12 @@ def _level_frame_row(slope_rows: list[dict[str, Any]]) -> dict[str, Any]:
"item": "수평 규준틀",
"unit": "개소",
"amount": float(count) if count is not None else None,
"status": STATUS_READY if count is not None else STATUS_PENDING,
# 사면표가 안 섰으면 앞 단계 몫(입력) · 섰는데 성토고 칸이 없으면 우리 자료가 없는 것.
"status": STATUS_READY
if count is not None
else STATUS_PENDING
if slope_rows
else STATUS_NEEDS_INPUT,
"reason": "; ".join(notes)
+ (
" · 재료량은 품셈 11-3 [주]④ 「설계수량에 따른다」 —"
@@ -705,6 +645,7 @@ def build_table(
"columns": ["구분", "공종", "단위", "수량", "상태", "사유"],
"rows": rows,
"ready_count": sum(1 for row in rows if row["status"] == STATUS_READY),
"input_count": sum(1 for row in rows if row["status"] == STATUS_NEEDS_INPUT),
"pending_count": sum(1 for row in rows if row["status"] == STATUS_PENDING),
"row_count": len(rows),
}
@@ -11,6 +11,7 @@ from typing import Any
from B08_Quantity.B08_Quantity_Engine_Preparation_Status import (
REASON_NO_WORK_ITEM,
STATUS_NEEDS_INPUT,
STATUS_PENDING,
STATUS_READY,
)
@@ -94,7 +95,7 @@ def ancillary_rows(counts: dict[str, Any] | None = None) -> list[dict[str, Any]]
reasons.append(REASON_NO_WORK_ITEM)
if amount is None:
reasons.append("개소가 아직 입력되지 않았습니다 — 넣으면 물량이 섭니다")
status = STATUS_PENDING
status = STATUS_NEEDS_INPUT
else:
status = STATUS_READY if spec["code"] else STATUS_PENDING
rows.append(
@@ -106,6 +107,8 @@ def ancillary_rows(counts: dict[str, Any] | None = None) -> list[dict[str, Any]]
"status": status,
"work_item_code": spec["code"],
"legal_required": bool(spec["legal"]),
# 품셈에 공종이 없는 줄 — 금액은 별도 단가(사람 입력)로만 서서 인계는 늘 입력 갈래.
**({} if spec["code"] else {"blocked_kind": "input_missing"}),
"reason": " · ".join(reasons),
}
)
@@ -0,0 +1,81 @@
"""준비공 — 규준틀 재료 줄 (`B08_Quantity_Engine_Preparation` 에서 갈라냄 · 2026-09-14 700줄 제한).
내용·규칙은 그대로 옮겼다 — 개소 × 개소당 수량을 자재 축으로 보내고,
값은 제안값이며 산출 조건이 이긴다.
"""
from __future__ import annotations
from typing import Any
#: 규준틀 재료 — **세는 것은 확정이고 수량만 몰랐던 자리**(2026-09-09).
#: 품셈 11-2·11-3 [주]④ 「재료량은 **설계수량에 따른다**」 ⇒ 품셈이 값을 안 주는 것이지
#: 「안 센다」가 아니다. 그래서 **제안값을 보이고 사용자가 고치는** 모양으로 둔다
#: (확정 ⑨·⑩ 과 같은 틀 — 「가는 기본값이고 나 선택처럼 동작할 수 있어야 함」).
#: ⚠ **제안값은 실무 관측값이지 법정 기준이 아니다** — 울진 소광 원단위 라이브러리 §8
#: 「규준틀 수평 | 개소 | 각재 50×50 0.0044㎥ · 판재 T12 0.0029㎥ · 못 0.03㎏」.
#: ⚠ **비탈 규준틀 값은 그 시트에 없다** — 수평 값을 준용하고 그 사실을 사유에 적는다.
#: ⚠ **손율은 원문에 있다** — 품셈 11-2 [주]③ 비탈 **50%** · 11-3 [주]③ 수평 **80%**.
FRAME_MATERIAL_SUGGESTED = {
"각재 50×50": (0.0044, ""),
"판재 T12": (0.0029, ""),
"": (0.03, ""),
}
FRAME_MATERIAL_SOURCE = (
"⚠ 실무 관측값(울진 소광 원단위 라이브러리 §8 규준틀 수평) — **법정 기준 아님**."
" 품셈 11-2·11-3 [주]④ 는 「재료량은 설계수량에 따른다」로만 둠. 산출 조건에서 고칠 수 있음"
)
FRAME_LOSS_RATE = {"비탈 규준틀": 50, "수평 규준틀": 80}
#: 자재 줄의 **이름 · 규격** — 이름 칸에 규격을 섞으면 할증률표(「각재」·「판재」)와 안 맞음
#: (2026-09-14 · 돌 줄 8210c2b7 과 같은 병). 산출 조건 키·관급구분 키(이름+규격)는 글자 그대로.
FRAME_MATERIAL_NAME_SPEC = {"각재 50×50": ("각재", "50×50"), "판재 T12": ("판재", "T12")}
def frame_material_rows(
frame_rows: list[dict[str, Any]], overrides: dict[str, Any] | None = None
) -> list[dict[str, Any]]:
"""규준틀 재료 — 개소 × 개소당 수량. **자재 축으로 보낸다.**
⚠ 개소가 안 서면 재료도 안 선다(밑수가 그 줄이다).
⚠ 값은 **제안값**이고 산출 조건에서 덮어쓸 수 있다 — 그 사실이 사유에 적힌다.
"""
given = overrides or {}
rows: list[dict[str, Any]] = []
for frame in frame_rows:
count = frame.get("amount")
if not count:
continue
loss = FRAME_LOSS_RATE.get(str(frame.get("item")), None)
for name, (default, unit) in FRAME_MATERIAL_SUGGESTED.items():
raw = given.get(name)
try:
per_ea = (
float(raw) if raw is not None and str(raw).strip() != "" else float(default)
)
except (TypeError, ValueError):
per_ea = float(default)
picked = "산출 조건에서 고른 값" if raw not in (None, "") else "제안값(기본)"
material, spec = FRAME_MATERIAL_NAME_SPEC.get(name, (name, ""))
rows.append(
{
"name": material,
"spec": spec,
"unit": unit,
"amount": float(count) * per_ea,
"destination": "material",
"source": str(frame.get("item") or "규준틀"),
"basis": (
f"{frame.get('item')} {float(count):g}개소 × {per_ea:g}{unit}/개소"
f" ({picked}) · {FRAME_MATERIAL_SOURCE}"
# ⚠ 준용이라는 사실이 상수 주석에만 있고 **화면 근거에는 없던**
# 자리다 — 값이 서면 어디서 온 값인지 안 보인다(2026-09-09 감사).
+ (
" · ⚠ 비탈 규준틀 재료량은 그 시트에 없어 **수평 값을 준용**함"
if str(frame.get("item")) == "비탈 규준틀"
else ""
)
+ (f" · 손율 {loss}%(품셈 [주]③)" if loss else "")
),
}
)
return rows
@@ -8,6 +8,12 @@
from __future__ import annotations
STATUS_READY = "값 있음"
#: 사람이 넣으면 서는 줄 — 산출 조건 칸, 또는 앞 단계(횡단·사면표)를 마치면 섬.
#: 인계 `input_missing`.
#: ⚠ 「근거 없음」과 가른다 — 한 낱말로 덮으면 입력만 넣으면 서는 줄이 「못 세움」으로 읽힌다
#: (2026-09-14 브레인 ㉴ · 인계는 반대로 사방 원단위까지 「입력이 필요합니다」로 보냈다).
STATUS_NEEDS_INPUT = "입력이 필요함"
#: 자료·산식이 우리에게 없는 줄 — 입력으로는 안 풀림. 인계 `unit_data_missing`.
STATUS_PENDING = "값을 낼 근거가 없음"
STATUS_COUNTED_ELSEWHERE = "다른 표에서 이미 섬"
STATUS_NOT_APPLICABLE = "해당 없음"
@@ -27,7 +27,7 @@ from __future__ import annotations
import math
from typing import Any
from B08_Quantity.B08_Quantity_Engine_Preparation_Status import STATUS_PENDING, STATUS_READY
from B08_Quantity.B08_Quantity_Engine_Preparation_Status import STATUS_NEEDS_INPUT, STATUS_READY
TREE_WASTE_ITEM = "임목폐기물 처리"
STEM_FORM_FACTOR = 0.5 # k
@@ -139,7 +139,7 @@ def tree_waste_rows(
{
**base,
"amount": None,
"status": STATUS_PENDING,
"status": STATUS_NEEDS_INPUT, # 조사값 칸 · 앞 단계 사면표 — 둘 다 사람 몫
"reason": f"{why}. 산식: {TREE_WASTE_BASIS} · {root_basis}",
"reference_amount": area,
}
+3 -1
View File
@@ -243,6 +243,8 @@ export interface PreparationRow {
export interface PreparationTable {
columns: string[];
rows: PreparationRow[];
/** 입력하면 서는 줄 수 — 「근거 없음」(`pending_count`)과 갈라 셈(2026-09-14). */
input_count?: number;
pending_count: number;
row_count: number;
}
@@ -261,7 +263,7 @@ export function renderPreparationGrid(
const caption = document.createElement("p");
caption.className = "b08-grid__caption";
caption.textContent = `${table.row_count}줄 · 값을 낼 근거가 아직 없는 줄 ${table.pending_count}`;
caption.textContent = `${table.row_count}줄 · 입력이 필요한 줄 ${table.input_count ?? 0}개 · 값을 낼 근거가 없는 줄 ${table.pending_count}`;
const unconfirmed = table.rows.reduce((sum, row) => sum + (row.unconfirmed ?? 0), 0);
if (unconfirmed) {
const badge = document.createElement("span");
@@ -0,0 +1,105 @@
"""준비공 상태 칸 — 「입력이 필요함」과 「값을 낼 근거가 없음」을 가른다 (2026-09-14 브레인 ㉴).
훑기에서 잡은 거짓 사유: 상태 칸이 **입력만 넣으면 서는 줄**(표토 두께·거리 · 임목폐기물 조사값 ·
부대시설 개소 · 임목파쇄 부피)까지 「값을 낼 근거가 없음」으로 덮었다. 반대로 인계는
**우리가 만들어야 하는 줄**(사방 원단위 · 뿌리 부피)까지 「입력이 필요합니다」로 보냈다.
① 입력이면 풀리는 줄 → 상태 「입력이 필요함」 · 인계 `input_missing`
② 자료·산식이 없는 줄 → 상태 「값을 낼 근거가 없음」 · 인계 `unit_data_missing`
③ 앞 단계(횡단·사면표)가 안 선 줄도 사람이 할 일이라 ①
④ 규준틀이 필요 없는 노선(비탈길이 10m 이상 구간 없음)은 「해당 없음」
"""
from __future__ import annotations
from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff
from B08_Quantity.B08_Quantity_Engine_Preparation import build_table
from B08_Quantity.B08_Quantity_Engine_Preparation_Status import (
STATUS_NEEDS_INPUT,
STATUS_NOT_APPLICABLE,
STATUS_PENDING,
)
SLOPE = {"tree_removal_fill": 600.0, "tree_removal_cut": 400.0, "face_dressing_cut": 300.0}
SHORT_SLOPES = [{"lengths": {"fill": 4.0}, "distance_m": 20.0, "fill_height_m": 2.0}]
def _table(**overrides: object) -> dict:
args: dict = {
"slope_totals": SLOPE,
"structures": [{"type_id": "erosion_check"}],
"slope_rows": SHORT_SLOPES,
"road_surface_area_m2": 1000.0,
"chipping_enabled": True,
"tree_waste": {},
"ancillary_counts": {},
}
args.update(overrides)
return build_table(**args)
def _by_item(table: dict) -> dict[str, dict]:
return {row["item"]: row for row in table["rows"]}
def test_입력이면_서는_줄은_입력이_필요함() -> None:
rows = _by_item(_table())
for item in (
"표토 운반·적치",
"임목파쇄",
"임목폐기물 처리",
"국가지점번호판",
"가설창고(컨테이너)",
):
assert rows[item]["status"] == STATUS_NEEDS_INPUT, item
# 두께를 넣으면 거리 칸이 남는다 — 여전히 입력이 필요함.
with_thickness = _by_item(_table(topsoil_thickness_m=0.2))
assert with_thickness["표토 운반·적치"]["status"] == STATUS_NEEDS_INPUT
def test_자료나_산식이_없는_줄은_근거가_없음() -> None:
rows = _by_item(_table())
assert rows["뿌리 운반"]["status"] == STATUS_PENDING
assert (
rows[next(k for k, r in rows.items() if r["group"] == "사방공")]["status"] == STATUS_PENDING
)
def test_앞_단계가_안_섰으면_입력이_필요함() -> None:
rows = _by_item(_table(slope_totals={}, slope_rows=[], road_surface_area_m2=None))
assert rows["표토제거"]["status"] == STATUS_NEEDS_INPUT
assert rows["뿌리 적재"]["status"] == STATUS_NEEDS_INPUT
assert rows["비탈 규준틀"]["status"] == STATUS_NEEDS_INPUT
assert rows["수평 규준틀"]["status"] == STATUS_NEEDS_INPUT
def test_규준틀이_필요_없는_노선은_해당_없음() -> None:
assert _by_item(_table())["비탈 규준틀"]["status"] == STATUS_NOT_APPLICABLE
def test_인계_막힘_갈래가_상태를_따른다() -> None:
table = _table()
handed = {row["name"]: row for row in build_handoff(preparation_table=table)["work_items"]}
assert handed["표토 운반·적치"]["blocked_kind"] == "input_missing"
assert handed["임목파쇄"]["blocked_kind"] == "input_missing"
# 우리가 만들어야 하는 줄 — 「입력이 필요합니다」로 보내면 사용자가 헛걸음한다.
assert handed["뿌리 운반"]["blocked_kind"] == "unit_data_missing"
assert handed["골막이"]["blocked_kind"] == "unit_data_missing"
# 해당 없음은 막힘이 아니다.
assert handed["비탈 규준틀"]["blocked_kind"] is None
# 공종이 품셈에 없는 부대시설은 개소를 넣어도 종전대로 「입력」(별도 단가) 갈래.
entered = _table(ancillary_counts={"national_point_sign": 3})
sign = next(
row
for row in build_handoff(preparation_table=entered)["work_items"]
if row["name"] == "국가지점번호판"
)
assert sign["blocked_kind"] == "input_missing"
def test_표_머리가_두_갈래를_따로_센다() -> None:
table = _table()
rows = table["rows"]
assert table["input_count"] == sum(1 for r in rows if r["status"] == STATUS_NEEDS_INPUT)
assert table["pending_count"] == sum(1 for r in rows if r["status"] == STATUS_PENDING)
assert table["input_count"] > 0 and table["pending_count"] > 0
+3 -2
View File
@@ -23,7 +23,7 @@ sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Engine_Preparation import ( # noqa: E402
STATUS_COUNTED_ELSEWHERE,
STATUS_PENDING,
STATUS_NEEDS_INPUT,
STATUS_READY,
build_table,
preparation_rows,
@@ -61,7 +61,8 @@ def test_20m_이내면_제거_품에_들어_따로_안_셈() -> None:
def test_거리를_안_넣으면_막고_사유() -> None:
"""⚠ 「최고 홍수위보다 높은 장소」는 현장이 정한다 — 품셈이 거리를 주지 않는다."""
row = (0.2, None)["표토 운반·적치"]
assert row["amount"] is None and row["status"] == STATUS_PENDING
# 거리만 넣으면 서는 줄 — 「근거 없음」이 아니라 「입력이 필요함」(2026-09-14 ㉴).
assert row["amount"] is None and row["status"] == STATUS_NEEDS_INPUT
assert "운반거리가 아직 입력되지 않았습니다" in row["reason"]
assert row["reference_amount"] == 200.0 # 값을 버리지 않는다
+3 -2
View File
@@ -21,7 +21,7 @@ ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Engine_Preparation import ( # noqa: E402
STATUS_PENDING,
STATUS_NEEDS_INPUT,
STATUS_READY,
build_table,
)
@@ -43,7 +43,8 @@ def test_켜면_줄이_선다() -> None:
def test_켜도_부피는_지어내지_않는다() -> None:
row = (True)["임목파쇄"]
assert row["amount"] is None and row["status"] == STATUS_PENDING
# 부피를 넣으면 서는 줄 — 「입력이 필요함」(2026-09-14 ㉴).
assert row["amount"] is None and row["status"] == STATUS_NEEDS_INPUT
assert "부피" in row["reason"]