- 전개가 structures.json 만 읽던 구멍 — pipe_points.json 도 읽음(관 자체는 관 줄이 셈) - 관 부설(품셈 12-11 m당)에 기슭막이 몫 없음 → 기슭막이는 전개 한 곳에서만 · 사유에 적음 - 안 적힌 벽 칸은 등록부 기본값(횡단도 그림과 같은 값) + 「기본값으로 섰음」 사유 - 구조물 목록에 남은 관 지점 종류 옛 저장분은 안 셈(두 번 방지) - 기슭막이 공종 = 형태로 돌쌓기 찰·메 코드 · 독립 기슭막이 한쪽 설치에 두 칸이 다르면 사유만 - 산출식 없는 세월교가 「터파기 줄 없음」 거짓 사유를 안 내게 · 구조물도 제원 저장이 관 지점 시설엔 안 먹힘을 알림 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
627 lines
34 KiB
Python
627 lines
34 KiB
Python
"""인계 줄을 만드는 자리 — 토공·운반·구조물·준비공·배수관·연장·타설 (`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 entry.get("class_from") == "form":
|
||
# 기슭막이 — **형태**가 공종을 가름(돌쌓기 찰·메). 그 밖 형태는 전개 사유가 막음.
|
||
form = str((structure.get("options") or {}).get("form") or "").strip()
|
||
code = (entry.get("form_codes") or {}).get(form)
|
||
class_basis = f"형태 「{form}」 → {code}" if code else ""
|
||
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",
|
||
]
|