Files
Aislo/B07_DesignDetail/B07_DesignDetail_Engine_Standard_Edit.py
T
eomsangdonandClaude Opus 5 2b2906466e feat(B07): 표준도 제원 칸 일곱 완성 — 야면석 계수·채움 강도 + 터파기 상수 한 벌로
⑨ `stone_coeff_basis`(품셈 / 실무 관행) · ⑩ `fill_concrete_mpa`(180 / 210) 를 폼에 붙임.
둘 다 **빈 값이 기본**(품셈 열 · 210) — 값을 미리 적어 두면 「안 정함」이 사라짐.
없는 갈래는 막지 않고 알림. ⇒ 칸 일곱: 돌 종류 · 조달 · 뒷길이 · 기초 · 전면 기울기 ·
야면석 계수 · 채움 강도.

⚠ **그림이 또 죽어 있었음** — `STONE_MASONRY["excavation_extra_m"]` 이 사라져 `KeyError`.
터파기 수가 `common_util_excavation` 으로 옮겨 갔음(내가 정본 xls 에서 낸 값들).
그림도 **그 한 벌**을 읽게 바꿈 — 횡단도와 같은 상수라야 두 도면이 같은 터파기를 그림.
```
WALL_TRENCH_CLEARANCE_M 0.2 · WALL_FOUNDATION_WIDTH_M 0.9 · WALL_FOUNDATION_DEPTH_M 0.5
WALL_BLINDING_WIDTH_M 0.7 · WALL_BLINDING_DEPTH_M 0.1
```
그림에 **기초 칸을 그림** — 「기초유」 0.9×0.5 · 「기초버림」 0.7×0.1.
**안 정한 장은 기초를 안 그림**(지어내지 않음).

⚠ 오늘 두 번째임 — 공용 상수 **이름이 바뀌면 그림이 죽음**. 값이 한 벌인 것과 별개로
이름 변경은 서로 알려야 함.

실화면 확인 — 칸 일곱이 다 서고, 야면석 계수 「실무 관행」·채움 강도 「180」 저장 →
「2개소에 반영했습니다」, 정본에 그 장의 두 개소만 들어감.

자체검증 — 새 시험 3건 + 회귀 586 통과 · 0 실패, `tsc --noEmit` 0.
⚠ 병합 여파로 깨진 등록부 규정 시험을 고침 — 「빈 칸이 곧 기본」인 칸 셋
(`face_slope_ratio`·`stone_coeff_basis`·`fill_concrete_mpa`)을 규정에서 뺌.

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

207 lines
9.2 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",
"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