Files
Aislo/B07_DesignDetail/B07_DesignDetail_Engine_Standard_Sheet.py
T
eomsangdonandClaude Opus 5 dd8c86fa49 feat(B07): 표준도 그림이 안 서는 장에 까닭을 적음 + 큰돌쌓기는 그림 대상에서 뺌
계획서 4-12. **빈 자리를 그냥 두면 사용자가 「고장인가」로 읽음** — 장마다 왜 그림이
없는지와 **무엇을 받아야 서는지**를 표 위에 한 줄 적음.
- 돌쌓기(찰) — 「뒷길이를 고르면 그림이 섭니다」 · 옹벽 — 「단면이 달라 아직 안 그림」

⚠ **큰돌쌓기를 `FIGURE_TYPE_IDS` 에서 뺌** — 그림은 두께를 뒷길이에서 내는데 큰돌쌓기는
규격이 **직경**이라 목록에만 있고 결과는 늘 빈 그림이었음(품셈 13-6 직경 ↔ 13-4 뒷길이,
수량 쪽에서 이미 갈라 둔 자리). **식은 만들지 않음** — 「직경에서 두께를 내는 법」은
도메인 판단이라 사용자 몫이고, 지금은 사유만 띄움.

⚠ 곁가지 하나를 화면에서 잡아 함께 고침 — 그림 대상 목록을 **기울기 판정**에도 쓰고 있어
큰돌쌓기를 빼자 **장 제목의 「1:0.3」까지 사라졌음**. `SLOPE_TYPE_IDS` 로 갈라 되살림.

화면 실측 — 표준도 1장에 「그림 없음 — 큰돌쌓기는 규격이 직경이라 …」 한 줄이 표 위에 뜸.
시험 다섯 추가, 전체 1225 통과·실패 0.

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

212 lines
9.8 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.
"""표준도(구조물도) 하단표 — **장 나눔**과 **표 조판**.
실무 원본은 울진소광 `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 _slope_or_none(structure: dict[str, Any]) -> float | None:
"""돌쌓기 계열이면 판정된 전면 기울기, 아니면 `None`. 판정은 그림 모듈이 가진 한 벌."""
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardFigure import (
SLOPE_TYPE_IDS,
slope_of,
)
# ⚠ **그림 대상 목록을 쓰지 않는다** — 큰돌쌓기는 그림은 못 그려도 기울기는 판정된다.
if str(structure.get("type_id") or "") not in SLOPE_TYPE_IDS:
return None
return slope_of(structure)[0]
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 = _slope_or_none(structure)
if slope is not None:
parts.append(f"1:{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], section_modes: dict[float, str] | None = None
) -> dict[str, Any]:
"""B08 원단위 전개(`build_unit_table` 결과)를 **표준도 장 목록**으로 접는다.
⚠ 계산을 다시 하지 않는다 — 들어온 전개를 제원 조합으로 묶고 단위당으로 나눌 뿐이다.
"""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import _section_mode_at
from common_util.common_util_structure_face_role import structure_face_role
groups: dict[str, dict[str, Any]] = {}
for structure in unit_table.get("structures") or []:
# ⚠ 성절토는 **표를 만든 그 판정**을 그대로 물고 온다 — 제목·그림이 다시 판정하면
# 근거를 못 받아 종전값으로 떨어져 **표와 갈린다**(2026-09-09 실측: 표 1:0.35 ·
# 제목 1:0.3). 자리를 고르는 것도 B08 이 쓰는 그 함수를 쓴다.
mode = _section_mode_at(structure, section_modes)
face, face_reason = structure_face_role(mode, (structure.get("options") or {}).get("side"))
structure = {**structure, "face": face, "face_reason": face_reason}
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),
# 판정 근거 — 제목·그림이 표와 **같은 기울기**를 쓰게 하는 열쇠.
"face": structure.get("face"),
"face_reason": structure.get("face_reason") or "",
# 실무 시트 머리의 「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 []),
}