확정 2차 ② 후반 「사용자가 값을 바꿀 수 있게」. 식은 이미 있었고 없던 것은 덮어쓸 칸이었음. - 등록부(돌쌓기 계열 다섯 종류)에 `thickness_top_m`·`thickness_bottom_m` 칸을 둠. 큰돌쌓기는 두께 식이 달라 뺐고 화면에서도 그 장에는 안 뜸. - 빈 칸은 「안 정함」 — 자동값을 저장에 박지 않음. 상부만 넣으면 하부는 그 위에서 식을 이음. - 근거 문구가 어디서 온 값인지 적음(「사용자 입력」 / 「뒷길이 45㎝ + 0.3」). 화면 실측(랩탑_보조, 표준도 3장 돌쌓기(찰)) — 0.60 넣고 저장하니 2개소 반영, 입적 24.375 → 20.625㎥ 로 움직였고 칸을 비워 되돌리니 24.375 로 복귀함. 시험 다섯 추가, 전체 1203 통과·실패 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
231 lines
10 KiB
Python
231 lines
10 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",
|
||
"stone_coeff_basis",
|
||
"fill_concrete_mpa",
|
||
"thickness_top_m",
|
||
"thickness_bottom_m",
|
||
)
|
||
|
||
#: 기초 갈래 — 정본 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": "하부 두께",
|
||
}
|
||
|
||
#: 야면석 계수를 어느 열에서 읽나 — 확정 ⑨ 「품셈 열이 기본, 사용자가 고를 수 있게」.
|
||
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)
|
||
|
||
# 벽 두께 — 비우면 실무 구조물도 식(확정 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
|