"""인계 줄을 만드는 자리 — 토공·운반·구조물·준비공·배수관·연장·타설 (`Engine_Handoff` 에서 갈라냄). ⚠ **왜 갈랐나** — 위와 같다(700줄 제한). **줄의 모양은 하나도 안 바뀐다.** ⚠ **줄 빌더가 여덟이라 칸을 하나 늘리면 여덟 곳을 함께 고쳐야 한다** — 계약 시험 (`tmp/tests/test_b08_handoff_contract.py`)이 「모든 줄이 같은 칸을 갖는가」로 그것을 지킨다. """ from __future__ import annotations from typing import Any from B08_Quantity.B08_Quantity_Engine_BasisUnit import normalize_unit, unit_for_code from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import ( BLOCKED_FORMULA_MISSING, BLOCKED_INPUT_MISSING, BLOCKED_UNIT_DATA_MISSING, METHOD_TO_GROUND, NOTE_METHOD_MISSING, ORIGIN_EARTHWORK, ORIGIN_HAUL, ORIGIN_PIPE, ORIGIN_PREPARATION, ORIGIN_SLOPE, ORIGIN_STRUCTURE, SLOPE_GROUPS, SUBTOTAL_GROUPS, WorkItemMapping, composite_quantities, masonry_class, placing_code, structure_kind, ) from B08_Quantity.B08_Quantity_Engine_Preparation import ( STATUS_COUNTED_ELSEWHERE as PREP_COUNTED_ELSEWHERE, ) from B08_Quantity.B08_Quantity_Engine_Preparation import ( STATUS_NOT_APPLICABLE as PREP_NOT_APPLICABLE, ) from B08_Quantity.B08_Quantity_Engine_Preparation import STATUS_PENDING as PREP_PENDING from B08_Quantity.B08_Quantity_Engine_Preparation import STATUS_READY as PREP_READY from B08_Quantity.B08_Quantity_Wording import type_label as wording_type_label def _spec_detail(structure: dict[str, Any]) -> str: """규격 표기 — 저장된 제원에서 만든다. 없는 값은 적지 않는다.""" parts: list[str] = [] height = structure.get("height_m") length = structure.get("length_m") if height: parts.append(f"H={height:g}") if length: parts.append(f"L={length:g}m") return "·".join(parts) def _mapping_ground(ground: str | None, methods: dict[str, str | None]) -> tuple[str | None, str]: """갈래 이름을 **매핑표가 아는 이름**으로 바꾼다. 「토사」는 그대로 가고, 암 갈래는 **시공법이 정해져야** 리핑암·발파암으로 간다. 안 정했으면 `(None, 사유)` — 찍지 않는다. 잘못 찍으면 공종이 조용히 틀린다. """ if ground is None or ground == "토사": return ground, "" method = methods.get(ground) mapped = METHOD_TO_GROUND.get(method or "") if mapped: return mapped, "" return None, NOTE_METHOD_MISSING def _basis_mismatch(entry: dict[str, Any] | None, unit: str) -> tuple[str, str, str] | None: """(품셈 밑수, 막힘 갈래, 사유) — 매핑이 밝힌 밑수와 우리 단위가 **뜻이 다를 때만**. ⚠ **왜 매핑이 밝히나** — 마스터 `basis_unit` 은 절 머리의 「(단위: …)」에서 오는데, **공식으로만 단위가 밝혀지는 공종**은 그 자리가 비어 있다(층따기 9-18 은 [주]의 `Q1 = … = ㎥/시간` 이 유일한 단서). 마스터가 비면 밑수 대조가 조용히 통과한다 — 그래서 **원문에서 읽은 밑수를 매핑에 적어** 대조가 서게 한다. ⚠ **환산하지 않는다.** ㎡ 를 ㎥ 로 바꾸려면 층따기 단의 높이·폭을 지어내야 한다. """ declared = str((entry or {}).get("basis_unit") or "") if not declared or not unit: return None if normalize_unit(unit) == normalize_unit(declared): return None return ( declared, str((entry or {}).get("mismatch_kind") or BLOCKED_UNIT_DATA_MISSING), str((entry or {}).get("mismatch_reason") or ""), ) def _earthwork_rows( summary_table: dict[str, Any], mapping: WorkItemMapping, methods: dict[str, str | None], ) -> tuple[list[dict[str, Any]], list[str]]: """토공집계표 줄을 내역 줄로 옮긴다. ⚠ 「보정량계」 같은 합계 줄은 **내역 줄이 아니다** — 빼지 않고 `in_bill: False` 로 넘긴다. 빼 버리면 B09 가 검산할 때 합이 안 맞는 까닭을 알 수 없다. """ rows: list[dict[str, Any]] = [] unmatched: list[str] = [] for row in summary_table.get("rows") or []: group = str(row.get("group") or "") if not group: continue ground = row.get("item") or None origin = ORIGIN_SLOPE if group in SLOPE_GROUPS else ORIGIN_EARTHWORK is_subtotal = group in SUBTOTAL_GROUPS lookup_ground, method_note = _mapping_ground(ground, methods) entry = mapping.for_earthwork(group, lookup_ground) if method_note == "" else None code = (entry or {}).get("work_item_code") # ⚠ 품셈 밑수와 우리 단위가 다른 자리 — **곱하면 금액이 틀린다**(층따기 9-18). # 면적 값을 버리지 않고 `spec_detail` 에 남겨 되짚을 수 있게 한다. unit = str(row.get("unit") or "㎥") amount = float(row.get("amount") or 0.0) mismatch = _basis_mismatch(entry, unit) if code else None spec_detail = "" if mismatch is not None: spec_detail = f"집계 {amount:,.2f} {unit} (품셈 밑수 {mismatch[0]})" unit, amount = mismatch[0], 0.0 if code is None and not is_subtotal: label = f"{group}({ground})" if ground else group unmatched.append(f"{label} — {method_note}" if method_note else label) rows.append( { "work_item_code": code, "name": group, "spec": str(row.get("spec") or ""), "unit": unit, "quantity": amount, # 반영률 — 곱하기는 여기가 끝이다. 받는 쪽은 적기만 한다. "quantity_gross": row.get("amount_gross"), "application_ratio_pct": row.get("application_ratio_pct"), # 율이 부분마다 다른 줄 — 받는 쪽이 문장을 안 뜯게 칸으로 준다. "application_ratio_breakdown": row.get("application_ratio_breakdown"), "quantity_breakdown": row.get("quantity_breakdown"), "ground_class": ground, "haul_distance_m": None, "haul_equipment": None, "station_from": None, "station_to": None, "spec_detail": spec_detail, "composite_parts": None, "structure_kind": None, # 토공·운반 줄에는 갈래 축이 없다 — 그래도 **칸은 둔다**(계약이 한 모양). "variant_axis": None, "variant_value": None, "secondary_axes": None, "spec_class": None, "spec_class_basis": "", # 토공 줄도 막힐 수 있다 — 품셈 밑수와 단위가 다르면 그 사유가 실린다. "blocked_kind": mismatch[1] if mismatch else None, "blocked_reason": mismatch[2] if mismatch else "", "composite_not_ready": None, # 합계 줄과 무대 줄은 값은 내되 내역에 안 선다. "in_bill": bool(row.get("in_bill", True)) and not is_subtotal and mismatch is None, "excavation_method": methods.get(ground) if ground else None, "in_bill_reason": "집계 합계 줄 — 검산용" if is_subtotal else str(row.get("note") or ""), "origin": origin, } ) return rows, unmatched def _haul_rows( haul_table: dict[str, Any], mapping: WorkItemMapping ) -> tuple[list[dict[str, Any]], list[str]]: """운반 줄 — 가중평균 거리가 붙는다. 무대는 `in_bill: False` 로 함께 넘긴다(㉡).""" rows: list[dict[str, Any]] = [] unmatched: list[str] = [] for row in haul_table.get("rows") or []: equipment = str(row.get("equipment") or "") entry = mapping.for_haul(equipment) or {} code = entry.get("work_item_code") in_bill = bool(row.get("in_bill", True)) and entry.get("in_bill", True) if code is None and in_bill: unmatched.append(f"운반({equipment})") rows.append( { "work_item_code": code, "name": f"{equipment} 운반", "spec": str(row.get("ground") or ""), "unit": "㎥", "quantity": float(row.get("volume_m3") or 0.0), # 운반에는 반영률 개념이 없다 — 그래서 `None` 이다(0 이 아니다). "quantity_gross": None, "application_ratio_pct": None, "application_ratio_breakdown": None, "quantity_breakdown": None, "ground_class": row.get("ground") or None, "haul_distance_m": float(row.get("average_distance_m") or 0.0), "haul_equipment": equipment, "excavation_method": None, "station_from": None, "station_to": None, "spec_detail": "", "composite_parts": None, "structure_kind": None, # 토공·운반 줄에는 갈래 축이 없다 — 그래도 **칸은 둔다**(계약이 한 모양). "variant_axis": None, "variant_value": None, "secondary_axes": None, "spec_class": None, "spec_class_basis": "", "blocked_kind": None, "blocked_reason": "", "composite_not_ready": None, "in_bill": in_bill, "in_bill_reason": str(entry.get("reason") or ""), "origin": ORIGIN_HAUL, } ) return rows, unmatched def blocked_of( structure: dict[str, Any], class_basis: str = "", has_code: bool = False ) -> tuple[str | None, str]: """(막힌 갈래, 사유). 안 막혔으면 `(None, "")`. ⚠ 사유 문구는 **`B08_Quantity_Wording` 것을 그대로** 쓴다 — 두 벌로 짜면 갈린다. 전개 알림(`notes`)에 이미 사람 말로 적혀 있으므로 그것을 그대로 옮긴다. ⚠⚠ **전개식이 없다고 다 막힌 것이 아니다** (2026-09-08 V-3 에서 드러남). B군 종단배수(산마루측구 12-9-2 · 소단측구 12-9-3 · 맹암거 12-10)는 품셈 밑수가 **1 m** 라 **연장이 곧 수량**이다 — 원단위 전개가 필요 없다. 그런데 「성분이 없으면 전개식 없음」으로 단정해 B09 가 **「우리가 만들 것」으로 빼 금액이 0** 이었다. **공종코드가 붙었고 수량이 있으면 막힌 것이 아니다.** """ notes = [str(note) for note in structure.get("notes") or []] if has_code and float(structure.get("length_m") or 0.0) > 0: return None, "" 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 _component_billing( structure: dict[str, Any], entry: dict[str, Any] ) -> tuple[str, float] | None: """(내역 단위, 그 단위로 센 수량) — **전개 성분 하나**에서 가져온다. 없으면 `None`. ⚠ **왜 성분에서 가져오나** — 돌쌓기 면적은 이미 전개가 냈다(비탈면적 = 정면적 × √(1+n²)). 여기서 다시 재면 **같은 식이 두 벌**이 되고 한쪽만 고쳐지는 자리가 된다. ⚠ **어느 성분인지는 매핑이 말한다** — 단위만 보고 고르면 거푸집 같은 다른 ㎡ 성분을 집는다. """ name = str(entry.get("billing_component") or "") if not name: return None for component in structure.get("components") or []: if str(component.get("name") or "") != name: continue unit = str(component.get("unit") or "") amount = float(component.get("amount") or 0.0) if unit and amount > 0: return unit, amount return None def _structure_rows( unit_quantity_table: dict[str, Any], mapping: WorkItemMapping ) -> tuple[list[dict[str, Any]], list[str]]: """구조물 줄 — **작업 공종 하나**로 선다. ⚠ 전개 성분(터파기·야면석·모르터)은 여기 오지 않는다. 구조물 한 기가 내역 한 줄이고, 그 전개는 토공 합산·자재총괄·일위대가로 갈린다(8-2 이중계상 함정). """ rows: list[dict[str, Any]] = [] unmatched: list[str] = [] for structure in unit_quantity_table.get("structures") or []: type_id = str(structure.get("type_id") or "") entry = mapping.for_structure(type_id) or {} code = entry.get("work_item_code") # 돌쌓기는 **뒷길이 갈래**로, 큰돌쌓기는 **메/찰**로 단가가 갈린다 — # 둘 다 저장 제원에서 자동으로 고른다(사용자 칸을 따로 만들지 않는다). class_key: str | None = None class_basis = "" if entry.get("class_from") == "bond": bond = str((structure.get("options") or {}).get("bond") or "").strip() bond_codes = entry.get("bond_codes") or {} if bond in bond_codes: # 메/찰은 **의미 판정**이라 우리 몫이다 — 공종 자체가 갈린다. code = bond_codes[bond] class_key = bond class_basis = f"쌓기 방식 「{bond}」 → 품셈 13-6 {code.split('-')[-1]}" else: class_basis = ( "큰돌쌓기 쌓기 방식이 아직 입력되지 않았습니다 — 구조물 상세 입력에서 " "메쌓기·찰쌓기 중 하나를 고르면 공종이 정해집니다" ) if code and entry.get("class_from") == "back_length": class_key, class_basis = masonry_class(structure.get("options") or {}) # ⚠ 품셈 밑수가 「㎡당」인 공종은 **연장으로 세면 안 된다** — 받는 쪽이 ㎡ 단가를 # m 수량에 곱해 **2.6배** 금액이 섰다(2026-09-08 실증). 어느 성분으로 세는지는 # 매핑이 말한다(`billing_component`) — 코드가 짐작하지 않는다. billing = _component_billing(structure, entry) composite = mapping.composite_for(type_id) if code is None else None kind = structure_kind(structure) if composite else None parts: list[dict[str, Any]] | None = None parts_missing: list[dict[str, Any]] = [] if composite: parts, parts_missing = composite_quantities(structure, composite, mapping) # ⚠ 매핑이 「이 성분으로 센다」고 했는데 그 성분이 없으면 **연장으로 세지 않는다** — # 세면 다시 틀린 축으로 금액이 선다. 코드가 없는 것과 같이 보아 사유를 찾는다. counts_by_component = bool(entry.get("billing_component")) blocked_kind, blocked_reason = blocked_of( structure, class_basis, has_code=bool(code) and (billing is not None or not counts_by_component), ) # 갈래 축과 **저장 제원 원본값**. 가공하지 않는다. variant_axis = str(entry.get("variant_axis") or "") or None variant_value = (structure.get("options") or {}).get(variant_axis) if variant_axis else None secondary_axes = [ {"axis": axis, "value": (structure.get("options") or {}).get(axis)} for axis in entry.get("secondary_axes") or [] ] if code is None and composite is None: unmatched.append(f"{wording_type_label(type_id)} — 품셈 공종을 아직 못 이었습니다") elif entry.get("class_from") in ("back_length", "bond") and class_key is None: unmatched.append(f"{wording_type_label(type_id)} — {class_basis}") length = float(structure.get("length_m") or 0.0) # ⚠ 관측 원단위가 「개소당」·「㎡당」인 종류는 **연장으로 세면 축이 어긋난다** — # 집수정 한 개소가 연장 2m 면 값이 두 배로 실린다(2026-09-08 ㉕ 실증). # 성분은 개소 기준으로 맞게 서는데 **줄의 축만** 틀렸던 자리다. if billing is not None: bill_unit, bill_quantity = billing elif counts_by_component: # ⚠ 물량이 못 섰다 — **연장으로 대신 세지 않는다.** 단위는 품셈 밑수를 그대로 # 실어 둔다(「0 m」로 내면 받는 쪽이 길이로 읽고 축이 어긋난 채 채워진다). bill_unit, bill_quantity = unit_for_code(str(code or "")), 0.0 elif structure.get("billing_unit"): bill_unit = str(structure["billing_unit"]) bill_quantity = float(structure.get("billing_quantity") or 0.0) else: bill_unit, bill_quantity = "m", length rows.append( { "work_item_code": code, "name": str(structure.get("name") or type_id), "spec": _spec_detail(structure), "unit": bill_unit, "quantity": bill_quantity, "quantity_gross": None, "application_ratio_pct": None, "application_ratio_breakdown": None, "quantity_breakdown": None, "ground_class": None, "haul_distance_m": None, "haul_equipment": None, "station_from": structure.get("start_m"), "station_to": structure.get("end_m"), "excavation_method": None, "spec_detail": _spec_detail(structure), # 품셈에 그 이름의 공종이 없어 여러 공종을 묶는 자리 — 빈 코드와 구별한다. "composite_parts": parts, # 철근이 있나 없나로 자동 판정 — 사람이 고르는 값이 아니다. "structure_kind": kind, # ⚠ 줄마다 **왜 막혔는지**를 싣는다 — 안 실으면 받는 쪽 화면이 빈다. "blocked_kind": blocked_kind, "blocked_reason": blocked_reason, # 규격 갈래(뒷길이 …㎝ 이하) — 못 고르면 사유가 남는다. # ⚠ **갈래 키 문자열을 우리가 조립하지 않는다** (2026-09-07 계약 변경). # 품셈 원문이 물결표를 섞어 쓴다(`∼` U+223C / `~` U+FF5E). 두 창이 각자 # 키를 조립하면 **글자 하나로 영영 안 맞는다.** 우리는 **어느 축인지와 # 저장 원본값**만 보내고, 원문을 읽는 쪽이 그 표기를 흡수한다. "variant_axis": variant_axis, "variant_value": variant_value, # ⚠ 갈래 축이 **둘 이상**인 자리 — 돌쌓기는 뒷길이와 **돌 종류**로 갈린다. # 여기서도 **저장 원본값만** 싣는다(키 조립은 원문 읽는 쪽 몫). "secondary_axes": secondary_axes or None, "spec_class": class_key, "spec_class_basis": class_basis, # ⚠ 물량을 못 채운 조각 — 0 으로 적지 않고 사유와 함께 드러낸다. "composite_not_ready": parts_missing or None, "in_bill": True, "in_bill_reason": (composite or {}).get("why", ""), "origin": ORIGIN_STRUCTURE, } ) return rows, unmatched #: 타설 대상으로 보는 성분 이름 — **정확히 같은 이름**으로만 본다. #: ⚠ **「채움콘크리트」는 뺀다** — 돌쌓기 뒤채움이라 그 공종의 품에 이미 들어 있을 수 있다. #: 품셈 13-6 [주]① 은 큰돌쌓기의 채움콘크리트를 **품에 포함**이라고 못 박았고, 13-4 는 #: 그 [주]가 없어 **확인 전까지 세우지 않는다.** 넓게 잡으면 그것이 곧 이중계상이다. #: (넣으려면 돌쌓기 일위대가에 타설 품이 있는지부터 확인할 것 — B09 ㉢ 과 같은 자리.) PLACING_TARGET_NAMES = frozenset({"콘크리트", "버림콘크리트", "레미콘"}) #: 버림 타설 줄에 **표시**할 이름 — 실무 내역 표기 그대로(「레미콘타설(장비) 무근,버림」). #: ⚠⚠ **표시 문구일 뿐 갈래가 아니다.** 품셈 12-1-1 의 갈래는 무근/철근/소형 셋뿐이고 #: **「버림」이라는 열이 없다.** 실무도 줄 이름만 「무근,버림」이고 품은 무근 것을 쓴다 #: (봉화 제50호표 단가가 「무근」과 같음). #: ⇒ `variant_value`·`structure_kind` 는 **「무근구조물」 그대로** 보내고 여기 이름은 #: `spec` 에만 쓴다. 갈래 축에 없는 값을 보내면 받는 쪽이 단가를 못 고른다 #: (2026-09-09 실측: 275,584원이 통째로 빠졌다). BLINDING_PLACING_LABEL = "무근,버림" #: 버림이 실제로 쓰는 품셈 갈래 — 무근이다. BLINDING_PLACING_KIND = "무근구조물" def _placing_rows( unit_quantity_table: dict[str, Any], mapping: WorkItemMapping, method: str | None, ) -> tuple[list[dict[str, Any]], list[str]]: """콘크리트 **타설 공종** 줄 — 구조물 종류별로 체적을 모아 한 줄씩 낸다. ⚠ **이중계상이 아니다** (2026-09-08 두 창 확인). 품셈 12-1-1 표는 **직종·품만** 주고 재료를 안 준다(원문도 「콘크리트공(인) | 보통인부(인)」 두 열뿐). 서브 일위대가 `B-FP-12-01-01#철근구조물` 도 **재료 0원 · 노무 65,826.48원**이다. ⇒ **품은 이 줄, 재료는 자재 쪽**으로 갈려 있어 겹치지 않는다. ⚠ 방식은 **설계 판단**이고 종류(무근/철근)는 **철근이 있나 없나로 자동 판정**한다 — 사람이 고르는 값이 아니다(`work_item_mapping` 의 `kind_rule`). """ code, used_default = placing_code(mapping, method) if code is None: return [], [] buckets: dict[str, float] = {} for structure in unit_quantity_table.get("structures") or []: # ⚠⚠ **묶음으로 서는 구조물은 건너뛴다 — 그 콘크리트는 묶음 조각이 이미 센다.** # 옹벽 묶음에 `FP-12-01-01 콘크리트 타설` 조각이 들어 있다(`work_item_mapping` # 의 `composite`). 여기서 또 세우면 **같은 콘크리트를 두 번** 센다. # 2026-09-08 B09 가 「철근이 겹치나」를 물어 그 김에 드러난 자리다 — # 철근은 안 겹치고(자재는 재료·묶음 조각은 품, 재료 0원) **타설이 겹쳤다.** # ⚠ 묶음이 아직 미확보라 지금은 값이 안 걸렸을 뿐, 묶음이 서는 날 두 번이 된다. if mapping.composite_for(str(structure.get("type_id") or "")): continue # ⚠ 버림은 **따로 센다** — 실무 내역이 「레미콘타설(장비) **무근,버림**」으로 갈라 # 적는다(봉화 제50호표, 2026-09-09 데스크탑 보조 확인). 같은 공종·같은 단가라 # 금액은 안 움직이고 **이름만 맞추는 것**이다. for component in structure.get("components") or []: name = str(component.get("name") or "").strip() if name not in PLACING_TARGET_NAMES or component.get("unit") != "㎥": continue volume = float(component.get("amount") or 0.0) if volume <= 0: continue # (갈래, 표시 이름) 으로 담는다 — 갈래는 품셈 축, 표시는 실무 줄 이름. if name == "버림콘크리트": key = (BLINDING_PLACING_KIND, BLINDING_PLACING_LABEL) else: kind = structure_kind(structure) key = (kind, kind) buckets[key] = buckets.get(key, 0.0) + volume rows = [ { "work_item_code": code, "name": "콘크리트 타설", # ⚠ 표시 이름과 갈래를 **가른다** — 표시는 실무 줄 이름(「무근,버림」), # 갈래(`variant_value`)는 품셈 축(무근/철근/소형)이라야 단가가 붙는다. "spec": label, "unit": "㎥", "quantity": volume, "quantity_gross": None, "application_ratio_pct": None, "application_ratio_breakdown": None, "quantity_breakdown": None, "ground_class": None, "haul_distance_m": None, "haul_equipment": None, "station_from": None, "station_to": None, "excavation_method": None, "spec_detail": label, "composite_parts": None, "structure_kind": kind, "blocked_kind": None, "blocked_reason": "", "variant_axis": "structure_kind", "variant_value": kind, "secondary_axes": None, "spec_class": kind, "spec_class_basis": ( "철근이 있으면 철근구조물, 없으면 무근구조물 — 원단위로 자동 판정" ), "composite_not_ready": None, "in_bill": True, "in_bill_reason": "", "origin": ORIGIN_STRUCTURE, } for (kind, label), volume in sorted(buckets.items()) ] notes: list[str] = [] if rows and used_default: notes.append( "콘크리트 타설 방식을 아직 안 정해 기본값(레디믹스트)으로 섰습니다 — " "산출 조건에서 정하면 이 공종의 단가가 달라집니다" ) return rows, notes def _preparation_rows(preparation_table: dict[str, Any]) -> list[dict[str, Any]]: """준비공·사방공 줄 — **값이 서는 줄도, 못 서는 줄도** 함께 보낸다. ⚠ **줄을 빼면 「빠졌다는 사실조차 안 보인다」** (2026-09-08 보조 창 제보). 받는 쪽 화면에서 「내역서에 원래 없는 것」과 「우리가 아직 못 내는 것」이 구별되지 않는다. 그래서 못 내는 줄도 `in_bill: False` + `blocked_reason` 으로 실어 보낸다 — **금액은 안 붙되 「무엇이 채워지면 풀리는지」가 함께 간다.** ⚠ 이 표가 통째로 안 가고 있었다 — 표토제거(값 있음·`FP-09-15`)·규준틀(개소·`FP-11-02`)이 화면에는 서는데 인계에는 없었다. 「사유를 실어 달라」는 요청을 보다 드러났다. """ rows: list[dict[str, Any]] = [] for row in preparation_table.get("rows") or []: status = str(row.get("status") or "") amount = row.get("amount") ready = status == PREP_READY and amount is not None rows.append( { "work_item_code": row.get("work_item_code"), "name": str(row.get("item") or ""), "spec": str(row.get("group") or ""), "unit": str(row.get("unit") or ""), "quantity": float(amount or 0.0), "quantity_gross": None, "application_ratio_pct": None, "application_ratio_breakdown": None, "quantity_breakdown": None, "ground_class": None, "haul_distance_m": None, "haul_equipment": None, "station_from": None, "station_to": None, "excavation_method": None, "spec_detail": str(row.get("group") or ""), "composite_parts": None, "structure_kind": None, # ⚠ 못 서는 까닭을 그대로 넘긴다 — 받는 쪽이 「만들어야 할 것」 목록에 얹는다. "blocked_kind": None if ready else _prep_blocked_kind(status), "blocked_reason": "" if ready else str(row.get("reason") or status), "variant_axis": None, "variant_value": None, "secondary_axes": None, "spec_class": None, "spec_class_basis": str(row.get("reason") or ""), "composite_not_ready": None, # 값이 없는 줄은 **내역에 세우지 않는다** — 0 원 줄을 만들면 더 나쁘다. "in_bill": ready, "in_bill_reason": "" if ready else str(row.get("reason") or status), "origin": ORIGIN_PREPARATION, } ) return rows def _prep_blocked_kind(status: str) -> str | None: """준비공 줄의 상태를 **막힌 갈래 셋** 중 하나로 옮긴다. 모르는 상태는 `None`.""" if status == PREP_PENDING: return BLOCKED_INPUT_MISSING if status == PREP_COUNTED_ELSEWHERE: # 다른 표에서 이미 선 줄 — 막힌 것이 아니라 **여기서 세면 안 되는** 줄이다. return None if status == PREP_NOT_APPLICABLE: return None return BLOCKED_UNIT_DATA_MISSING def _pipe_rows(pipe_table: dict[str, Any]) -> list[dict[str, Any]]: """배수관 줄 — 값이 서는 줄도, 못 서는 줄도 함께 보낸다(준비공과 같은 규칙). ⚠ 터파기·되메우기를 붙이지 않는다 — 관 부설과 굴착이 각각 오면 **같은 굴착을 두 번** 센다 (B09 ㉡ 가드와 같은 자리). """ rows: list[dict[str, Any]] = [] for row in pipe_table.get("rows") or []: ready = bool(row.get("in_bill")) rows.append( { "work_item_code": row.get("work_item_code"), "name": f"배수관({row.get('kind')})", "spec": f"Ø{row.get('variant_value')}" if row.get("variant_value") else "", "unit": str(row.get("unit") or "m"), "quantity": float(row.get("quantity") or 0.0), "quantity_gross": None, "application_ratio_pct": None, "application_ratio_breakdown": None, "quantity_breakdown": None, "ground_class": None, "haul_distance_m": None, "haul_equipment": None, "station_from": row.get("chainage_m"), "station_to": row.get("chainage_m"), "excavation_method": None, "spec_detail": f"Ø{row.get('variant_value')}" if row.get("variant_value") else "", "composite_parts": None, "structure_kind": None, "blocked_kind": row.get("blocked_kind"), "blocked_reason": str(row.get("blocked_reason") or ""), # 갈래는 **저장 원본값**만 — 「…㎜ 이하」 구간 나누기는 원문을 읽는 쪽 몫. "variant_axis": row.get("variant_axis"), "variant_value": row.get("variant_value"), "secondary_axes": None, "spec_class": None, "spec_class_basis": str(row.get("blocked_reason") or ""), "composite_not_ready": None, "in_bill": ready, "in_bill_reason": "" if ready else str(row.get("blocked_reason") or ""), "origin": ORIGIN_PIPE, } ) return rows def _length_rows( length_table: list[dict[str, Any]], mapping: WorkItemMapping ) -> list[dict[str, Any]]: """B군 종단배수 — **종류별 한 줄**로 낸다(연장이 곧 수량). ⚠ **왜 구조물별로 안 내나** — `common_util_structure_lengths` 가 **겹친 구간을 합쳐** 준다. 같은 시설을 겹쳐 놓으면 구조물별로 세는 순간 그 구간을 **두 번** 센다. 그 규칙(겹침 합치기 · 측구 제외 · 관 소관 제외)이 이미 그 함수에 있으므로 **두 벌로 짜지 않는다**(2026-09-08 랩탑 창 제안, 두 창 합의). ⚠ **C군(돌쌓기·옹벽 등)은 여기로 오지 않는다** — 그 함수는 종류별로 뭉쳐 내는데, C군은 **측점·규격이 줄마다 달라** 구조물별로 서야 하고 자재도 줄마다 나온다. 실무 내역도 B군은 「산마루측구 40m」 한 줄, C군은 구조물별 줄이다. ⚠ 겹침이 있으면(`length_m != raw_length_m`) **숨기지 않고 비고에 적는다.** """ rows: list[dict[str, Any]] = [] for entry in length_table or []: type_id = str(entry.get("type_id") or "") found = mapping.for_structure(type_id) code = (found or {}).get("work_item_code") length = float(entry.get("length_m") or 0.0) raw = float(entry.get("raw_length_m") or length) # 구간 목록 — **겹침을 지운 뒤**의 것이라 그 합이 곧 `length_m` 이다 # (80~120 과 100~140 은 80~140 한 줄로 합쳐져 온다, 2026-09-08 랩탑 창). # ⚠ 표기(`NO.4+0.0`)는 만들지 않는다 — 측점 간격을 아는 화면 몫이다. spans = [ span for span in (entry.get("spans") or []) if span.get("start_m") is not None and span.get("end_m") is not None ] span_note = " · ".join(f"{s['start_m']:g}~{s['end_m']:g}m" for s in spans) note = f"구간 {span_note}" if span_note else "" if abs(raw - length) > 1e-9: 겹침 = f"입력 구간 합 {raw:g}m 에서 겹친 {raw - length:g}m 를 뺀 값" note = f"{note} · {겹침}" if note else 겹침 # ⚠ 겹침 설명은 **비고**이지 막힌 사유가 아니다 — `blocked_reason` 에 넣으면 # 받는 쪽이 「막힌 줄」로 읽어 금액을 안 붙인다(2026-09-08 실측에서 그랬다). reason = "" if code is None: reason = f"{entry.get('name') or type_id} — 품셈 공종을 아직 못 이었습니다" elif length <= 0: reason = f"{entry.get('name') or type_id} — 연장이 0 이라 값이 서지 않습니다" rows.append( { "work_item_code": code, "name": str(entry.get("name") or type_id), "spec": f"{entry.get('count')}개소", "unit": "m", "quantity": length, "quantity_gross": raw if note else None, "application_ratio_pct": None, "application_ratio_breakdown": None, "quantity_breakdown": None, "ground_class": None, "haul_distance_m": None, "haul_equipment": None, # 여러 구간이면 **처음과 끝**만 싣는다 — 사이 구간은 비고에 다 적혀 있다. "station_from": spans[0]["start_m"] if spans else None, "station_to": spans[-1]["end_m"] if spans else None, "excavation_method": None, "spec_detail": f"{entry.get('count')}개소", "composite_parts": None, "structure_kind": None, "blocked_kind": None if (code and length > 0) else BLOCKED_FORMULA_MISSING, "blocked_reason": reason, "variant_axis": None, "variant_value": None, "secondary_axes": None, "spec_class": None, "spec_class_basis": note, "composite_not_ready": None, "in_bill": bool(code and length > 0), "in_bill_reason": "" if (code and length > 0) else reason, "origin": ORIGIN_STRUCTURE, } ) return rows def _material_rows(material_table: dict[str, Any]) -> list[dict[str, Any]]: """자재 줄 — **공종코드를 붙이지 않는다.** 자재 축은 B09 카탈로그가 잇는다(8-7).""" rows: list[dict[str, Any]] = [] for row in material_table.get("rows") or []: rows.append( { "material_name": row.get("name"), "spec": row.get("spec") or "", "unit": row.get("unit"), "net_amount": row.get("net_amount"), "total_amount": row.get("total_amount"), "surcharge_pct": row.get("surcharge_pct"), "surcharge_note": row.get("note") or "", "supply_type": row.get("supply"), "install_by": row.get("install_by"), "source_structure": row.get("sources") or [], } ) return rows