feat(B08): 인계 줄마다 막힌 까닭을 실음 + 계약 칸 대조 시험

서브 화면에 「왜 안 서는지」가 안 뜨던 줄 셋(돌쌓기·흙막이·큰돌쌓기)을 고침.

- `blocked_reason` · `blocked_kind` 를 줄마다 실음. 갈래는 셋 —
  `input_missing`(입력하면 풀림) / `unit_data_missing`(자료 없음) /
  `formula_missing`(산출식 없음). 받는 쪽이 「사용자가 입력하면 풀리는 것」과
  「우리가 만들어야 하는 것」을 화면에서 갈라야 함(8-27 표와 같은 축).
- 문구는 `B08_Quantity_Wording` 것을 그대로 씀 — 두 벌로 짜면 갈림.
- 토공·운반 줄은 막힐 자리가 없어도 **칸은 둠** — 계약이 한 모양이어야 함.

⚠ 계약 칸 대조 시험 추가(`test_b08_handoff_contract.py`).
오늘 이 병이 계약 양쪽에서 하나씩 났음 — 우리 쪽은 레지스트리 키와 엔진이 읽는
키가 달라 저장값이 안 닿았고, 받는 쪽은 우리가 보낸 `composite_parts`·
`quantity_gross` 를 안 읽고 버렸음. **「보내는 쪽은 보냈는데 받는 쪽이 안 읽는」
자리는 양쪽 다 조용함.** 키를 늘릴 때 계약에 안 적고 늘리면 받는 쪽이 영영 모름.
자재 줄에 공종코드가 없는 것도 계약으로 못 박음.

