"""표준도 **입력** — 장 하나의 제원을 고쳐 그 조합의 구조물 전부에 반영한다. 왜 여기가 입력 자리인가 (PLAN 4-5b · 2026-09-09) `phase: "detail"` 칸(돌 종류·조달·뒷길이·전면 기울기)을 **그리는 화면이 없었다** (2026-09-09 실측: B06 구조물 폼은 b05 phase 열 칸만 그림). 그 값들이 곧 표준도가 받는 값이고, **장 하나 = 제원 조합 하나**라 여기서 한 번 고치면 그 조합 전부에 걸린다. B06 폼은 「어디에·몇 m」(배치), 표준도는 「어떤 제원」 — 축이 갈린다. ⚠ **자동값을 저장에 박지 않는다.** 빈 칸은 「정한 적 없음」의 뜻이다. 기울기를 비우면 품셈 표준경사 판정이 돌고, 채우면 그 값이 이긴다(확정 ⑨). 그래서 빈 값이 오면 **키를 지운다** — 0 이나 판정값을 적어 두면 그 구별이 사라진다. ⚠ **막지 않는다.** 실무 도면에 `S0.7`·`0.8` 이 실재하는데 품셈 표준경사 범위는 0.20~0.50 이다 (2026-09-09 구조물도 53장 확인). 범위 밖이면 **안내만** 하고 값은 받는다. ⚠ **고치면 장이 갈릴 수 있다** — 그 조합 전부에 같은 값을 넣으므로 장은 통째로 옮겨 가고 쪼개지지 않는다. 한 개소만 다르게 하려면 그 구조물을 따로 고쳐야 하고, 그때 새 조합이 되어 장이 하나 는다(PLAN 4-5b). """ from __future__ import annotations from typing import Any from B05_Profile.B05_Profile_Structures_Schema import StructureInstance #: 표준도에서 받는 칸 — `키 → (이름, 검사)`. 여기 없는 칸은 표준도가 안 만진다. EDITABLE_KEYS: tuple[str, ...] = ( "stone_kind", "stone_supply", "back_len_cm", "face_slope_ratio", "foundation", "stone_coeff_basis", "fill_concrete_mpa", ) #: 기초 갈래 — 정본 xls 탭 제목 그대로(`04.구조도(기슭막이).xls`). #: ⚠ **물량과 그림이 같은 칸을 본다** — 터파기 기초 몫이 0.5×(0.7+0.2)=0.45 대 #: 0.1×(0.7+0.0)=0.07 ㎥/m 로 갈리고, 횡단도 터파기 선도 이 값으로 그려진다. FOUNDATION_CHOICES: tuple[str, ...] = ("기초유", "기초버림") #: 품셈 13-4-3·13-4-4 [주]① 의 일곱 규격. 그 밖의 값은 계수가 없어 물량이 안 선다. BACK_LENGTH_CHOICES: tuple[int, ...] = (25, 30, 35, 45, 55, 60, 75) #: 품셈 표준경사 표가 덮는 범위. **막는 선이 아니라 안내 선**이다. SLOPE_TABLE_RANGE: tuple[float, float] = (0.20, 0.50) #: 안내 문구에 쓸 사람 말. FIELD_LABELS: dict[str, str] = { "stone_kind": "돌 종류", "stone_supply": "조달", "back_len_cm": "뒷길이", "face_slope_ratio": "전면 기울기", "foundation": "기초", "stone_coeff_basis": "야면석 계수", "fill_concrete_mpa": "채움 강도", } #: 야면석 계수를 어느 열에서 읽나 — 확정 ⑨ 「품셈 열이 기본, 사용자가 고를 수 있게」. STONE_COEFF_CHOICES: tuple[str, ...] = ("품셈", "실무 관행") #: 채움 콘크리트 강도(MPa) — 확정 2차 ⑩ 「기본 210, 고를 수 있게」. #: 180 은 국가기준 하한(돌쌓기 전용), 210 은 콘크리트 구조물 몸체 쪽 기준. FILL_CONCRETE_CHOICES: tuple[str, ...] = ("180", "210") def _clean_slope(value: Any) -> tuple[float | None, str | None]: """전면 기울기 — `(값, 안내)`. 비면 `(None, None)` 이고 그것이 「자동」의 뜻이다.""" if value in (None, ""): return None, None try: ratio = float(value) except (TypeError, ValueError): return None, f"전면 기울기 「{value}」를 숫자로 읽지 못했습니다 — 비워 두면 자동입니다." if ratio <= 0: return None, "전면 기울기는 0보다 커야 합니다 — 비워 두면 자동입니다." low, high = SLOPE_TABLE_RANGE if not (low <= ratio <= high): return ratio, ( f"1:{ratio:g} 는 품셈 표준경사 표 범위(1:{low:g}~1:{high:g}) 밖입니다 — " "실무 도면에 1:0.7·1:0.8 이 실재하므로 값은 그대로 씁니다." ) return ratio, None def clean_spec(spec: dict[str, Any]) -> tuple[dict[str, Any], list[str]]: """받은 제원을 **저장할 꼴**로 다듬는다 — `(고칠 값, 안내 문구)`. 값이 `None`(또는 빈 문자열)이면 **그 키를 지우라**는 뜻으로 `None` 을 담아 돌려준다. """ cleaned: dict[str, Any] = {} notes: list[str] = [] if "stone_kind" in spec: kind = spec.get("stone_kind") cleaned["stone_kind"] = str(kind) if kind not in (None, "") else None if "stone_supply" in spec: supply = spec.get("stone_supply") cleaned["stone_supply"] = str(supply) if supply not in (None, "") else None if "back_len_cm" in spec: raw = spec.get("back_len_cm") if raw in (None, ""): cleaned["back_len_cm"] = None else: try: back = int(float(raw)) except (TypeError, ValueError): back = None notes.append(f"뒷길이 「{raw}」를 숫자로 읽지 못했습니다.") if back is not None: cleaned["back_len_cm"] = back if back not in BACK_LENGTH_CHOICES: notes.append( f"뒷길이 {back}㎝ 는 품셈 표(25·30·35·45·55·60·75㎝)에 없어 " "물량이 서지 않습니다." ) if "foundation" in spec: found = spec.get("foundation") cleaned["foundation"] = str(found) if found not in (None, "") else None if cleaned["foundation"] and cleaned["foundation"] not in FOUNDATION_CHOICES: notes.append( f"기초 「{cleaned['foundation']}」는 정본에 없는 갈래입니다 " f"(있는 것: {' · '.join(FOUNDATION_CHOICES)})." ) for key, choices in ( ("stone_coeff_basis", STONE_COEFF_CHOICES), ("fill_concrete_mpa", FILL_CONCRETE_CHOICES), ): if key not in spec: continue raw = spec.get(key) cleaned[key] = str(raw) if raw not in (None, "") else None if cleaned[key] and cleaned[key] not in choices: notes.append( f"{FIELD_LABELS[key]} 「{cleaned[key]}」는 없는 갈래입니다 " f"(있는 것: {' · '.join(choices)})." ) if "face_slope_ratio" in spec: ratio, note = _clean_slope(spec.get("face_slope_ratio")) cleaned["face_slope_ratio"] = ratio if note: notes.append(note) return cleaned, notes def drop_unregistered( type_id: str, spec: dict[str, Any], allowed: set[str] ) -> tuple[dict[str, Any], list[str]]: """등록부에 **칸이 없는** 제원은 빼고 알린다 — `(남긴 값, 안내)`. ⚠ 저장소가 「정의되지 않은 옵션」을 거절하므로, 그대로 넘기면 **한 칸 때문에 전부** 저장이 안 된다(2026-09-09 실측: `face_slope_ratio` 가 등록부에 없어 저장 전체 실패). 한 칸을 못 받는 것과 아무것도 못 받는 것은 다르다 — 나머지는 살리고 **못 받은 칸을 이름으로 말한다**. 조용히 버리면 사용자는 저장된 줄 안다. """ kept: dict[str, Any] = {} notes: list[str] = [] for key, value in spec.items(): if key in allowed: kept[key] = value continue if value is None: # 지우라는 뜻인데 칸 자체가 없다 — 이미 없으므로 조용히 넘어간다. continue notes.append( f"「{FIELD_LABELS.get(key, key)}」 칸이 {type_id} 등록부에 아직 없어 " "저장하지 못했습니다 — 다른 칸은 저장했습니다." ) return kept, notes def apply_spec( structures: list[StructureInstance], member_ids: set[str], spec: dict[str, Any] ) -> tuple[list[StructureInstance], int]: """그 장에 속한 구조물마다 제원을 갈아 끼운다 — `(새 목록, 바뀐 개소 수)`. ⚠ **개소 id 로 고른다** — 장 이름(`sheet_key`)을 여기서 다시 셈하지 않는다. 그 이름은 B08 이 편 결과(`height_m` 이 위로 올라온 꼴) 위에서 나오는데, 정본 `StructureInstance` 는 높이가 `options` 안에 있어 **같은 이름이 안 나온다**. 장 목록이 이미 `members[].structure_id` 를 실어 주므로 그것을 그대로 쓴다. """ changed = 0 out: list[StructureInstance] = [] for item in structures: payload = item.model_dump() if str(payload.get("structure_id") or "") not in member_ids: out.append(item) continue options = dict(payload.get("options") or {}) for key, value in spec.items(): if value is None: # ⚠ 지운다 — 「정한 적 없음」과 「그 값으로 정함」이 구별돼야 한다. options.pop(key, None) else: options[key] = value payload["options"] = options out.append(StructureInstance.model_validate(payload)) changed += 1 return out, changed