"""표준도(구조물도) 하단표 — **장 나눔**과 **표 조판**. 실무 원본은 울진소광 `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 []), }