Files
Aislo/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py
T
eomsangdonandClaude Opus 5 c2e3c4cda2 feat(b09): 구조물도 일위대가를 내역으로 받는 문 — 호표 B-AX-ST-<8hex>#<제원 키>
- 양식 일위대가로 셀 구조물도 장은 인계 줄 하나(코드 AX-ST · 갈래 = 제원 키 · 수량 연장 m)
- 양식이 품은 줄(기초잡석)은 따로 인계 안 함 — 이중계상 막음
- B09 내역이 자기 단가표로 B08 일위대가 엔진을 돌려 금액 · 못 받거나 막히면 사유
- 수동 단가도 씀 — 내역 요약에 미확정 N건

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-13 22:20:26 +09:00

622 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""인계 줄을 만드는 자리 — 토공·운반·구조물·준비공·배수관·연장·타설 (`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,
GROUND_SPLIT_GROUPS,
HAUL_SUMMARY_GROUPS,
METHOD_TO_GROUND,
NOTE_HAUL_IN_SUMMARY,
NOTE_METHOD_MISSING,
ORIGIN_EARTHWORK,
ORIGIN_HAUL,
ORIGIN_SLOPE,
ORIGIN_STRUCTURE,
SLOPE_GROUPS,
SUBTOTAL_GROUPS,
WorkItemMapping,
composite_quantities,
masonry_class,
placing_code,
structure_kind,
)
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 ""),
)
#: 면적(㎡)으로 서지만 **길이를 곱해 ㎥ 로 내보내는** 공종 — 지금은 층따기뿐이다.
#: ⭐ 2026-09-09 사용자 확정 2차 ① — 「면적이 정본이고 부피는 사용자가 지정한 길이를 곱해 쓴다」.
#: ⚠ 면적을 없애지 않는다(횡단도 하단 표가 면적을 쓴다) — **㎥ 를 덧붙이는 것**이다.
AREA_TIMES_DEPTH_GROUPS = {"층따기"}
def _earthwork_rows(
summary_table: dict[str, Any],
mapping: WorkItemMapping,
methods: dict[str, str | None],
bench_cut_depth_m: float | None = None,
variant_inputs: dict[str, str | None] | None = None,
) -> tuple[list[dict[str, Any]], list[str]]:
"""토공집계표 줄을 내역 줄로 옮긴다.
`variant_inputs` — 매핑 줄이 `variant_from` 으로 가리키는 **설정값**(임목축적 등급 등).
⚠ 「보정량계」 같은 합계 줄은 **내역 줄이 아니다** — 빼지 않고 `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
# ⚠ `item` 이 **지반 갈래인 공종**은 정해져 있다(흙깎기·측구터파기·구조물터파기).
# 그 밖(지장목제거의 「뿌리뽑기·잡관목제거」 같은 **작업 갈래**)을 갈래로 읽으면
# 「시공법 미지정으로 공종을 못 고름」이라는 **틀린 사유**가 붙는다(2026-09-09 실측).
is_ground_split = group in GROUND_SPLIT_GROUPS
ground = (row.get("item") or None) if is_ground_split else None
work_kind = None if is_ground_split else (row.get("item") or None)
origin = ORIGIN_SLOPE if group in SLOPE_GROUPS else ORIGIN_EARTHWORK
# ⚠ 운반은 **집계에도 오르고 운반표에도 오른다** — 내역 줄은 운반표 쪽 하나뿐이다.
is_subtotal = group in SUBTOTAL_GROUPS or group in HAUL_SUMMARY_GROUPS
lookup_ground, method_note = _mapping_ground(ground, methods)
entry = (
mapping.for_earthwork(group, lookup_ground, work_kind) 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 = ""
# 층따기 — 길이가 들어오면 **면적 × 길이**로 ㎥ 를 내 품셈 단가를 그대로 쓴다.
depth = float(bench_cut_depth_m or 0.0)
if group in AREA_TIMES_DEPTH_GROUPS and unit == "㎡" and depth > 0:
spec_detail = f"면적 {amount:,.2f}× 길이 {depth:g}m"
unit, amount, mismatch = "㎥", amount * depth, None
elif mismatch is not None:
spec_detail = f"집계 {amount:,.2f} {unit} (품셈 밑수 {mismatch[0]})"
unit, amount = mismatch[0], 0.0
# ⚠⚠ **코드가 없으면 반드시 막힘 표시를 단다**(2026-09-09 랩탑 메인 제보).
# 종전에는 `unmatched_work_items` 목록과 `bill_flag_warnings` 에만 실려,
# **줄 단위로 보는 쪽**(B09·화면)이 「코드도 없고 막힘 표시도 없는 멀쩡한 줄」로
# 읽었다 — 금액이 조용히 빠졌다(측구터파기·흙깎기 「굴삭기+브레카」).
# ⇒ 오늘 세운 규칙 「막혔다고 말하기 전에 `blocked_kind` 를 볼 것」의 **뒤집힌 얼굴**:
# 보는 쪽을 고쳤으면 **다는 쪽도** 빠짐없이 달아야 한다.
blocked_kind = mismatch[1] if mismatch else None
blocked_reason = mismatch[2] if mismatch else ""
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)
if blocked_kind is None:
# 시공법을 고르면 풀리는 자리와, 품셈을 아직 못 이은 자리를 **갈라 적는다** —
# 다음에 할 일이 다르다(하나는 사용자 입력, 하나는 매핑 작업).
blocked_kind = BLOCKED_INPUT_MISSING if method_note else BLOCKED_UNIT_DATA_MISSING
blocked_reason = method_note or f"{label} 의 품셈 공종을 아직 못 이었습니다"
rows.append(
{
"work_item_code": code,
"name": group,
# 작업 갈래가 있으면 **규격 칸**에 적는다 — 갈래 축(`ground_class`)이 아니다.
"spec": str(work_kind or 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,
# 토공 줄은 대개 갈래 축이 없다 — 그래도 **칸은 둔다**(계약이 한 모양).
# 매핑이 갈래를 적은 작업 갈래(잡관목제거 → 단목베기 「5m 미만」)만 값을 싣는다.
"variant_axis": (entry or {}).get("variant_axis"),
# 암 갈래(연암·보통암·경암)는 줄 자신의 갈래를 넘김 — 9-4·9-5 단계 합산형(축 C Ⓐ).
"variant_value": (entry or {}).get("variant_value")
or {**(variant_inputs or {}), "ground_class": ground}.get(
str((entry or {}).get("variant_from") or "")
)
or None,
"secondary_axes": None,
"spec_class": None,
"spec_class_basis": "",
# 토공 줄도 막힐 수 있다 — 밑수 어긋남과 **코드 없음** 둘 다 여기 실린다.
"blocked_kind": blocked_kind,
"blocked_reason": blocked_reason,
"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": NOTE_HAUL_IN_SUMMARY
if group in HAUL_SUMMARY_GROUPS
else ("집계 합계 줄 — 검산용" 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})")
# ⚠ 코드가 없으면 **줄에 막힘 표시를 단다**(2026-09-09) — 목록에만 실으면 줄 단위로
# 보는 쪽이 「멀쩡한 줄」로 읽어 금액이 조용히 빠진다(도자운반·덤프운반이 그랬다).
# ⚠ `in_bill` 이 False 인 무대 줄은 **막힌 것이 아니다** — 품에 포함이라 안 세우는 것.
haul_blocked = BLOCKED_UNIT_DATA_MISSING if (code is None and in_bill) else None
haul_blocked_reason = (
f"운반({equipment})의 품셈 공종을 아직 못 이었습니다" if haul_blocked else ""
)
# ⚠⚠ **내역서 수량은 자연상태다** — 유토곡선은 다짐상태로 쌓고(운반거리를 그 기준으로
# 재야 맞는다) 내역에 오르는 수량은 되돌린 값이다(`config_system_design` 5-4-3
# 「운반거리 산정 시 모든 수량은 다짐상태로 환산해 계산하고, **내역서에 적용하는
# 수량은 자연상태로 한다**」). 되돌린 값이 없으면 갈래를 못 붙인 것이라 **다짐 그대로
# 두고 사유를 낸다** — 토사 계수로 눅이면 근거 없이 금액이 움직인다.
compacted = float(row.get("volume_m3") or 0.0)
natural = row.get("natural_m3")
state_note = ""
if isinstance(natural, (int, float)) and float(natural) > 0:
quantity, factor = float(natural), row.get("conversion_c")
state_note = (
f"다짐 {compacted:,.2f}㎥ ÷ C {factor} = 자연상태"
if factor
else f"다짐 {compacted:,.2f}㎥ 을 되돌린 자연상태"
)
else:
quantity = compacted
state_note = (
"⚠ 다짐상태 그대로 — 갈래를 못 붙여 되돌릴 계수가 없음(내역 수량은 자연상태여야 함)"
)
rows.append(
{
"work_item_code": code,
"name": f"{equipment} 운반",
"spec": str(row.get("ground") or ""),
"unit": "㎥",
"quantity": quantity,
# 운반에는 반영률 개념이 없다 — 그래서 `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": state_note,
"composite_parts": None,
"structure_kind": None,
# ⚠⚠ **갈래를 통로로 보낸다**(2026-09-09). 운반 단가가 지반 갈래로 갈리는데
# `spec` 문자열만 보내고 있어 받는 쪽이 갈래를 못 골랐다 — 「버림」에서
# 275,584원이 사라졌던 그 자리와 같다. 갈래는 `variant_value` 한 통로로만.
# ⚠ 이름은 **우리 갈래 그대로**(리핑암) 보낸다 — 일위대가의 「파쇄암」과는
# 인계본의 `ground_class_aliases` 가 이어 준다(이름을 갈면 흙깎기가 어긋난다).
"variant_axis": "ground_class" if row.get("ground") else None,
"variant_value": str(row.get("ground")) if row.get("ground") else None,
"secondary_axes": None,
"spec_class": None,
"spec_class_basis": "",
"blocked_kind": haul_blocked,
"blocked_reason": haul_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,
priced_sheets: list[dict[str, Any]] | None = None,
) -> tuple[list[dict[str, Any]], list[str]]:
"""구조물 줄 — **작업 공종 하나**로 선다.
⚠ 전개 성분(터파기·야면석·모르터)은 여기 오지 않는다. 구조물 한 기가 내역 한 줄이고,
그 전개는 토공 합산·자재총괄·일위대가로 갈린다(8-2 이중계상 함정).
⭐ 양식 일위대가로 셀 장(`priced_sheets`)에 든 구조물은 **호표 `AX-ST` 줄**로 감(PLAN 6장 ②).
"""
from B08_Quantity.B08_Quantity_Engine_StructurePriceLink import by_structure, template_row
priced = by_structure(priced_sheets or [])
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 []
]
# 양식 일위대가로 셀 구조물 — 막힘은 일위대가 표가 가림(전개 갈래 사유는 안 셈).
sheet_entry = priced.get(str(structure.get("structure_id")))
if sheet_entry is not None:
pass
elif 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
# ⚠⚠ **물량 0 을 내역에 세우지 않는다**(2026-09-09 감사). 물넘이포장이 면적을 안 받아
# `0.0 ㎡` 로 서고 있었다 — 코드가 붙어 있어 **0 원 줄**이 만들어지고, 화면에는
# 「값이 있는 줄」로 보인다. 0 은 「없음」과 구별이 안 된다(오늘 표토에서 겪은 자리).
# ⇒ 줄은 그대로 넘기되 **내역에서 빼고 까닭을 적는다.**
in_bill = bill_quantity > 0
zero_reason = ""
if not in_bill:
notes = "; ".join(str(note) for note in (structure.get("notes") or []))
zero_reason = (
f"물량이 0 이라 내역에 안 세움 — {notes}"
if notes
else "물량이 0 이라 내역에 안 세움 — 저장 제원에서 치수·면적을 넣으면 값이 섭니다"
)
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 or (BLOCKED_INPUT_MISSING if not in_bill else None),
"blocked_reason": blocked_reason or zero_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": in_bill,
"in_bill_reason": zero_reason or (composite or {}).get("why", ""),
"origin": ORIGIN_STRUCTURE,
}
)
if sheet_entry is not None:
rows[-1] = template_row(rows[-1], structure, sheet_entry)
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
# ⚠ 준비공·배수관·연장·자재 줄은 700줄 제한으로 `_Handoff_Rows_Prep` 에 갈라 뒀다
# (2026-09-09). 여기서 다시 내보내 부르는 쪽 import 는 그대로 둔다.
from B08_Quantity.B08_Quantity_Engine_Handoff_Rows_Prep import ( # noqa: E402
_length_rows,
_material_rows,
_pipe_rows,
_prep_blocked_kind,
_preparation_rows,
)
__all__ = [
"_length_rows",
"_material_rows",
"_pipe_rows",
"_prep_blocked_kind",
"_preparation_rows",
]