Files
Aislo/B08_Quantity/B08_Quantity_Engine_StructureSheet_Edit.py
eomsangdonandClaude Opus 5 f20213592c feat(b08): 구조물도 엔진·창구·화면 조각을 B07 에서 B08 로 이관
- 장 나눔·제원 입력 엔진과 기울기 판정 대상을 B08 로 옮김
- 창구를 /quantity/structure-sheets 로 옮기고 기초잡석 두께·지반 갈래를 함께 넘김
  (옛 B07 창구는 두께를 안 넘겨 원단위 탭과 값이 갈렸음)
- 구조물도 탭 조각 renderStructureSheets 신설 — 탭 등록은 브레인 몫이라 안 붙임
- B07 표준도 목록은 탭 배선 날까지 B08 엔진·창구를 불러 그대로 둠

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-13 14:48:13 +09:00

240 lines
11 KiB
Python
Raw Permalink 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.
"""표준도 **입력** — 장 하나의 제원을 고쳐 그 조합의 구조물 전부에 반영한다.
왜 여기가 입력 자리인가 (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).
⚠ **2026-09-13 B07 에서 이관**(PLAN 3장). 옛 자리 `B07_DesignDetail_Engine_Standard_Edit.py`.
"""
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",
"thickness_top_m",
"thickness_bottom_m",
"blinding_concrete",
)
#: 기초 갈래 — 정본 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": "채움 강도",
"thickness_top_m": "상부 두께",
"thickness_bottom_m": "하부 두께",
"blinding_concrete": "버림 콘크리트",
}
#: 야면석 계수를 어느 열에서 읽나 — 확정 ⑨ 「품셈 열이 기본, 사용자가 고를 수 있게」.
STONE_COEFF_CHOICES: tuple[str, ...] = ("품셈", "실무 관행")
#: 채움 콘크리트 강도(MPa) — 확정 2차 ⑩ 「기본 210, 고를 수 있게」.
#: 180 은 국가기준 하한(돌쌓기 전용), 210 은 콘크리트 구조물 몸체 쪽 기준.
FILL_CONCRETE_CHOICES: tuple[str, ...] = ("180", "210")
#: 버림 콘크리트 넣음/안 넣음 — 확정 ⑭ 「기본은 넣고, 빼고 싶으면 뺀다」(두께는 KDS 100㎜).
#: ⚠ 문구는 B08 `wants_blinding` 이 「빼기」로 읽는 말과 같아야 한다 — 다르면 골라도 안 빠진다.
BLINDING_CHOICES: tuple[str, ...] = ("넣음", "안 넣음")
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),
("blinding_concrete", BLINDING_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)
# 벽 두께 — 비우면 실무 구조물도 식(확정 2차 ② 후반). 값을 넣으면 그 값이 이긴다.
for key in ("thickness_top_m", "thickness_bottom_m"):
if key not in spec:
continue
raw = spec.get(key)
if raw in (None, ""):
cleaned[key] = None
continue
try:
value = float(raw)
except (TypeError, ValueError):
notes.append(
f"{FIELD_LABELS[key]}{raw}」를 숫자로 읽지 못했습니다 — 비우면 식으로 돕니다."
)
continue
if value <= 0:
notes.append(f"{FIELD_LABELS[key]}는 0보다 커야 합니다 — 비우면 식으로 돕니다.")
continue
cleaned[key] = value
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