**기초 칸**(`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>
182 lines
8.1 KiB
Python
182 lines
8.1 KiB
Python
"""표준도 **입력** — 장 하나의 제원을 고쳐 그 조합의 구조물 전부에 반영한다.
|
||
|
||
왜 여기가 입력 자리인가 (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
|