diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index c50c1f5c..8f0adb09 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -245,3 +245,45 @@ export function resetFrameTemplate(projectId: string): Promise { method: "DELETE", }); } + +/** 표준도 장 목록 — 제원 입력 칸이 쓰는 것만 추린 꼴. */ +export interface StandardSheetsResponse { + status: string; + sheet_count: number; + structure_count: number; + sheets: { + key: string; + title: string; + type_id: string; + member_count: number; + options: Record; + }[]; +} + +export function fetchStandardSheets(projectId: string): Promise { + return requestJson(`/projects/${projectId}/standard-sheets`); +} + +/** 장 하나의 제원 저장 — 빈 값(null)은 **그 칸을 지우라**는 뜻이다. */ +export function putStandardSheetSpec( + projectId: string, + body: { + sheet_key: string; + base_revision: number; + stone_kind: string | null; + stone_supply: string | null; + back_len_cm: string | null; + face_slope_ratio: string | null; + }, +): Promise<{ status: string; revision: number; changed: number; notes: string[] }> { + return requestJson(`/projects/${projectId}/standard-sheets/spec`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +/** 구조물 정본 판번호 — 제원을 저장할 때 함께 보내야 다른 창 덮어쓰기를 막는다. */ +export function fetchStructureRevision(projectId: string): Promise<{ revision: number }> { + return requestJson(`/projects/${projectId}/route/structures`); +} diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Standard_Edit.py b/B07_DesignDetail/B07_DesignDetail_Engine_Standard_Edit.py new file mode 100644 index 00000000..71b43431 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Standard_Edit.py @@ -0,0 +1,160 @@ +"""표준도 **입력** — 장 하나의 제원을 고쳐 그 조합의 구조물 전부에 반영한다. + +왜 여기가 입력 자리인가 (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") + +#: 품셈 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": "전면 기울기", +} + + +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 "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 diff --git a/B07_DesignDetail/B07_DesignDetail_Router.py b/B07_DesignDetail/B07_DesignDetail_Router.py index c972e108..953548e1 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router.py +++ b/B07_DesignDetail/B07_DesignDetail_Router.py @@ -262,47 +262,6 @@ async def _designs_by_chainage(route_id: int) -> dict[int, dict[str, Any]]: } -@router.get("/{project_id}/standard-sheets") -async def get_standard_sheets(project_id: UUID) -> JSONResponse: - """표준도(구조물도) **장 목록 + 하단표**. - - ⚠ 수량을 여기서 새로 셈하지 않는다 — B08 원단위 전개를 그대로 받아 **제원 조합으로 묶고 - 단위당으로 접기만** 한다(계산 자리는 한 곳, CLAUDE.md 5장). - """ - from B07_DesignDetail.B07_DesignDetail_Engine_Standard_Sheet import build_standard_sheets - from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit_table - from B08_Quantity.B08_Quantity_Router_Material import _collect_structures - - try: - pool = get_db_pool() - async with pool.acquire() as connection: - stored_path = await get_project_storage_relative_path(connection, project_id) - project_root = str(Path(resolve_stored_project_path(stored_path)).resolve()) - except Exception: - logger.exception("B07 표준도 조회 실패(경로): project_id=%s", project_id) - return JSONResponse( - status_code=404, - content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."}, - ) - - try: - structures, names, skipped = await asyncio.to_thread(_collect_structures, project_root) - unit_table = await asyncio.to_thread(build_unit_table, structures, names) - except Exception: - logger.exception("B07 표준도 전개 실패: project_id=%s", project_id) - return JSONResponse( - status_code=500, - content={"status": "error", "message": "구조물 원단위를 전개하지 못했습니다."}, - ) - - payload = build_standard_sheets(unit_table) - payload["status"] = "success" - payload["project_id"] = str(project_id) - # 왜 안 실렸는지 — 「구조물이 없다」와 「걸러졌다」를 화면이 가릴 수 있어야 한다. - payload["skipped_structures"] = skipped - return JSONResponse(content=payload) - - @router.get("/{project_id}/design-drawings", response_model=DesignDrawingListResponse) async def get_design_drawing_list( project_id: UUID, diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Standard.py b/B07_DesignDetail/B07_DesignDetail_Router_Standard.py new file mode 100644 index 00000000..0c6c4625 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Router_Standard.py @@ -0,0 +1,153 @@ +"""B07 표준도(구조물도) 라우터 — 장 목록 조회와 **제원 입력**. + +표준도는 도면이자 **입력 화면**이다(PLAN 4-5b). `phase: "detail"` 칸(돌 종류·조달·뒷길이· +전면 기울기)을 그리는 화면이 없어(2026-09-09 실측) 그 자리를 여기가 맡는다. +**장 하나 = 제원 조합 하나**라 한 번 고치면 그 조합의 개소 전부에 걸린다. + +⚠ 값을 여기서 셈하지 않는다 — 정본(`structures.json`)에 적기만 하고 표·그림은 다음 조회에서 + 그 정본으로 다시 선다. 표준도가 두 번째 정본이 되면 안 된다(CLAUDE.md 5장). +""" + +import asyncio +import logging +from pathlib import Path +from uuid import UUID + +from fastapi import APIRouter +from fastapi.responses import JSONResponse +from pydantic import BaseModel, ConfigDict, Field + +from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from common_util.common_util_storage import resolve_stored_project_path +from config.config_db import get_db_pool + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/projects", tags=["B07 Design Detail"]) + + +class StandardSheetSpecRequest(BaseModel): + """표준도 장 하나의 제원. **빈 값(null)은 「정한 적 없음」**이라 그 칸을 지운다.""" + + model_config = ConfigDict(extra="forbid") + + sheet_key: str + base_revision: int = Field(ge=0) + stone_kind: str | None = None + stone_supply: str | None = None + back_len_cm: int | str | None = None + face_slope_ratio: float | str | None = None + + +@router.put("/{project_id}/standard-sheets/spec") +async def put_standard_sheet_spec( + project_id: UUID, payload: StandardSheetSpecRequest +) -> JSONResponse: + """장 하나의 제원을 고쳐 **그 조합의 구조물 전부**에 반영한다. + + ⚠ 값을 여기서 셈하지 않는다 — 정본(`structures.json`)에 적기만 하고, 표·그림은 다음 + 조회에서 그 정본으로 다시 선다. 표준도가 두 번째 정본이 되면 안 된다(CLAUDE.md 5장). + """ + from B05_Profile.B05_Profile_Structures_Repository import load_structures, save_structures + from B05_Profile.B05_Profile_Structures_Schema import structure_type_map + from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardSheet import standard_payload + from B07_DesignDetail.B07_DesignDetail_Engine_Standard_Edit import ( + apply_spec, + clean_spec, + drop_unregistered, + ) + + try: + pool = get_db_pool() + async with pool.acquire() as connection: + stored_path = await get_project_storage_relative_path(connection, project_id) + project_root = Path(resolve_stored_project_path(stored_path)).resolve() + except Exception: + logger.exception("B07 표준도 제원 저장 실패(경로): project_id=%s", project_id) + return JSONResponse( + status_code=404, + content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."}, + ) + + sheets = (await asyncio.to_thread(standard_payload, project_root)).get("sheets") or [] + picked = next((s for s in sheets if s.get("key") == payload.sheet_key), None) + if picked is None: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "그 표준도 장을 찾지 못했습니다."}, + ) + member_ids = { + str(m.get("structure_id")) for m in picked.get("members") or [] if m.get("structure_id") + } + + spec, notes = clean_spec(payload.model_dump(exclude={"sheet_key", "base_revision"})) + # ⚠ 등록부에 없는 칸은 저장소가 거절한다 — 한 칸 때문에 **전부** 못 저장되지 않게 거른다. + type_id = str(picked.get("type_id") or "") + definition = structure_type_map().get(type_id) + allowed = {field.key for field in definition.options} if definition else set() + spec, missing = drop_unregistered(type_id, spec, allowed) + notes.extend(missing) + try: + revision, stored = await asyncio.to_thread(load_structures, str(project_root)) + updated, changed = apply_spec(stored, member_ids, spec) + new_revision = await asyncio.to_thread( + save_structures, str(project_root), updated, base_revision=payload.base_revision + ) + except Exception as exc: + logger.exception("B07 표준도 제원 저장 실패: project_id=%s", project_id) + return JSONResponse( + status_code=409, + content={"status": "error", "message": f"제원을 저장하지 못했습니다 — {exc}"}, + ) + + return JSONResponse( + content={ + "status": "success", + "project_id": str(project_id), + "revision": new_revision, + "previous_revision": revision, + "changed": changed, + # 범위 밖 값·표에 없는 규격은 **막지 않고 알린다**(실무에 1:0.7 이 실재). + "notes": notes, + } + ) + + +@router.get("/{project_id}/standard-sheets") +async def get_standard_sheets(project_id: UUID) -> JSONResponse: + """표준도(구조물도) **장 목록 + 하단표**. + + ⚠ 수량을 여기서 새로 셈하지 않는다 — B08 원단위 전개를 그대로 받아 **제원 조합으로 묶고 + 단위당으로 접기만** 한다(계산 자리는 한 곳, CLAUDE.md 5장). + """ + from B07_DesignDetail.B07_DesignDetail_Engine_Standard_Sheet import build_standard_sheets + from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit_table + from B08_Quantity.B08_Quantity_Router_Material import _collect_structures + + try: + pool = get_db_pool() + async with pool.acquire() as connection: + stored_path = await get_project_storage_relative_path(connection, project_id) + project_root = str(Path(resolve_stored_project_path(stored_path)).resolve()) + except Exception: + logger.exception("B07 표준도 조회 실패(경로): project_id=%s", project_id) + return JSONResponse( + status_code=404, + content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."}, + ) + + try: + structures, names, skipped = await asyncio.to_thread(_collect_structures, project_root) + unit_table = await asyncio.to_thread(build_unit_table, structures, names) + except Exception: + logger.exception("B07 표준도 전개 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "구조물 원단위를 전개하지 못했습니다."}, + ) + + payload = build_standard_sheets(unit_table) + payload["status"] = "success" + payload["project_id"] = str(project_id) + # 왜 안 실렸는지 — 「구조물이 없다」와 「걸러졌다」를 화면이 가릴 수 있어야 한다. + payload["skipped_structures"] = skipped + return JSONResponse(content=payload) diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts index 01d264ee..2d926f7f 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -38,14 +38,22 @@ import { exportDrawing, fetchDesignDrawing, fetchDesignDrawingList, + fetchStandardSheets, + fetchStructureRevision, invalidateDesignDrawing, + putStandardSheetSpec, type CadDrawing, type DesignDrawingItem, type DesignDrawingResponse, type QuantityTable, + type StandardSheetsResponse, } from "./B07_DesignDetail_Api_Fetch"; import { appendStructureEntities } from "./B07_DesignDetail_UI_Cad_Structures"; import { createFrameTemplateEditor } from "./B07_DesignDetail_UI_FrameEdit"; +import { + buildStandardSpecPanel, + type StandardSpecResult, +} from "./B07_DesignDetail_UI_StandardSpec"; /** CAD 앱 수량 패널로 넘기는 설계 컨텍스트 (openwebcad DesignMeta와 동일 형식). */ interface DesignMeta { @@ -94,6 +102,10 @@ const CAD_TOAST_ACTION_MESSAGE = "aislo:b08:toast-action"; * -------------------------------------------------------------------------- */ export async function renderB07DesignDetail(root: HTMLElement): Promise { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + /** 표준도 장 목록 — 제원 폼이 쓰는 값. 진입 때 한 번 받고 저장 뒤 다시 받는다. */ + let standardSheets: StandardSheetsResponse["sheets"] = []; + /** 저장 뒤 폼이 다시 그려질 때 이어서 보여 줄 안내. */ + let specNotes: string[] = []; let workflowState: WorkflowState | undefined; let drawings: DesignDrawingItem[] = []; let drawingError: string | undefined; @@ -107,6 +119,14 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { if (drawingResult.status === "fulfilled") { drawings = drawingResult.value.drawings; devBypass = drawingResult.value.dev_bypass === true; + if (drawings.some((item) => item.kind === "standard") && projectId) { + // 표준도 제원 폼이 쓸 장 목록 — 실패해도 도면은 열려야 하므로 조용히 넘어간다. + try { + standardSheets = (await fetchStandardSheets(projectId)).sheets; + } catch { + standardSheets = []; + } + } } else drawingError = drawingResult.reason instanceof Error @@ -193,7 +213,52 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { const infoPanelHost = document.createElement("div"); infoPanelHost.className = "b07-info-host"; + const showStandardSpec = (drawing: DesignDrawingItem): void => { + const index = Number(/_(\d+)$/.exec(drawing.id)?.[1] ?? "1") - 1; + const sheet = standardSheets[index]; + if (!projectId || !sheet) { + infoPanelHost.replaceChildren(); + return; + } + // 판정된 기울기는 **칸에 적지 않고 도움말로만** 비춘다 — 적어 두면 「안 정함」이 사라진다. + const judged = /1:([\d.]+)/.exec(drawing.label)?.[1] ?? null; + infoPanelHost.replaceChildren( + buildStandardSpecPanel( + sheet, + judged, + async (result: StandardSpecResult) => { + const { revision } = await fetchStructureRevision(projectId); + const saved = await putStandardSheetSpec(projectId, { + ...result, + base_revision: revision, + }); + // 정본이 바뀌었으니 표·그림을 **다시 받아** 그린다 — 화면이 두 번째 정본이 되면 안 된다. + drawingCache.delete(drawing.id); + standardSheets = (await fetchStandardSheets(projectId)).sheets; + // ⚠ 장 제목에 제원이 들어 있다 — 고쳤으면 **좌측 단추 글자도 따라가야** 한다. + // 목록을 통째로 다시 받지 않고 표준도 줄만 갈아 끼운다(2026-09-09 실화면에서 잡음). + for (const [order, item] of drawings.filter((d) => d.kind === "standard").entries()) { + const fresh = standardSheets[order]; + if (!fresh) continue; + item.label = `표준도 ${order + 1}장 (${fresh.title})`; + const name = findButton(item.id)?.querySelector(".b07-drawing-button__name"); + if (name) name.textContent = item.label; + } + specNotes = [`${saved.changed}개소에 반영했습니다.`, ...(saved.notes ?? [])]; + await loadDrawing(drawing, currentIndex); + return specNotes; + }, + specNotes, + ), + ); + specNotes = []; + }; + const updateInfoPanel = (drawing: DesignDrawingItem, response: DesignDrawingResponse): void => { + if (drawing.kind === "standard") { + showStandardSpec(drawing); + return; + } if (drawing.kind !== "cross") { infoPanelHost.replaceChildren(); return; diff --git a/B07_DesignDetail/B07_DesignDetail_UI_StandardSpec.ts b/B07_DesignDetail/B07_DesignDetail_UI_StandardSpec.ts new file mode 100644 index 00000000..e1923b59 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_UI_StandardSpec.ts @@ -0,0 +1,174 @@ +/* ============================================================================= + * B07_DesignDetail_UI_StandardSpec.ts + * 표준도 **제원 입력 칸** — 장 하나가 곧 제원 조합 하나이므로 여기서 고치면 그 조합 전부. + * + * 왜 여기인가 (PLAN 4-5b) — `phase: "detail"` 칸(돌 종류·조달·뒷길이·전면 기울기)을 그리는 + * 화면이 없었다(2026-09-09 실측: B06 구조물 폼은 b05 phase 열 칸만 그림). B06 은 + * 「어디에·몇 m」(배치), 표준도는 「어떤 제원」이다. + * + * ⚠ **빈 칸이 「자동」의 뜻**이다(확정 ⑨). 기울기를 비우면 품셈 표준경사 판정이 돌고, + * 채우면 그 값이 이긴다. 그래서 화면도 **빈 칸을 기본으로** 두고, 판정된 값은 회색 + * 도움말로만 비춘다 — 칸에 미리 적어 두면 「정한 적 없음」이 사라진다. + * ⚠ **막지 않는다.** 실무 도면에 1:0.7·1:0.8 이 실재하므로 범위 밖 값도 받고 안내만 띄운다. + * ========================================================================== */ + +const STONE_KINDS = ["야면석·호박돌", "깬잡석", "깬돌", "견치돌"] as const; +const SUPPLIES = ["채집", "구입"] as const; +const BACK_LENGTHS = ["25", "30", "35", "45", "55", "60", "75"] as const; + +/** 표준도 장 하나 — 서버가 낸 것 중 이 폼이 쓰는 것만. */ +export interface StandardSheetSpec { + key: string; + title: string; + type_id: string; + member_count: number; + options: Record; +} + +export interface StandardSpecResult { + sheet_key: string; + stone_kind: string | null; + stone_supply: string | null; + back_len_cm: string | null; + face_slope_ratio: string | null; +} + +/** 이 종류가 돌쌓기 계열인가 — 옹벽·집수정에는 이 칸들이 뜻이 없다. */ +const STONE_TYPES = new Set(["masonry_wet", "masonry_dry", "boulder_masonry"]); + +function field(label: string, control: HTMLElement, hint?: string): HTMLLabelElement { + const wrap = document.createElement("label"); + wrap.className = "b07-spec__field"; + const name = document.createElement("span"); + name.className = "b07-spec__label"; + name.textContent = label; + wrap.append(name, control); + if (hint) { + const help = document.createElement("small"); + help.className = "b07-spec__hint"; + help.textContent = hint; + wrap.append(help); + } + return wrap; +} + +function select( + choices: readonly string[], + current: unknown, + autoLabel: string, +): HTMLSelectElement { + const el = document.createElement("select"); + el.className = "b07-spec__input"; + // 첫 보기가 **빈 값** — 「정한 적 없음」이 고를 수 있는 상태여야 한다. + const blank = document.createElement("option"); + blank.value = ""; + blank.textContent = autoLabel; + el.append(blank); + for (const choice of choices) { + const option = document.createElement("option"); + option.value = choice; + option.textContent = choice; + el.append(option); + } + el.value = current == null ? "" : String(current); + return el; +} + +/** + * 표준도 제원 폼. `onSave` 는 서버에 넘길 값을 받는다 — 빈 문자열은 **그 칸을 지우라**는 뜻. + */ +export function buildStandardSpecPanel( + sheet: StandardSheetSpec, + judgedSlope: string | null, + onSave: (result: StandardSpecResult) => Promise, + initialNotes: string[] = [], +): HTMLDivElement { + const panel = document.createElement("div"); + panel.className = "b07-spec ui-sidebar-section"; + + const title = document.createElement("h3"); + title.className = "b07-spec__title"; + title.textContent = `제원 — ${sheet.title}`; + const scope = document.createElement("p"); + scope.className = "b07-spec__scope"; + scope.textContent = `이 장의 ${sheet.member_count}개소에 함께 걸립니다.`; + panel.append(title, scope); + + if (!STONE_TYPES.has(sheet.type_id)) { + const none = document.createElement("p"); + none.className = "b07-spec__scope"; + none.textContent = "이 종류는 표준도에서 받는 제원 칸이 아직 없습니다."; + panel.append(none); + return panel; + } + + const options = sheet.options ?? {}; + const kind = select(STONE_KINDS, options.stone_kind, "— 안 정함 —"); + const supply = select(SUPPLIES, options.stone_supply, "— 안 정함(기본 채집) —"); + const back = select(BACK_LENGTHS, options.back_len_cm, "— 안 정함 —"); + + const slope = document.createElement("input"); + slope.className = "b07-spec__input"; + slope.type = "text"; + slope.inputMode = "decimal"; + slope.placeholder = "비우면 자동"; + slope.value = options.face_slope_ratio == null ? "" : String(options.face_slope_ratio); + + panel.append( + field("돌 종류", kind), + field("조달", supply, "비우면 「캔다」로 봅니다."), + field("뒷길이 (㎝)", back, "품셈 일곱 규격 밖이면 물량이 서지 않습니다."), + field( + "전면 기울기 1:n", + slope, + judgedSlope ? `비우면 자동 — 지금 판정값 1:${judgedSlope}` : "비우면 자동으로 판정합니다.", + ), + ); + + const notes = document.createElement("ul"); + notes.className = "b07-spec__notes"; + // ⚠ 저장하면 표·그림을 다시 받으면서 **이 폼이 통째로 새로 그려진다** — 그때 안내가 + // 지워지지 않게 밖에서 들고 있다가 다시 넣는다(2026-09-09 실화면에서 잡음). + const showNotes = (messages: string[]): void => { + notes.replaceChildren( + ...messages.map((text) => { + const item = document.createElement("li"); + item.textContent = text; + return item; + }), + ); + notes.hidden = messages.length === 0; + }; + showNotes(initialNotes); + + const save = document.createElement("button"); + save.type = "button"; + save.className = "b07-spec__save"; + save.textContent = "제원 저장"; + save.addEventListener("click", () => { + void (async () => { + save.disabled = true; + save.textContent = "저장 중…"; + try { + showNotes( + await onSave({ + sheet_key: sheet.key, + stone_kind: kind.value || null, + stone_supply: supply.value || null, + back_len_cm: back.value || null, + face_slope_ratio: slope.value.trim() || null, + }), + ); + } catch (error) { + // 실패도 같은 자리에 적는다 — 조용히 끝나면 사용자는 저장된 줄 안다. + showNotes([error instanceof Error ? error.message : "제원을 저장하지 못했습니다."]); + } finally { + save.disabled = false; + save.textContent = "제원 저장"; + } + })(); + }); + + panel.append(save, notes); + return panel; +} diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Style.css b/B07_DesignDetail/B07_DesignDetail_UI_Style.css index 2e900720..b22aeca4 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Style.css +++ b/B07_DesignDetail/B07_DesignDetail_UI_Style.css @@ -336,3 +336,74 @@ color: var(--color-text-muted); font-size: var(--text-caption); } + +/* 표준도 제원 입력 — 좌측 목록 아래 정보 칸에 선다. 장 하나가 곧 제원 조합 하나다. */ +.b07-spec { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding: var(--spacing-12); + border-radius: var(--radius-lg); +} + +.b07-spec__title { + margin: 0; + font-size: var(--text-body-sm); + font-weight: 600; +} + +.b07-spec__scope { + margin: 0; + color: var(--color-text-muted); + font-size: var(--text-caption); +} + +.b07-spec__field { + display: flex; + flex-direction: column; + gap: 2px; +} + +.b07-spec__label { + color: var(--color-text-muted); + font-size: var(--text-caption); +} + +.b07-spec__input { + width: 100%; + min-width: 0; + padding: var(--spacing-4) var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-buttons); + background: var(--color-surface); + color: var(--color-text); + font: inherit; +} + +/* 「비우면 자동」처럼 **칸의 뜻**을 적는 자리 — 값이 아니라 규칙을 말한다. */ +.b07-spec__hint { + color: var(--color-text-muted); + font-size: var(--text-caption); +} + +.b07-spec__save { + padding: var(--spacing-8); + border: 1px solid transparent; + border-radius: var(--radius-buttons); + background: var(--color-primary, #7c3aed); + color: #fff; + cursor: pointer; +} + +.b07-spec__save:disabled { + opacity: 0.6; + cursor: default; +} + +/* 저장 뒤 안내 — 막지 않고 알리는 자리(품셈 범위 밖 기울기 등). */ +.b07-spec__notes { + margin: 0; + padding-left: var(--spacing-16); + color: var(--color-text-muted); + font-size: var(--text-caption); +} diff --git a/main.py b/main.py index 08689bd8..b3f96e6f 100644 --- a/main.py +++ b/main.py @@ -58,20 +58,21 @@ from B06_Section.B06_Section_Router_HaulPlan import ( ) from B07_DesignDetail.B07_DesignDetail_Router import router as b07_design_router from B07_DesignDetail.B07_DesignDetail_Router_Frame import router as b07_frame_router +from B07_DesignDetail.B07_DesignDetail_Router_Standard import router as b07_standard_router from B08_Quantity.B08_Quantity_Router import router as b08_quantity_router from B08_Quantity.B08_Quantity_Router_Earthwork import router as b08_earthwork_router from B08_Quantity.B08_Quantity_Router_Material import router as b08_material_router from B09_Estimation.B09_Estimation_Router import router as b09_estimation_router from common_util.common_util_audit import note_api_call, record_call_burst - -# 개발환경 전용 — 「확정 없이 다음으로」. **문은 서버가 정본이다** — `ENVIRONMENT` 가 -# 개발이 아니면 세 입구 모두 403 으로 거절한다(화면 단추 숨김은 보조). -from common_util.common_util_dev_unlock_router import router as dev_unlock_router from common_util.common_util_auth import ( require_company, require_project_access, verify_session, ) + +# 개발환경 전용 — 「확정 없이 다음으로」. **문은 서버가 정본이다** — `ENVIRONMENT` 가 +# 개발이 아니면 세 입구 모두 403 으로 거절한다(화면 단추 숨김은 보조). +from common_util.common_util_dev_unlock_router import router as dev_unlock_router from common_util.common_util_resource_monitor import sample_resources_loop from common_util.common_util_temp_cleanup import cleanup_expired_temp_uploads_loop from config.config_db import close_db_pool, get_db_pool, init_db_pool @@ -541,6 +542,7 @@ app.include_router(b06_section_confirm_router, dependencies=protected_with_compa app.include_router(b06_section_haul_plan_router, dependencies=protected_with_company) app.include_router(b07_design_router, dependencies=protected_with_company) app.include_router(b07_frame_router, dependencies=protected_with_company) +app.include_router(b07_standard_router, dependencies=protected_with_company) app.include_router(b08_quantity_router, dependencies=protected_with_company) app.include_router(b08_earthwork_router, dependencies=protected_with_company) app.include_router(b08_material_router, dependencies=protected_with_company)