검증 — 전체 638 passed. 실물 프로젝트(`5601e828`)에서 네 줄 다 사유가 실림.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-08 04:35:31 +09:00
co-authored by Claude Opus 5
parent 755e37ba55
commit 4deaf0239c
+52 -1
View File
@@ -128,6 +128,12 @@ NOTE_METHOD_MISSING = "시공법 미지정으로 공종을 못 고름"
#: 아니라 공종 이름이라 성분 목록에는 안 온다. #: 아니라 공종 이름이라 성분 목록에는 안 온다.
REBAR_PREFIXES = ("이형철근", "원형철근", "철근") REBAR_PREFIXES = ("이형철근", "원형철근", "철근")
#: 줄이 왜 막혔나 — **받는 쪽이 「사용자가 입력하면 풀리는 것」과 「우리가 만들어야 하는 것」을
#: 화면에서 갈라야** 한다(2026-09-07 3자 확정). 8-27 표에서 이미 가른 그 축이다.
BLOCKED_INPUT_MISSING = "input_missing" # 저장 제원 칸이 비어 있음 — 입력하면 풀림
BLOCKED_UNIT_DATA_MISSING = "unit_data_missing" # 원단위·표준 물량 자료가 없음
BLOCKED_FORMULA_MISSING = "formula_missing" # 수량 산출식 자체가 없음
#: 사면 계열 — 토공집계에 함께 실리지만 출처가 사면적이다. #: 사면 계열 — 토공집계에 함께 실리지만 출처가 사면적이다.
SLOPE_GROUPS = frozenset({"성토면다짐", "초류종자살포", "지장목제거", "층따기", "면고르기"}) SLOPE_GROUPS = frozenset({"성토면다짐", "초류종자살포", "지장목제거", "층따기", "면고르기"})
@@ -459,6 +465,14 @@ def _earthwork_rows(
"station_from": None, "station_from": None,
"station_to": None, "station_to": None,
"spec_detail": "", "spec_detail": "",
"composite_parts": None,
"structure_kind": None,
"spec_class": None,
"spec_class_basis": "",
# 토공 줄은 막힐 자리가 없다 — 그래도 **칸은 둔다**(계약이 한 모양이어야 한다).
"blocked_kind": None,
"blocked_reason": "",
"composite_not_ready": None,
# 합계 줄과 무대 줄은 값은 내되 내역에 안 선다. # 합계 줄과 무대 줄은 값은 내되 내역에 안 선다.
"in_bill": bool(row.get("in_bill", True)) and not is_subtotal, "in_bill": bool(row.get("in_bill", True)) and not is_subtotal,
"excavation_method": methods.get(ground) if ground else None, "excavation_method": methods.get(ground) if ground else None,
@@ -499,9 +513,17 @@ def _haul_rows(
"ground_class": row.get("ground") or None, "ground_class": row.get("ground") or None,
"haul_distance_m": float(row.get("average_distance_m") or 0.0), "haul_distance_m": float(row.get("average_distance_m") or 0.0),
"haul_equipment": equipment, "haul_equipment": equipment,
"excavation_method": None,
"station_from": None, "station_from": None,
"station_to": None, "station_to": None,
"spec_detail": "", "spec_detail": "",
"composite_parts": None,
"structure_kind": None,
"spec_class": None,
"spec_class_basis": "",
"blocked_kind": None,
"blocked_reason": "",
"composite_not_ready": None,
"in_bill": in_bill, "in_bill": in_bill,
"in_bill_reason": str(entry.get("reason") or ""), "in_bill_reason": str(entry.get("reason") or ""),
"origin": ORIGIN_HAUL, "origin": ORIGIN_HAUL,
@@ -510,6 +532,30 @@ def _haul_rows(
return rows, unmatched return rows, unmatched
def blocked_of(structure: dict[str, Any], class_basis: str = "") -> tuple[str | None, str]:
"""(막힌 갈래, 사유). 안 막혔으면 `(None, "")`.
⚠ 사유 문구는 **`B08_Quantity_Wording` 것을 그대로** 쓴다 — 두 벌로 짜면 갈린다.
전개 알림(`notes`)에 이미 사람 말로 적혀 있으므로 그것을 그대로 옮긴다.
"""
notes = [str(note) for note in structure.get("notes") or []]
if structure.get("components"):
# 물량은 섰는데 **단가 갈래**를 못 고른 자리(돌쌓기 뒷길이 등).
if class_basis and "입력되지 않았습니다" in class_basis:
return BLOCKED_INPUT_MISSING, class_basis
return None, ""
for note in notes:
if "입력되지 않았습니다" in note:
return BLOCKED_INPUT_MISSING, note
if "자료에 없습니다" in note or "표준 물량 자료" in note:
return BLOCKED_UNIT_DATA_MISSING, note
if "산출식이 아직 없습니다" in note:
return BLOCKED_FORMULA_MISSING, note
if notes:
return BLOCKED_UNIT_DATA_MISSING, notes[0]
return None, ""
def _structure_rows( def _structure_rows(
unit_quantity_table: dict[str, Any], mapping: WorkItemMapping unit_quantity_table: dict[str, Any], mapping: WorkItemMapping
) -> tuple[list[dict[str, Any]], list[str]]: ) -> tuple[list[dict[str, Any]], list[str]]:
@@ -534,9 +580,10 @@ def _structure_rows(
composite = mapping.composite_for(type_id) if code is None else None composite = mapping.composite_for(type_id) if code is None else None
kind = structure_kind(structure) if composite else None kind = structure_kind(structure) if composite else None
parts: list[dict[str, Any]] | None = None parts: list[dict[str, Any]] | None = None
parts_missing: list[str] = [] parts_missing: list[dict[str, Any]] = []
if composite: if composite:
parts, parts_missing = composite_quantities(structure, composite, mapping) parts, parts_missing = composite_quantities(structure, composite, mapping)
blocked_kind, blocked_reason = blocked_of(structure, class_basis)
if code is None and composite is None: if code is None and composite is None:
unmatched.append(f"{wording_type_label(type_id)} — 품셈 공종을 아직 못 이었습니다") unmatched.append(f"{wording_type_label(type_id)} — 품셈 공종을 아직 못 이었습니다")
elif entry.get("class_from") == "back_length" and class_key is None: elif entry.get("class_from") == "back_length" and class_key is None:
@@ -558,11 +605,15 @@ def _structure_rows(
"haul_equipment": None, "haul_equipment": None,
"station_from": structure.get("start_m"), "station_from": structure.get("start_m"),
"station_to": structure.get("end_m"), "station_to": structure.get("end_m"),
"excavation_method": None,
"spec_detail": _spec_detail(structure), "spec_detail": _spec_detail(structure),
# 품셈에 그 이름의 공종이 없어 여러 공종을 묶는 자리 — 빈 코드와 구별한다. # 품셈에 그 이름의 공종이 없어 여러 공종을 묶는 자리 — 빈 코드와 구별한다.
"composite_parts": parts, "composite_parts": parts,
# 철근이 있나 없나로 자동 판정 — 사람이 고르는 값이 아니다. # 철근이 있나 없나로 자동 판정 — 사람이 고르는 값이 아니다.
"structure_kind": kind, "structure_kind": kind,
# ⚠ 줄마다 **왜 막혔는지**를 싣는다 — 안 실으면 받는 쪽 화면이 빈다.
"blocked_kind": blocked_kind,
"blocked_reason": blocked_reason,
# 규격 갈래(뒷길이 …㎝ 이하) — 못 고르면 사유가 남는다. # 규격 갈래(뒷길이 …㎝ 이하) — 못 고르면 사유가 남는다.
"spec_class": class_key, "spec_class": class_key,
"spec_class_basis": class_basis, "spec_class_basis": class_basis,