Files
Aislo/B07_DesignDetail/B07_DesignDetail_Engine_Standard_Edit.py
T
eomsangdonandClaude Opus 5 e128f651fc feat(B07): 표준도 제원에 「기초」 칸 + 그림을 새 두께 식에 맞춤
**기초 칸**(`foundation` — 기초유 / 기초버림, 정본 xls 탭 제목 그대로)
터파기 기초 몫이 0.5×(0.7+0.2)=0.45 대 0.1×(0.7+0.0)=0.07 ㎥/m 로 갈리고, 횡단도
터파기 선도 같은 칸을 봄. 고를 자리가 어디에도 없어 비어 있던 값임.
정본에 없는 갈래를 넣으면 **막지 않고 알림**(기존 네 칸과 같은 결). 비우면 키를 지움.

**그림을 새 두께 식에 맞춤** — 데스크탑 메인이 `STONE_MASONRY` 를 갈면서
`thickness_base_m`·`thickness_top_coeff`·`thickness_bottom_coeff` 가 사라져
그림이 `KeyError` 로 죽었음(도면이 「읽지 못했습니다」로 뜸). 실화면에서 잡았음.
  상부 = 뒷길이 + 0.30 · 하부 = 상부 + 0.30 × (H − 1.0)
  H=1.0 ℓ45 → 0.75/0.75 · H=1.5 ℓ35 → 0.65/0.80 · H=2.0 ℓ35 → 0.65/0.95 ·
  H=2.0 ℓ45 → 0.75/1.05 · H=3.0 ℓ45 → 0.75/1.35   ← 실무 원본과 전부 일치

⚠ **뒷길이를 안 정한 장은 두께를 못 내므로 그리지 않음** — 0 으로 때우면 거짓 그림이 됨.

⚠ 배운 것 — 「그림이 상수를 직접 읽으니 저절로 따라온다」가 **키 이름이 바뀌면 깨진다**는
   것으로 드러남. 값은 한 벌이 맞으나 **이름이 바뀌는 것은 예고가 필요함.**

실화면 확인 — 「표준도 3장」에서 기초 「기초유」 저장 → 「2개소에 반영했습니다」,
정본 실측 `[기초유, null, 기초유, null, null]`(그 장의 두 개소에만).

자체검증 — 새 시험 3건 + 회귀 583 통과 · 0 실패, `tsc --noEmit` 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 21:44:28 +09:00

182 lines
8.1 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.
"""표준도 **입력** — 장 하나의 제원을 고쳐 그 조합의 구조물 전부에 반영한다.
왜 여기가 입력 자리인가 (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",
)
#: 기초 갈래 — 정본 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": "기초",
}
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)})."
)
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