feat(b09): 연결고리 표를 읽는 한 자리 — 산림·건설 어느 품을 쓸지 표가 정함(고시 총칙 7 · 표 policy both_precedence) · B09 인계 입구에서 둘 다 있는 공종은 표가 고른 열쇠·목차 코드로 · 미판정은 안 고르고 막음(link_undecided · 사유 그대로 · 금액에 안 듦) · 열쇠 빈 미판정 행도 내역서 요약에 목록 · 지금 인계 코드 57 판정 안 달라짐 = 금액 불변 · Z01 목록에 줄 구실(총칙) 표시 칸(코덱스 크로스체크 ①② · 브레인)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
This commit is contained in:
2026-09-17 16:53:22 +09:00
co-authored by Claude Opus 5
parent 337e42314c
commit 8f3a01cd96
8 changed files with 302 additions and 9 deletions
@@ -47,6 +47,7 @@ _ZERO = Decimal(0)
# ⬇ 인계 읽기는 `_Input` 으로 옮겼다(2026-09-15 700줄 분리) — 쓰던 이름이 그대로 살게 다시 내보낸다.
from B09_Estimation.B09_Estimation_BillOfQuantities_Input import ( # noqa: E402,F401
BLOCKED_LINK_UNDECIDED,
BLOCKED_UNCONFIRMED,
SUPPLY_OWNER,
SUPPLY_UNKNOWN,
@@ -56,6 +57,7 @@ from B09_Estimation.B09_Estimation_BillOfQuantities_Input import ( # noqa: E402
_decimal,
parse_handoff,
)
from common_util.common_util_work_item_link import undecided_links # noqa: E402
#: 총 절취량으로 셀 공종 — 9-3 토사깎기 · 9-4 암절취 · 9-5 발파암(인계 대응표 「흙깎기」 셋).
#: ⚠ 판에 묶인 절 번호임(명세 17장) — 판이 바뀌면 대응표와 함께 고칠 것.
@@ -75,6 +77,7 @@ _BLOCKED_LABELS = {
"input_missing": "입력이 필요합니다",
"unit_data_missing": "원단위가 없습니다(우리가 만들 것)",
"formula_missing": "전개식이 없습니다(우리가 만들 것)",
BLOCKED_LINK_UNDECIDED: "연결고리 미판정 — 금액에 안 들어감",
}
@@ -353,6 +356,18 @@ def build_bill(
}
)
continue
if item.blocked_kind == BLOCKED_LINK_UNDECIDED:
# 산림·건설 어느 품을 쓸지 연결고리 표가 미판정 — 근거 없이 고르지 않음(브레인 · 코덱스 ②).
result.missing.append(
{
"name": item.display_name,
"unit": item.unit,
"quantity": str(item.quantity),
"reason": f"{_BLOCKED_LABELS[BLOCKED_LINK_UNDECIDED]}{item.blocked_reason}",
"blocked_kind": BLOCKED_LINK_UNDECIDED,
}
)
continue
if (item.composite_parts or item.composite_not_ready) and not item.work_item_code:
# 묶음 줄 — 품셈에 그 공종이 없어 **조각을 합쳐** 한 줄로 세운다
# (옹벽 = 타설 + 거푸집 + 철근 + 잡석). 「코드 없음」으로 세면 안 된다.
@@ -577,6 +592,8 @@ def bill_summary(result: BillResult) -> dict[str, Any]:
"direct_labor_krw": str(result.direct_labor_krw),
"direct_expense_krw": str(result.direct_expense_krw),
"notes": result.notes,
# 연결고리 표 미판정 행 — 열쇠가 비어 줄에 못 닿는 것도 드러냄(조용히 안 버림)
"link_undecided": undecided_links(),
}
@@ -14,7 +14,11 @@ from dataclasses import dataclass
from decimal import Decimal
from typing import Any
from common_util.common_util_work_item_key import work_item_key
from common_util.common_util_work_item_key import work_item_code_of, work_item_key
from common_util.common_util_work_item_link import choose
#: 연결고리 표 미판정 — 산림·건설 어느 품을 쓸지 근거가 없어 **안 고른** 줄(금액에 안 듦).
BLOCKED_LINK_UNDECIDED = "link_undecided"
_ZERO = Decimal(0)
@@ -121,6 +125,23 @@ def _decimal(value: Any, default: Decimal | None = _ZERO) -> Decimal | None:
return Decimal(str(value))
def _linked(row: dict[str, Any]) -> dict[str, Any]:
"""열쇠 길목 + **연결고리 표** — 산림·건설 어느 품을 쓸지 표가 정함(고시 총칙 7 · 표 `policy`).
둘 다 있는 공종이면 표가 적은 우선 품셈 열쇠·목차 코드로 바꿈 · 미판정이면 막음(사유 그대로).
⚠ 이미 B08 이 막아 보낸 줄은 그 까닭을 덮지 않음.
"""
edition = row.get("pum_edition") or None
key = row.get("work_item_key") or work_item_key(row.get("work_item_code"), edition)
choice = choose(key)
out = {"work_item_key": key or "", "work_item_code": row.get("work_item_code")}
if choice.blocked and not row.get("blocked_reason"):
out.update(blocked_reason=choice.blocked, blocked_kind=BLOCKED_LINK_UNDECIDED)
elif choice.key and choice.key != key:
out.update(work_item_key=choice.key, work_item_code=work_item_code_of(choice.key, edition))
return out
def parse_handoff(payload: dict[str, Any]) -> tuple[list[HandoffWorkItem], list[HandoffMaterial]]:
"""인계 응답을 우리 자료형으로 옮긴다. **모르는 칸을 채우지 않는다.**"""
if "work_items" not in payload or "materials" not in payload:
@@ -128,7 +149,7 @@ def parse_handoff(payload: dict[str, Any]) -> tuple[list[HandoffWorkItem], list[
work_items = [
HandoffWorkItem(
work_item_code=row.get("work_item_code"),
work_item_code=linked["work_item_code"],
name=row.get("name", ""),
spec=row.get("spec") or "",
unit=row.get("unit") or "",
@@ -147,19 +168,18 @@ def parse_handoff(payload: dict[str, Any]) -> tuple[list[HandoffWorkItem], list[
composite_parts=tuple(row.get("composite_parts") or ()),
composite_not_ready=tuple(row.get("composite_not_ready") or ()),
structure_kind=row.get("structure_kind") or "",
blocked_reason=row.get("blocked_reason") or "",
blocked_kind=row.get("blocked_kind") or "",
blocked_reason=row.get("blocked_reason") or linked.get("blocked_reason") or "",
blocked_kind=row.get("blocked_kind") or linked.get("blocked_kind") or "",
variant_axis=row.get("variant_axis") or "",
variant_value=str(row.get("variant_value") or ""),
spec_class=row.get("spec_class") or "",
spec_class_basis=row.get("spec_class_basis") or "",
pum_edition=str(row.get("pum_edition") or ""),
# 옛 인계(열쇠 없음)도 같은 길목으로 채움
work_item_key=row.get("work_item_key")
or work_item_key(row.get("work_item_code"), row.get("pum_edition") or None)
or "",
# 옛 인계(열쇠 없음)도 같은 길목으로 채움 · 연결고리 표가 고른 열쇠
work_item_key=linked["work_item_key"],
)
for row in payload["work_items"]
for linked in (_linked(row),)
]
materials = [
HandoffMaterial(