Files
Aislo/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py
T
eomsangdonandClaude Opus 5 036da3f9f9 refactor(B08): 인계 줄 파일 700줄 분리 — 준비공·배수관·연장·자재를 _Rows_Prep 로
777줄이던 _Handoff_Rows 를 565 + 255 로 가름. 줄의 모양은 안 바뀜 —
갈라낸 넷을 본체에서 다시 내보내 부르는 쪽 import 도 그대로임.
상태 낱말은 _Preparation_Status 한 벌에서 읽음.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 13:00:21 +09:00

566 lines
30 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,
) -> 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
# ⚠ `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) 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
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,
# 작업 갈래가 있으면 **규격 칸**에 적는다 — 갈래 축(`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,
# 토공·운반 줄에는 갈래 축이 없다 — 그래도 **칸은 둔다**(계약이 한 모양).
"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": 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})")
# ⚠⚠ **내역서 수량은 자연상태다** — 유토곡선은 다짐상태로 쌓고(운반거리를 그 기준으로
# 재야 맞는다) 내역에 오르는 수량은 되돌린 값이다(`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": 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
# ⚠ 준비공·배수관·연장·자재 줄은 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",
]