사용자 확정 5번(2026-09-09 「진행해줘」)의 1단계. 실무 원본 `07-구조도-소광리.xlsx`
53탭을 펴서 표 짜임을 확정한 뒤, B08 원단위 전개를 그 모양으로 접는 통로를 냄.
⚠ 계산은 새로 만들지 않음 — `build_unit_table` 결과를 제원 조합으로 묶고 단위당으로
나눌 뿐임(계산 자리는 한 곳, CLAUDE.md 5장).
장 나눔 축 = 제원 조합(`type_id` + 높이 + 제원 전부). 확정 ⑨ 대로 기울기가 바뀌면 장이
갈림 — 기울기가 제원의 한 칸이라 저절로 지켜짐. 측점·방향처럼 개소마다 다를 뿐인 칸은
장을 안 가름(`PER_PLACE_OPTION_KEYS`).
선행 확인 두 건도 끝냄
· 8-3 틈메우기돌 — 53탭 전수에서 `틈메우기`·`틈막이`·`사춤` 0건 ⇒ 넣지 않음
· 8-2 버림 콘크리트 — 실무에 있음(「바닥 10㎝ 이상 콘크리트(버림)」 · 옹벽 버림CON'C),
KDS 44 90 00 의 100㎜ 와 같음
⚠ 만들다 잡음 — `billing_unit` 이 비어 있으면 「단위 미정」이 아니라 「m·연장」이라는
계약이 있었음(`StructureQuantity`). 미정으로 읽어 돌쌓기가 전부 단위 없는 장이 됐음.
⚠ 실무 시트와 어긋나는 줄 일곱은 PLAN 4-12 에 표로 남기고 확정 대기로 둠 —
임의로 맞추지 않음(터파기 0.35 대 2.30 이 가장 큼).
자체검증 — 새 시험 11건 통과, 회귀 562 통과 · 0 실패.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
183 lines
8.1 KiB
Python
183 lines
8.1 KiB
Python
"""표준도(구조물도) 하단표 — **장 나눔**과 **표 조판**.
|
||
|
||
실무 원본은 울진소광 `07-구조도-소광리.xlsx`(보이는 22탭 + 숨긴 31탭). **탭 하나 = 장 하나**이고
|
||
탭 안은 「위 치수조서 + 아래 수량산출서」다. 이 모듈이 만드는 것은 **아래쪽 표**다.
|
||
|
||
실무 표의 짜임(돌쌓기 계열 네 탭 실측, 2026-09-09)::
|
||
|
||
(단위 표시) m당
|
||
공종 | 산 출 근 거 | 수량 | 단위
|
||
1 | 면적 2 × 1 × 1.044 2.088 ㎡
|
||
...
|
||
11 | 잔토정리 터파기 − 되메우기 0.165 ㎥
|
||
|
||
찰쌓기는 11줄, 메쌓기는 8줄(채움콘크리트·모르터·물구멍이 빠짐)이다. 우리 B08 전개가 이미
|
||
줄마다 `name · unit · amount · basis` 를 내므로 **여기서 새로 계산하지 않는다** — 접어서 낼 뿐이다.
|
||
|
||
⚠ **장 나눔 축은 「제원 조합」이다**(2026-09-09 사용자 확정 ⑨). 같은 종류라도 높이·기울기·
|
||
뒷길이·돌 종류가 다르면 그림도 수량도 달라지므로 **장이 갈린다**. 기울기가 제원의 한 칸이라
|
||
「기울기가 바뀌면 장이 갈린다」는 확정이 저절로 지켜진다.
|
||
|
||
⚠ **단위당 값**은 성분 수량을 `billing_quantity` 로 나눈 것이다. 실무 시트 머리의 `m당` ·
|
||
`개소당` · `㎡당` 이 그 단위이고, **구조물마다 다르다**(통일하지 않음 — 4-1).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from typing import Any
|
||
|
||
#: 장을 가르지 **않는** 제원 칸 — 개소마다 다를 뿐 그림·단위수량을 안 바꾼다.
|
||
#: 여기 없는 칸은 전부 장 나눔에 들어간다(모르는 칸을 빠뜨려 두 장이 한 장으로 합쳐지는 것보다,
|
||
#: 장이 하나 더 서는 쪽이 안전하다 — 합쳐지면 값이 조용히 틀린다).
|
||
PER_PLACE_OPTION_KEYS: frozenset[str] = frozenset(
|
||
{
|
||
"start_m",
|
||
"end_m",
|
||
"station",
|
||
"station_m",
|
||
"side",
|
||
"length_m",
|
||
"note",
|
||
"memo",
|
||
"label",
|
||
}
|
||
)
|
||
|
||
|
||
def billing_of(structure: dict[str, Any]) -> tuple[str, float]:
|
||
"""이 장이 설 **단위와 그 수량**.
|
||
|
||
⚠ `billing_unit` 이 비어 있으면 「단위가 없다」가 아니라 **「m · 연장」**이라는 뜻이다
|
||
(`StructureQuantity` 계약). 관측 원단위가 「개소당」·「㎡당」인 종류만 자기 단위를 채운다.
|
||
이것을 「미정」으로 읽으면 **돌쌓기처럼 흔한 종류가 전부 단위 없는 장**이 된다.
|
||
"""
|
||
unit = str(structure.get("billing_unit") or "")
|
||
if unit:
|
||
return unit, float(structure.get("billing_quantity") or 0.0)
|
||
return "m", float(structure.get("length_m") or 0.0)
|
||
|
||
|
||
def _sheet_options(structure: dict[str, Any]) -> dict[str, Any]:
|
||
"""장 나눔에 쓰는 제원만 남긴다."""
|
||
options = structure.get("options") or {}
|
||
return {
|
||
key: value for key, value in sorted(options.items()) if key not in PER_PLACE_OPTION_KEYS
|
||
}
|
||
|
||
|
||
def sheet_key(structure: dict[str, Any]) -> str:
|
||
"""제원 조합 하나를 가리키는 이름. 같은 값이면 같은 장이다."""
|
||
payload = {
|
||
"type_id": structure.get("type_id") or "",
|
||
"height_m": round(float(structure.get("height_m") or 0.0), 3),
|
||
"options": _sheet_options(structure),
|
||
}
|
||
return json.dumps(payload, ensure_ascii=False, sort_keys=True)
|
||
|
||
|
||
def sheet_title(structure: dict[str, Any]) -> str:
|
||
"""장 제목 — 「이름 H=2.0 1:0.3 뒷길이 45㎝」처럼 **무엇이 갈랐는지**가 보이게."""
|
||
parts = [str(structure.get("name") or structure.get("type_id") or "구조물")]
|
||
height = float(structure.get("height_m") or 0.0)
|
||
if height > 0:
|
||
parts.append(f"H={height:g}")
|
||
options = structure.get("options") or {}
|
||
slope = options.get("face_slope_ratio")
|
||
if slope is not None:
|
||
parts.append(f"1:{float(slope):g}")
|
||
back = options.get("back_len_cm") or options.get("stone_back_length_cm")
|
||
if back:
|
||
parts.append(f"뒷길이 {int(back)}㎝")
|
||
kind = options.get("stone_kind")
|
||
if kind:
|
||
parts.append(str(kind))
|
||
return " ".join(parts)
|
||
|
||
|
||
def _unit_amount(amount: float, quantity: float) -> float | None:
|
||
"""단위당 값. 셀 단위를 못 정했으면 **0 으로 나누지 않고 `None`** 을 낸다."""
|
||
if quantity <= 0:
|
||
return None
|
||
return amount / quantity
|
||
|
||
|
||
def _rows_of(structure: dict[str, Any]) -> list[dict[str, Any]]:
|
||
"""구조물 하나의 성분을 표 줄로 접는다 — 실무 시트의 `공종 | 산출근거 | 수량 | 단위`."""
|
||
_unit, quantity = billing_of(structure)
|
||
rows: list[dict[str, Any]] = []
|
||
for index, component in enumerate(structure.get("components") or [], start=1):
|
||
amount = float(component.get("amount") or 0.0)
|
||
rows.append(
|
||
{
|
||
"no": index,
|
||
"name": component.get("name") or "",
|
||
# 실무 시트의 「산출근거」 칸 — B08 이 이미 사람이 읽는 문구로 낸다.
|
||
"basis": component.get("basis") or "",
|
||
"unit_amount": _unit_amount(amount, quantity),
|
||
"amount": amount,
|
||
"unit": component.get("unit") or "",
|
||
# 값이 식에서 나왔나(derived) 관측 원단위표에서 왔나(observed) — 되짚기용.
|
||
"basis_kind": component.get("basis_kind") or "",
|
||
"source": component.get("source") or "",
|
||
}
|
||
)
|
||
return rows
|
||
|
||
|
||
def build_standard_sheets(unit_table: dict[str, Any]) -> dict[str, Any]:
|
||
"""B08 원단위 전개(`build_unit_table` 결과)를 **표준도 장 목록**으로 접는다.
|
||
|
||
⚠ 계산을 다시 하지 않는다 — 들어온 전개를 제원 조합으로 묶고 단위당으로 나눌 뿐이다.
|
||
"""
|
||
groups: dict[str, dict[str, Any]] = {}
|
||
for structure in unit_table.get("structures") or []:
|
||
key = sheet_key(structure)
|
||
sheet = groups.get(key)
|
||
if sheet is None:
|
||
unit, _quantity = billing_of(structure)
|
||
sheet = {
|
||
"key": key,
|
||
"title": sheet_title(structure),
|
||
"type_id": structure.get("type_id") or "",
|
||
"height_m": float(structure.get("height_m") or 0.0),
|
||
"options": _sheet_options(structure),
|
||
# 실무 시트 머리의 「m당」·「개소당」·「㎡당」. 종류마다 다르다 — 통일하지 않는다.
|
||
"unit_label": f"{unit}당",
|
||
"billing_unit": unit,
|
||
"rows": _rows_of(structure),
|
||
"members": [],
|
||
"notes": list(structure.get("notes") or []),
|
||
}
|
||
groups[key] = sheet
|
||
sheet["members"].append(
|
||
{
|
||
"structure_id": structure.get("structure_id"),
|
||
"name": structure.get("name") or "",
|
||
"start_m": structure.get("start_m"),
|
||
"end_m": structure.get("end_m"),
|
||
"length_m": float(structure.get("length_m") or 0.0),
|
||
"billing_quantity": billing_of(structure)[1],
|
||
}
|
||
)
|
||
for note in structure.get("notes") or []:
|
||
if note not in sheet["notes"]:
|
||
sheet["notes"].append(note)
|
||
|
||
sheets = sorted(groups.values(), key=lambda sheet: (sheet["type_id"], sheet["height_m"]))
|
||
for sheet in sheets:
|
||
sheet["member_count"] = len(sheet["members"])
|
||
sheet["billing_total"] = sum(member["billing_quantity"] for member in sheet["members"])
|
||
# ⚠ 단위당을 못 낸 줄 — 「값이 없다」를 숫자 0 으로 때우지 않고 이름으로 드러낸다.
|
||
sheet["unpriced_rows"] = [
|
||
row["name"] for row in sheet["rows"] if row["unit_amount"] is None
|
||
]
|
||
|
||
return {
|
||
"sheets": sheets,
|
||
"sheet_count": len(sheets),
|
||
# 왜 안 실렸는지 — 화면이 「구조물이 없다」와 「걸러졌다」를 가릴 수 있어야 한다.
|
||
"structure_count": int(unit_table.get("structure_count") or 0),
|
||
"pending_choices": list(unit_table.get("pending_choices") or []),
|
||
}
|